diff options
| author | 2023-12-15 09:11:47 +0800 | |
|---|---|---|
| committer | 2023-12-15 09:11:47 +0800 | |
| commit | be8de118db913711eb72ae5187d26e54a0055727 (patch) | |
| tree | 96cd6c012dafa3f4015e54edef90df5eaaab0ddb /docs/src/templates/assets/javascripts/components | |
| parent | 9b2d27ba1d91a0d5531bc9c0d52c3887a2dfb2aa (diff) | |
| download | infini-be8de118db913711eb72ae5187d26e54a0055727.tar.gz infini-be8de118db913711eb72ae5187d26e54a0055727.zip | |
refactor(docs): optmst `docs` dir & `deps`
Diffstat (limited to 'docs/src/templates/assets/javascripts/components')
42 files changed, 5789 insertions, 0 deletions
diff --git a/docs/src/templates/assets/javascripts/components/_/index.ts b/docs/src/templates/assets/javascripts/components/_/index.ts new file mode 100644 index 00000000..61c471d9 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/_/index.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2016-2023 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 { getElement, getElements } from "~/browser" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Component type + */ +export type ComponentType = + | "announce" /* Announcement bar */ + | "container" /* Container */ + | "consent" /* Consent */ + | "content" /* Content */ + | "dialog" /* Dialog */ + | "header" /* Header */ + | "header-title" /* Header title */ + | "header-topic" /* Header topic */ + | "main" /* Main area */ + | "outdated" /* Version warning */ + | "palette" /* Color palette */ + | "progress" /* Progress indicator */ + | "search" /* Search */ + | "search-query" /* Search input */ + | "search-result" /* Search results */ + | "search-share" /* Search sharing */ + | "search-suggest" /* Search suggestions */ + | "sidebar" /* Sidebar */ + | "skip" /* Skip link */ + | "source" /* Repository information */ + | "tabs" /* Navigation tabs */ + | "toc" /* Table of contents */ + | "top" /* Back-to-top button */ + +/** + * Component + * + * @template T - Component type + * @template U - Reference type + */ +export type Component< + T extends {} = {}, + U extends HTMLElement = HTMLElement +> = + T & { + ref: U /* Component reference */ + } + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Component type map + */ +interface ComponentTypeMap { + "announce": HTMLElement /* Announcement bar */ + "container": HTMLElement /* Container */ + "consent": HTMLElement /* Consent */ + "content": HTMLElement /* Content */ + "dialog": HTMLElement /* Dialog */ + "header": HTMLElement /* Header */ + "header-title": HTMLElement /* Header title */ + "header-topic": HTMLElement /* Header topic */ + "main": HTMLElement /* Main area */ + "outdated": HTMLElement /* Version warning */ + "palette": HTMLElement /* Color palette */ + "progress": HTMLElement /* Progress indicator */ + "search": HTMLElement /* Search */ + "search-query": HTMLInputElement /* Search input */ + "search-result": HTMLElement /* Search results */ + "search-share": HTMLAnchorElement /* Search sharing */ + "search-suggest": HTMLElement /* Search suggestions */ + "sidebar": HTMLElement /* Sidebar */ + "skip": HTMLAnchorElement /* Skip link */ + "source": HTMLAnchorElement /* Repository information */ + "tabs": HTMLElement /* Navigation tabs */ + "toc": HTMLElement /* Table of contents */ + "top": HTMLAnchorElement /* Back-to-top button */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Retrieve the element for a given component or throw a reference error + * + * @template T - Component type + * + * @param type - Component type + * @param node - Node of reference + * + * @returns Element + */ +export function getComponentElement<T extends ComponentType>( + type: T, node: ParentNode = document +): ComponentTypeMap[T] { + return getElement(`[data-md-component=${type}]`, node) +} + +/** + * Retrieve all elements for a given component + * + * @template T - Component type + * + * @param type - Component type + * @param node - Node of reference + * + * @returns Elements + */ +export function getComponentElements<T extends ComponentType>( + type: T, node: ParentNode = document +): ComponentTypeMap[T][] { + return getElements(`[data-md-component=${type}]`, node) +} diff --git a/docs/src/templates/assets/javascripts/components/announce/index.ts b/docs/src/templates/assets/javascripts/components/announce/index.ts new file mode 100644 index 00000000..dd04b4ff --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/announce/index.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2016-2023 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 { + EMPTY, + Observable, + Subject, + defer, + finalize, + fromEvent, + map, + tap +} from "rxjs" + +import { feature } from "~/_" +import { getElement } from "~/browser" + +import { Component } from "../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Announcement bar + */ +export interface Announce { + hash: number /* Content hash */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch announcement bar + * + * @param el - Announcement bar element + * + * @returns Announcement bar observable + */ +export function watchAnnounce( + el: HTMLElement +): Observable<Announce> { + const button = getElement(".md-typeset > :first-child", el) + return fromEvent(button, "click", { once: true }) + .pipe( + map(() => getElement(".md-typeset", el)), + map(content => ({ hash: __md_hash(content.innerHTML) })) + ) +} + +/** + * Mount announcement bar + * + * @param el - Announcement bar element + * + * @returns Announcement bar component observable + */ +export function mountAnnounce( + el: HTMLElement +): Observable<Component<Announce>> { + if (!feature("announce.dismiss") || !el.childElementCount) + return EMPTY + + /* Support instant navigation - see https://t.ly/3FTme */ + if (!el.hidden) { + const content = getElement(".md-typeset", el) + if (__md_hash(content.innerHTML) === __md_get("__announce")) + el.hidden = true + } + + /* Mount component on subscription */ + return defer(() => { + const push$ = new Subject<Announce>() + push$.subscribe(({ hash }) => { + el.hidden = true + + /* Persist preference in local storage */ + __md_set<number>("__announce", hash) + }) + + /* Create and return component */ + return watchAnnounce(el) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/consent/index.ts b/docs/src/templates/assets/javascripts/components/consent/index.ts new file mode 100644 index 00000000..bc99db58 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/consent/index.ts @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + finalize, + map, + tap +} from "rxjs" + +import { Component } from "../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Consent + */ +export interface Consent { + hidden: boolean /* Consent is hidden */ +} + +/** + * Consent defaults + */ +export interface ConsentDefaults { + analytics?: boolean /* Consent for Analytics */ + github?: boolean /* Consent for GitHub */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + target$: Observable<HTMLElement> /* Target observable */ +} + +/** + * Mount options + */ +interface MountOptions { + target$: Observable<HTMLElement> /* Target observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch consent + * + * @param el - Consent element + * @param options - Options + * + * @returns Consent observable + */ +export function watchConsent( + el: HTMLElement, { target$ }: WatchOptions +): Observable<Consent> { + return target$ + .pipe( + map(target => ({ hidden: target !== el })) + ) +} + +/* ------------------------------------------------------------------------- */ + +/** + * Mount consent + * + * @param el - Consent element + * @param options - Options + * + * @returns Consent component observable + */ +export function mountConsent( + el: HTMLElement, options: MountOptions +): Observable<Component<Consent>> { + const internal$ = new Subject<Consent>() + internal$.subscribe(({ hidden }) => { + el.hidden = hidden + }) + + /* Create and return component */ + return watchConsent(el, options) + .pipe( + tap(state => internal$.next(state)), + finalize(() => internal$.complete()), + map(state => ({ ref: el, ...state })) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/content/_/index.ts b/docs/src/templates/assets/javascripts/components/content/_/index.ts new file mode 100644 index 00000000..899a695c --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/_/index.ts @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2016-2023 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 { Observable, merge } from "rxjs" + +import { Viewport, getElements } from "~/browser" + +import { Component } from "../../_" +import { + Annotation, + mountAnnotationBlock +} from "../annotation" +import { + CodeBlock, + mountCodeBlock +} from "../code" +import { + Details, + mountDetails +} from "../details" +import { + Mermaid, + mountMermaid +} from "../mermaid" +import { + DataTable, + mountDataTable +} from "../table" +import { + ContentTabs, + mountContentTabs +} from "../tabs" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Content + */ +export type Content = + | Annotation + | CodeBlock + | ContentTabs + | DataTable + | Details + | Mermaid + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + target$: Observable<HTMLElement> /* Location target observable */ + print$: Observable<boolean> /* Media print observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount content + * + * This function mounts all components that are found in the content of the + * actual article, including code blocks, data tables and details. + * + * @param el - Content element + * @param options - Options + * + * @returns Content component observable + */ +export function mountContent( + el: HTMLElement, { viewport$, target$, print$ }: MountOptions +): Observable<Component<Content>> { + return merge( + + /* Annotations */ + ...getElements(".annotate:not(.highlight)", el) + .map(child => mountAnnotationBlock(child, { target$, print$ })), + + /* Code blocks */ + ...getElements("pre:not(.mermaid) > code", el) + .map(child => mountCodeBlock(child, { target$, print$ })), + + /* Mermaid diagrams */ + ...getElements("pre.mermaid", el) + .map(child => mountMermaid(child)), + + /* Data tables */ + ...getElements("table:not([class])", el) + .map(child => mountDataTable(child)), + + /* Details */ + ...getElements("details", el) + .map(child => mountDetails(child, { target$, print$ })), + + /* Content tabs */ + ...getElements("[data-tabs]", el) + .map(child => mountContentTabs(child, { viewport$ })) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/content/annotation/_/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/_/index.ts new file mode 100644 index 00000000..c5138fa4 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/annotation/_/index.ts @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + animationFrameScheduler, + auditTime, + combineLatest, + debounceTime, + defer, + delay, + endWith, + filter, + finalize, + fromEvent, + ignoreElements, + map, + merge, + switchMap, + take, + takeUntil, + tap, + throttleTime, + withLatestFrom +} from "rxjs" + +import { + ElementOffset, + getActiveElement, + getElementSize, + watchElementContentOffset, + watchElementFocus, + watchElementOffset, + watchElementVisibility +} from "~/browser" + +import { Component } from "../../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Annotation + */ +export interface Annotation { + active: boolean /* Annotation is active */ + offset: ElementOffset /* Annotation offset */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + target$: Observable<HTMLElement> /* Location target observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch annotation + * + * @param el - Annotation element + * @param container - Containing element + * + * @returns Annotation observable + */ +export function watchAnnotation( + el: HTMLElement, container: HTMLElement +): Observable<Annotation> { + const offset$ = defer(() => combineLatest([ + watchElementOffset(el), + watchElementContentOffset(container) + ])) + .pipe( + map(([{ x, y }, scroll]): ElementOffset => { + const { width, height } = getElementSize(el) + return ({ + x: x - scroll.x + width / 2, + y: y - scroll.y + height / 2 + }) + }) + ) + + /* Actively watch annotation on focus */ + return watchElementFocus(el) + .pipe( + switchMap(active => offset$ + .pipe( + map(offset => ({ active, offset })), + take(+!active || Infinity) + ) + ) + ) +} + +/** + * Mount annotation + * + * @param el - Annotation element + * @param container - Containing element + * @param options - Options + * + * @returns Annotation component observable + */ +export function mountAnnotation( + el: HTMLElement, container: HTMLElement, { target$ }: MountOptions +): Observable<Component<Annotation>> { + const [tooltip, index] = Array.from(el.children) + + /* Mount component on subscription */ + return defer(() => { + const push$ = new Subject<Annotation>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + push$.subscribe({ + + /* Handle emission */ + next({ offset }) { + el.style.setProperty("--md-tooltip-x", `${offset.x}px`) + el.style.setProperty("--md-tooltip-y", `${offset.y}px`) + }, + + /* Handle complete */ + complete() { + el.style.removeProperty("--md-tooltip-x") + el.style.removeProperty("--md-tooltip-y") + } + }) + + /* Start animation only when annotation is visible */ + watchElementVisibility(el) + .pipe( + takeUntil(done$) + ) + .subscribe(visible => { + el.toggleAttribute("data-md-visible", visible) + }) + + /* Toggle tooltip presence to mitigate empty lines when copying */ + merge( + push$.pipe(filter(({ active }) => active)), + push$.pipe(debounceTime(250), filter(({ active }) => !active)) + ) + .subscribe({ + + /* Handle emission */ + next({ active }) { + if (active) + el.prepend(tooltip) + else + tooltip.remove() + }, + + /* Handle complete */ + complete() { + el.prepend(tooltip) + } + }) + + /* Toggle tooltip visibility */ + push$ + .pipe( + auditTime(16, animationFrameScheduler) + ) + .subscribe(({ active }) => { + tooltip.classList.toggle("md-tooltip--active", active) + }) + + /* Track relative origin of tooltip */ + push$ + .pipe( + throttleTime(125, animationFrameScheduler), + filter(() => !!el.offsetParent), + map(() => el.offsetParent!.getBoundingClientRect()), + map(({ x }) => x) + ) + .subscribe({ + + /* Handle emission */ + next(origin) { + if (origin) + el.style.setProperty("--md-tooltip-0", `${-origin}px`) + else + el.style.removeProperty("--md-tooltip-0") + }, + + /* Handle complete */ + complete() { + el.style.removeProperty("--md-tooltip-0") + } + }) + + /* Allow to copy link without scrolling to anchor */ + fromEvent<MouseEvent>(index, "click") + .pipe( + takeUntil(done$), + filter(ev => !(ev.metaKey || ev.ctrlKey)) + ) + .subscribe(ev => { + ev.stopPropagation() + ev.preventDefault() + }) + + /* Allow to open link in new tab or blur on close */ + fromEvent<MouseEvent>(index, "mousedown") + .pipe( + takeUntil(done$), + withLatestFrom(push$) + ) + .subscribe(([ev, { active }]) => { + + /* Open in new tab */ + if (ev.button !== 0 || ev.metaKey || ev.ctrlKey) { + ev.preventDefault() + + /* Close annotation */ + } else if (active) { + ev.preventDefault() + + /* Focus parent annotation, if any */ + const parent = el.parentElement!.closest(".md-annotation") + if (parent instanceof HTMLElement) + parent.focus() + else + getActiveElement()?.blur() + } + }) + + /* Open and focus annotation on location target */ + target$ + .pipe( + takeUntil(done$), + filter(target => target === tooltip), + delay(125) + ) + .subscribe(() => el.focus()) + + /* Create and return component */ + return watchAnnotation(el, container) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/content/annotation/block/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/block/index.ts new file mode 100644 index 00000000..c73b01fa --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/annotation/block/index.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2016-2023 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 { EMPTY, Observable, defer } from "rxjs" + +import { Component } from "../../../_" +import { Annotation } from "../_" +import { mountAnnotationList } from "../list" + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + target$: Observable<HTMLElement> /* Location target observable */ + print$: Observable<boolean> /* Media print observable */ +} + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Find list element directly following a block + * + * @param el - Annotation block element + * + * @returns List element or nothing + */ +function findList(el: HTMLElement): HTMLElement | undefined { + if (el.nextElementSibling) { + const sibling = el.nextElementSibling as HTMLElement + if (sibling.tagName === "OL") + return sibling + + /* Skip empty paragraphs - see https://bit.ly/3r4ZJ2O */ + else if (sibling.tagName === "P" && !sibling.children.length) + return findList(sibling) + } + + /* Everything else */ + return undefined +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount annotation block + * + * @param el - Annotation block element + * @param options - Options + * + * @returns Annotation component observable + */ +export function mountAnnotationBlock( + el: HTMLElement, options: MountOptions +): Observable<Component<Annotation>> { + return defer(() => { + const list = findList(el) + return typeof list !== "undefined" + ? mountAnnotationList(list, el, options) + : EMPTY + }) +} diff --git a/docs/src/templates/assets/javascripts/components/content/annotation/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/index.ts new file mode 100644 index 00000000..c593b723 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/annotation/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" +export * from "./block" +export * from "./list" diff --git a/docs/src/templates/assets/javascripts/components/content/annotation/list/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/list/index.ts new file mode 100644 index 00000000..725dd583 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/annotation/list/index.ts @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2016-2023 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 { + EMPTY, + Observable, + Subject, + defer, + endWith, + finalize, + ignoreElements, + merge, + share, + takeUntil +} from "rxjs" + +import { + getElement, + getElements, + getOptionalElement +} from "~/browser" +import { renderAnnotation } from "~/templates" + +import { Component } from "../../../_" +import { + Annotation, + mountAnnotation +} from "../_" + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + target$: Observable<HTMLElement> /* Location target observable */ + print$: Observable<boolean> /* Media print observable */ +} + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Find all annotation hosts in the containing element + * + * @param container - Containing element + * + * @returns Annotation hosts + */ +function findHosts(container: HTMLElement): HTMLElement[] { + return container.tagName === "CODE" + ? getElements(".c, .c1, .cm", container) + : [container] +} + +/** + * Find all annotation markers in the containing element + * + * @param container - Containing element + * + * @returns Annotation markers + */ +function findMarkers(container: HTMLElement): Text[] { + const markers: Text[] = [] + for (const el of findHosts(container)) { + const nodes: Text[] = [] + + /* Find all text nodes in current element */ + const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT) + for (let node = it.nextNode(); node; node = it.nextNode()) + nodes.push(node as Text) + + /* Find all markers in each text node */ + for (let text of nodes) { + let match: RegExpExecArray | null + + /* Split text at marker and add to list */ + while ((match = /(\(\d+\))(!)?/.exec(text.textContent!))) { + const [, id, force] = match + if (typeof force === "undefined") { + const marker = text.splitText(match.index) + text = marker.splitText(id.length) + markers.push(marker) + + /* Replace entire text with marker */ + } else { + text.textContent = id + markers.push(text) + break + } + } + } + } + return markers +} + +/** + * Swap the child nodes of two elements + * + * @param source - Source element + * @param target - Target element + */ +function swap(source: HTMLElement, target: HTMLElement): void { + target.append(...Array.from(source.childNodes)) +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount annotation list + * + * This function analyzes the containing code block and checks for markers + * referring to elements in the given annotation list. If no markers are found, + * the list is left untouched. Otherwise, list elements are rendered as + * annotations inside the code block. + * + * @param el - Annotation list element + * @param container - Containing element + * @param options - Options + * + * @returns Annotation component observable + */ +export function mountAnnotationList( + el: HTMLElement, container: HTMLElement, { target$, print$ }: MountOptions +): Observable<Component<Annotation>> { + + /* Compute prefix for tooltip anchors */ + const parent = container.closest("[id]") + const prefix = parent?.id + + /* Find and replace all markers with empty annotations */ + const annotations = new Map<string, HTMLElement>() + for (const marker of findMarkers(container)) { + const [, id] = marker.textContent!.match(/\((\d+)\)/)! + if (getOptionalElement(`:scope > li:nth-child(${id})`, el)) { + annotations.set(id, renderAnnotation(id, prefix)) + marker.replaceWith(annotations.get(id)!) + } + } + + /* Keep list if there are no annotations to render */ + if (annotations.size === 0) + return EMPTY + + /* Mount component on subscription */ + return defer(() => { + const push$ = new Subject() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + + /* Retrieve container pairs for swapping */ + const pairs: [HTMLElement, HTMLElement][] = [] + for (const [id, annotation] of annotations) + pairs.push([ + getElement(".md-typeset", annotation), + getElement(`:scope > li:nth-child(${id})`, el) + ]) + + /* Handle print mode - see https://bit.ly/3rgPdpt */ + print$.pipe(takeUntil(done$)) + .subscribe(active => { + el.hidden = !active + + /* Add class to discern list element */ + el.classList.toggle("md-annotation-list", active) + + /* Show annotations in code block or list (print) */ + for (const [inner, child] of pairs) + if (!active) + swap(child, inner) + else + swap(inner, child) + }) + + /* Create and return component */ + return merge(...[...annotations] + .map(([, annotation]) => ( + mountAnnotation(annotation, container, { target$ }) + )) + ) + .pipe( + finalize(() => push$.complete()), + share() + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/content/code/_/index.ts b/docs/src/templates/assets/javascripts/components/content/code/_/index.ts new file mode 100644 index 00000000..ccc09339 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/code/_/index.ts @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2016-2023 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 ClipboardJS from "clipboard" +import { + EMPTY, + Observable, + Subject, + defer, + distinctUntilChanged, + distinctUntilKeyChanged, + filter, + finalize, + map, + mergeWith, + switchMap, + take, + tap +} from "rxjs" + +import { feature } from "~/_" +import { + getElementContentSize, + watchElementSize, + watchElementVisibility +} from "~/browser" +import { renderClipboardButton } from "~/templates" + +import { Component } from "../../../_" +import { + Annotation, + mountAnnotationList +} from "../../annotation" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Code block + */ +export interface CodeBlock { + scrollable: boolean /* Code block overflows */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + target$: Observable<HTMLElement> /* Location target observable */ + print$: Observable<boolean> /* Media print observable */ +} + +/* ---------------------------------------------------------------------------- + * Data + * ------------------------------------------------------------------------- */ + +/** + * Global sequence number for code blocks + */ +let sequence = 0 + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Find candidate list element directly following a code block + * + * @param el - Code block element + * + * @returns List element or nothing + */ +function findCandidateList(el: HTMLElement): HTMLElement | undefined { + if (el.nextElementSibling) { + const sibling = el.nextElementSibling as HTMLElement + if (sibling.tagName === "OL") + return sibling + + /* Skip empty paragraphs - see https://bit.ly/3r4ZJ2O */ + else if (sibling.tagName === "P" && !sibling.children.length) + return findCandidateList(sibling) + } + + /* Everything else */ + return undefined +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch code block + * + * This function monitors size changes of the viewport, as well as switches of + * content tabs with embedded code blocks, as both may trigger overflow. + * + * @param el - Code block element + * + * @returns Code block observable + */ +export function watchCodeBlock( + el: HTMLElement +): Observable<CodeBlock> { + return watchElementSize(el) + .pipe( + map(({ width }) => { + const content = getElementContentSize(el) + return { + scrollable: content.width > width + } + }), + distinctUntilKeyChanged("scrollable") + ) +} + +/** + * Mount code block + * + * This function ensures that an overflowing code block is focusable through + * keyboard, so it can be scrolled without a mouse to improve on accessibility. + * Furthermore, if code annotations are enabled, they are mounted if and only + * if the code block is currently visible, e.g., not in a hidden content tab. + * + * Note that code blocks may be mounted eagerly or lazily. If they're mounted + * lazily (on first visibility), code annotation anchor links will not work, + * as they are evaluated on initial page load, and code annotations in general + * might feel a little bumpier. + * + * @param el - Code block element + * @param options - Options + * + * @returns Code block and annotation component observable + */ +export function mountCodeBlock( + el: HTMLElement, options: MountOptions +): Observable<Component<CodeBlock | Annotation>> { + const { matches: hover } = matchMedia("(hover)") + + /* Defer mounting of code block - see https://bit.ly/3vHVoVD */ + const factory$ = defer(() => { + const push$ = new Subject<CodeBlock>() + push$.subscribe(({ scrollable }) => { + if (scrollable && hover) + el.setAttribute("tabindex", "0") + else + el.removeAttribute("tabindex") + }) + + /* Render button for Clipboard.js integration */ + if (ClipboardJS.isSupported()) { + if (el.closest(".copy") || ( + feature("content.code.copy") && !el.closest(".no-copy") + )) { + const parent = el.closest("pre")! + parent.id = `__code_${sequence++}` + parent.insertBefore( + renderClipboardButton(parent.id), + el + ) + } + } + + /* Handle code annotations */ + const container = el.closest(".highlight") + if (container instanceof HTMLElement) { + const list = findCandidateList(container) + + /* Mount code annotations, if enabled */ + if (typeof list !== "undefined" && ( + container.classList.contains("annotate") || + feature("content.code.annotate") + )) { + const annotations$ = mountAnnotationList(list, el, options) + + /* Create and return component */ + return watchCodeBlock(el) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })), + mergeWith( + watchElementSize(container) + .pipe( + map(({ width, height }) => width && height), + distinctUntilChanged(), + switchMap(active => active ? annotations$ : EMPTY) + ) + ) + ) + } + } + + /* Create and return component */ + return watchCodeBlock(el) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) + + /* Mount code block lazily */ + if (feature("content.lazy")) + return watchElementVisibility(el) + .pipe( + filter(visible => visible), + take(1), + switchMap(() => factory$) + ) + + /* Mount code block */ + return factory$ +} diff --git a/docs/src/templates/assets/javascripts/components/content/code/index.ts b/docs/src/templates/assets/javascripts/components/content/code/index.ts new file mode 100644 index 00000000..3f86e2b4 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/code/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" diff --git a/docs/src/templates/assets/javascripts/components/content/details/index.ts b/docs/src/templates/assets/javascripts/components/content/details/index.ts new file mode 100644 index 00000000..17bfae45 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/details/index.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + defer, + filter, + finalize, + map, + merge, + tap +} from "rxjs" + +import { Component } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Details + */ +export interface Details { + action: "open" | "close" /* Details state */ + reveal?: boolean /* Details is revealed */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + target$: Observable<HTMLElement> /* Location target observable */ + print$: Observable<boolean> /* Media print observable */ +} + +/** + * Mount options + */ +interface MountOptions { + target$: Observable<HTMLElement> /* Location target observable */ + print$: Observable<boolean> /* Media print observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch details + * + * @param el - Details element + * @param options - Options + * + * @returns Details observable + */ +export function watchDetails( + el: HTMLDetailsElement, { target$, print$ }: WatchOptions +): Observable<Details> { + let open = true + return merge( + + /* Open and focus details on location target */ + target$ + .pipe( + map(target => target.closest("details:not([open])")!), + filter(details => el === details), + map(() => ({ + action: "open", reveal: true + }) as Details) + ), + + /* Open details on print and close afterwards */ + print$ + .pipe( + filter(active => active || !open), + tap(() => open = el.open), + map(active => ({ + action: active ? "open" : "close" + }) as Details) + ) + ) +} + +/** + * Mount details + * + * This function ensures that `details` tags are opened on anchor jumps and + * prior to printing, so the whole content of the page is visible. + * + * @param el - Details element + * @param options - Options + * + * @returns Details component observable + */ +export function mountDetails( + el: HTMLDetailsElement, options: MountOptions +): Observable<Component<Details>> { + return defer(() => { + const push$ = new Subject<Details>() + push$.subscribe(({ action, reveal }) => { + el.toggleAttribute("open", action === "open") + if (reveal) + el.scrollIntoView() + }) + + /* Create and return component */ + return watchDetails(el, options) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/content/index.ts b/docs/src/templates/assets/javascripts/components/content/index.ts new file mode 100644 index 00000000..a29d8b41 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" +export * from "./annotation" +export * from "./code" +export * from "./details" +export * from "./table" +export * from "./tabs" diff --git a/docs/src/templates/assets/javascripts/components/content/mermaid/index.css b/docs/src/templates/assets/javascripts/components/content/mermaid/index.css new file mode 100644 index 00000000..3092b8ec --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/mermaid/index.css @@ -0,0 +1,430 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +/* ---------------------------------------------------------------------------- + * Rules: general + * ------------------------------------------------------------------------- */ + +/* General node */ +.node circle, +.node ellipse, +.node path, +.node polygon, +.node rect { + fill: var(--md-mermaid-node-bg-color); + stroke: var(--md-mermaid-node-fg-color); +} + +/* General marker */ +marker { + fill: var(--md-mermaid-edge-color) !important; +} + +/* General edge label */ +.edgeLabel .label rect { + fill: transparent; +} + +/* ---------------------------------------------------------------------------- + * Rules: flowcharts + * ------------------------------------------------------------------------- */ + +/* Flowchart node label */ +.label { + color: var(--md-mermaid-label-fg-color); + font-family: var(--md-mermaid-font-family); +} + +/* Flowchart node label container */ +.label foreignObject { + overflow: visible; + line-height: initial; +} + +/* Flowchart edge label in node label */ +.label div .edgeLabel { + color: var(--md-mermaid-label-fg-color); + background-color: var(--md-mermaid-label-bg-color); +} + +/* Flowchart edge label */ +.edgeLabel, +.edgeLabel rect { + color: var(--md-mermaid-edge-color); + background-color: var(--md-mermaid-label-bg-color); + fill: var(--md-mermaid-label-bg-color); +} + +/* Flowchart edge path */ +.edgePath .path, +.flowchart-link { + stroke: var(--md-mermaid-edge-color); + stroke-width: .05rem; +} + +/* Flowchart arrow head */ +.edgePath .arrowheadPath { + fill: var(--md-mermaid-edge-color); + stroke: none; +} + +/* Flowchart subgraph */ +.cluster rect { + fill: var(--md-default-fg-color--lightest); + stroke: var(--md-default-fg-color--lighter); +} + +/* Flowchart subgraph labels */ +.cluster span { + color: var(--md-mermaid-label-fg-color); + font-family: var(--md-mermaid-font-family); +} + +/* Flowchart markers */ +g #flowchart-circleStart, +g #flowchart-circleEnd, +g #flowchart-crossStart, +g #flowchart-crossEnd, +g #flowchart-pointStart, +g #flowchart-pointEnd { + stroke: none; +} + +/* ---------------------------------------------------------------------------- + * Rules: class diagrams + * ------------------------------------------------------------------------- */ + +/* Class group node */ +g.classGroup line, +g.classGroup rect { + fill: var(--md-mermaid-node-bg-color); + stroke: var(--md-mermaid-node-fg-color); +} + +/* Class group node text */ +g.classGroup text { + font-family: var(--md-mermaid-font-family); + fill: var(--md-mermaid-label-fg-color); +} + +/* Class label box */ +.classLabel .box { + background-color: var(--md-mermaid-label-bg-color); + opacity: 1; + fill: var(--md-mermaid-label-bg-color); +} + +/* Class label text */ +.classLabel .label { + font-family: var(--md-mermaid-font-family); + fill: var(--md-mermaid-label-fg-color); +} + +/* Class group divider */ +.node .divider { + stroke: var(--md-mermaid-node-fg-color); +} + +/* Class relation */ +.relation { + stroke: var(--md-mermaid-edge-color); +} + +/* Class relation cardinality */ +.cardinality { + font-family: var(--md-mermaid-font-family); + fill: var(--md-mermaid-label-fg-color); +} + +/* Class relation cardinality text */ +.cardinality text { + fill: inherit !important; +} + +/* Class extension, composition and dependency marker */ +defs #classDiagram-extensionStart, +defs #classDiagram-extensionEnd, +defs #classDiagram-compositionStart, +defs #classDiagram-compositionEnd, +defs #classDiagram-dependencyStart, +defs #classDiagram-dependencyEnd { + fill: var(--md-mermaid-edge-color) !important; + stroke: var(--md-mermaid-edge-color) !important; +} + +/* Class aggregation marker */ +defs #classDiagram-aggregationStart, +defs #classDiagram-aggregationEnd { + fill: var(--md-mermaid-label-bg-color) !important; + stroke: var(--md-mermaid-edge-color) !important; +} + +/* ---------------------------------------------------------------------------- + * Rules: state diagrams + * ------------------------------------------------------------------------- */ + +/* State group node */ +g.stateGroup rect { + fill: var(--md-mermaid-node-bg-color); + stroke: var(--md-mermaid-node-fg-color); +} + +/* State group title */ +g.stateGroup .state-title { + font-family: var(--md-mermaid-font-family); + fill: var(--md-mermaid-label-fg-color) !important; +} + +/* State group background */ +g.stateGroup .composit { + fill: var(--md-mermaid-label-bg-color); +} + +/* State node label */ +.nodeLabel { + color: var(--md-mermaid-label-fg-color); + font-family: var(--md-mermaid-font-family); +} + +/* State start and end marker */ +.start-state, +.node circle.state-start, +.node circle.state-end { + fill: var(--md-mermaid-edge-color); + stroke: none; +} + +/* State end marker */ +.end-state-outer, +.end-state-inner { + fill: var(--md-mermaid-edge-color); +} + +/* State end marker */ +.end-state-inner, +.node circle.state-end { + stroke: var(--md-mermaid-label-bg-color); +} + +/* State transition */ +.transition { + stroke: var(--md-mermaid-edge-color); +} + +/* State fork and join */ +[id^=state-fork] rect, +[id^=state-join] rect { + fill: var(--md-mermaid-edge-color) !important; + stroke: none !important; +} + +/* State cluster (yes, 2x... Mermaid WTF) */ +.statediagram-cluster.statediagram-cluster .inner { + fill: var(--md-default-bg-color); +} + +/* State cluster node */ +.statediagram-cluster rect { + fill: var(--md-mermaid-node-bg-color); + stroke: var(--md-mermaid-node-fg-color); +} + +/* State cluster divider */ +.statediagram-state rect.divider { + fill: var(--md-default-fg-color--lightest); + stroke: var(--md-default-fg-color--lighter); +} + +/* State diagram markers */ +defs #statediagram-barbEnd { + stroke: var(--md-mermaid-edge-color); +} + +/* ---------------------------------------------------------------------------- + * Rules: entity-relationship diagrams + * ------------------------------------------------------------------------- */ + +/* Attribute box */ +.attributeBoxEven, +.attributeBoxOdd { + fill: var(--md-mermaid-node-bg-color); + stroke: var(--md-mermaid-node-fg-color); +} + +/* Entity node */ +.entityBox { + fill: var(--md-mermaid-label-bg-color); + stroke: var(--md-mermaid-node-fg-color); +} + +/* Entity node label */ +.entityLabel { + font-family: var(--md-mermaid-font-family); + fill: var(--md-mermaid-label-fg-color); +} + +/* Entity relationship label container */ +.relationshipLabelBox { + background-color: var(--md-mermaid-label-bg-color); + opacity: 1; + fill: var(--md-mermaid-label-bg-color); + fill-opacity: 1; +} + +/* Entity relationship label */ +.relationshipLabel { + fill: var(--md-mermaid-label-fg-color); +} + +/* Entity relationship line { */ +.relationshipLine { + stroke: var(--md-mermaid-edge-color); +} + +/* Entity relationship line markers */ +defs #ZERO_OR_ONE_START *, +defs #ZERO_OR_ONE_END *, +defs #ZERO_OR_MORE_START *, +defs #ZERO_OR_MORE_END *, +defs #ONLY_ONE_START *, +defs #ONLY_ONE_END *, +defs #ONE_OR_MORE_START *, +defs #ONE_OR_MORE_END * { + stroke: var(--md-mermaid-edge-color) !important; +} + +/* Entity relationship line markers */ +defs #ZERO_OR_MORE_START circle, +defs #ZERO_OR_MORE_END circle { + fill: var(--md-mermaid-label-bg-color); +} + +/* ---------------------------------------------------------------------------- + * Rules: sequence diagrams + * ------------------------------------------------------------------------- */ + +/* Sequence actor */ +.actor { + fill: var(--md-mermaid-sequence-actor-bg-color); + stroke: var(--md-mermaid-sequence-actor-border-color); +} + +/* Sequence actor text */ +text.actor > tspan { + font-family: var(--md-mermaid-font-family); + fill: var(--md-mermaid-sequence-actor-fg-color); +} + +/* Sequence actor line */ +line { + stroke: var(--md-mermaid-sequence-actor-line-color); +} + +/* Sequence actor */ +.actor-man circle, +.actor-man line { + fill: var(--md-mermaid-sequence-actorman-bg-color); + stroke: var(--md-mermaid-sequence-actorman-line-color); +} + +/* Sequence message line */ +.messageLine0, +.messageLine1 { + stroke: var(--md-mermaid-sequence-message-line-color); +} + +/* Sequence note */ +.note { + fill: var(--md-mermaid-sequence-note-bg-color); + stroke: var(--md-mermaid-sequence-note-border-color); +} + +/* Sequence message, loop and note text */ +.messageText, +.loopText, +.loopText > tspan, +.noteText > tspan { + font-family: var(--md-mermaid-font-family) !important; + stroke: none; +} + +/* Sequence message text */ +.messageText { + fill: var(--md-mermaid-sequence-message-fg-color); +} + +/* Sequence loop text */ +.loopText, +.loopText > tspan { + fill: var(--md-mermaid-sequence-loop-fg-color); +} + +/* Sequence note text */ +.noteText > tspan { + fill: var(--md-mermaid-sequence-note-fg-color); +} + +/* Sequence arrow head */ +#arrowhead path { + fill: var(--md-mermaid-sequence-message-line-color); + stroke: none; +} + +/* Sequence loop line */ +.loopLine { + fill: var(--md-mermaid-sequence-loop-bg-color); + stroke: var(--md-mermaid-sequence-loop-border-color); +} + +/* Sequence label box */ +.labelBox { + fill: var(--md-mermaid-sequence-label-bg-color); + stroke: none; +} + +/* Sequence label text */ +.labelText, +.labelText > span { + font-family: var(--md-mermaid-font-family); + fill: var(--md-mermaid-sequence-label-fg-color); +} + +/* Sequence number */ +.sequenceNumber { + fill: var(--md-mermaid-sequence-number-fg-color); +} + +/* Sequence rectangle */ +rect.rect { + fill: var(--md-mermaid-sequence-box-bg-color); + stroke: none; +} + +/* Sequence rectangle text */ +rect.rect + text.text { + fill: var(--md-mermaid-sequence-box-fg-color); +} + +/* Sequence diagram markers */ +defs #sequencenumber { + fill: var(--md-mermaid-sequence-number-bg-color) !important; +} diff --git a/docs/src/templates/assets/javascripts/components/content/mermaid/index.ts b/docs/src/templates/assets/javascripts/components/content/mermaid/index.ts new file mode 100644 index 00000000..3f6480fd --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/mermaid/index.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + map, + of, + shareReplay, + tap +} from "rxjs" + +import { watchScript } from "~/browser" +import { h } from "~/utilities" + +import { Component } from "../../_" + +import themeCSS from "./index.css" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Mermaid diagram + */ +export interface Mermaid {} + +/* ---------------------------------------------------------------------------- + * Data + * ------------------------------------------------------------------------- */ + +/** + * Mermaid instance observable + */ +let mermaid$: Observable<void> + +/** + * Global sequence number for diagrams + */ +let sequence = 0 + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Fetch Mermaid script + * + * @returns Mermaid scripts observable + */ +function fetchScripts(): Observable<void> { + return typeof mermaid === "undefined" || mermaid instanceof Element + ? watchScript("https://unpkg.com/mermaid@9.4.3/dist/mermaid.min.js") + : of(undefined) +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount Mermaid diagram + * + * @param el - Code block element + * + * @returns Mermaid diagram component observable + */ +export function mountMermaid( + el: HTMLElement +): Observable<Component<Mermaid>> { + el.classList.remove("mermaid") // Hack: mitigate https://bit.ly/3CiN6Du + mermaid$ ||= fetchScripts() + .pipe( + tap(() => mermaid.initialize({ + startOnLoad: false, + themeCSS, + sequence: { + actorFontSize: "16px", // Hack: mitigate https://bit.ly/3y0NEi3 + messageFontSize: "16px", + noteFontSize: "16px" + } + })), + map(() => undefined), + shareReplay(1) + ) + + /* Render diagram */ + mermaid$.subscribe(() => { + el.classList.add("mermaid") // Hack: mitigate https://bit.ly/3CiN6Du + const id = `__mermaid_${sequence++}` + + /* Create host element to replace code block */ + const host = h("div", { class: "mermaid" }) + const text = el.textContent + + /* Render and inject diagram */ + mermaid.mermaidAPI.render(id, text, (svg: string, fn: Function) => { + + /* Create a shadow root and inject diagram */ + const shadow = host.attachShadow({ mode: "closed" }) + shadow.innerHTML = svg + + /* Replace code block with diagram and bind functions */ + el.replaceWith(host) + fn?.(shadow) + }) + }) + + /* Create and return component */ + return mermaid$ + .pipe( + map(() => ({ ref: el })) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/content/table/index.ts b/docs/src/templates/assets/javascripts/components/content/table/index.ts new file mode 100644 index 00000000..c318e7a6 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/table/index.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2016-2023 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 { Observable, of } from "rxjs" + +import { renderTable } from "~/templates" +import { h } from "~/utilities" + +import { Component } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Data table + */ +export interface DataTable {} + +/* ---------------------------------------------------------------------------- + * Data + * ------------------------------------------------------------------------- */ + +/** + * Sentinel for replacement + */ +const sentinel = h("table") + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount data table + * + * This function wraps a data table in another scrollable container, so it can + * be smoothly scrolled on smaller screen sizes and won't break the layout. + * + * @param el - Data table element + * + * @returns Data table component observable + */ +export function mountDataTable( + el: HTMLElement +): Observable<Component<DataTable>> { + el.replaceWith(sentinel) + sentinel.replaceWith(renderTable(el)) + + /* Create and return component */ + return of({ ref: el }) +} diff --git a/docs/src/templates/assets/javascripts/components/content/tabs/index.ts b/docs/src/templates/assets/javascripts/components/content/tabs/index.ts new file mode 100644 index 00000000..f57447e2 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/content/tabs/index.ts @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + animationFrameScheduler, + asyncScheduler, + auditTime, + combineLatest, + defer, + endWith, + finalize, + fromEvent, + ignoreElements, + map, + merge, + skip, + startWith, + subscribeOn, + takeUntil, + tap, + withLatestFrom +} from "rxjs" + +import { feature } from "~/_" +import { + Viewport, + getElement, + getElementContentOffset, + getElementContentSize, + getElementOffset, + getElementSize, + getElements, + watchElementContentOffset, + watchElementSize +} from "~/browser" +import { renderTabbedControl } from "~/templates" + +import { Component } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Content tabs + */ +export interface ContentTabs { + active: HTMLLabelElement /* Active tab label */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch content tabs + * + * @param el - Content tabs element + * + * @returns Content tabs observable + */ +export function watchContentTabs( + el: HTMLElement +): Observable<ContentTabs> { + const inputs = getElements<HTMLInputElement>(":scope > input", el) + const initial = inputs.find(input => input.checked) || inputs[0] + return merge(...inputs.map(input => fromEvent(input, "change") + .pipe( + map(() => getElement<HTMLLabelElement>(`label[for="${input.id}"]`)) + ) + )) + .pipe( + startWith(getElement<HTMLLabelElement>(`label[for="${initial.id}"]`)), + map(active => ({ active })) + ) +} + +/** + * Mount content tabs + * + * This function scrolls the active tab into view. While this functionality is + * provided by browsers as part of `scrollInfoView`, browsers will always also + * scroll the vertical axis, which we do not want. Thus, we decided to provide + * this functionality ourselves. + * + * @param el - Content tabs element + * @param options - Options + * + * @returns Content tabs component observable + */ +export function mountContentTabs( + el: HTMLElement, { viewport$ }: MountOptions +): Observable<Component<ContentTabs>> { + + /* Render content tab previous button for pagination */ + const prev = renderTabbedControl("prev") + el.append(prev) + + /* Render content tab next button for pagination */ + const next = renderTabbedControl("next") + el.append(next) + + /* Mount component on subscription */ + const container = getElement(".tabbed-labels", el) + return defer(() => { + const push$ = new Subject<ContentTabs>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + combineLatest([push$, watchElementSize(el)]) + .pipe( + auditTime(1, animationFrameScheduler), + takeUntil(done$) + ) + .subscribe({ + + /* Handle emission */ + next([{ active }, size]) { + const offset = getElementOffset(active) + const { width } = getElementSize(active) + + /* Set tab indicator offset and width */ + el.style.setProperty("--md-indicator-x", `${offset.x}px`) + el.style.setProperty("--md-indicator-width", `${width}px`) + + /* Scroll container to active content tab */ + const content = getElementContentOffset(container) + if ( + offset.x < content.x || + offset.x + width > content.x + size.width + ) + container.scrollTo({ + left: Math.max(0, offset.x - 16), + behavior: "smooth" + }) + }, + + /* Handle complete */ + complete() { + el.style.removeProperty("--md-indicator-x") + el.style.removeProperty("--md-indicator-width") + } + }) + + /* Hide content tab buttons on borders */ + combineLatest([ + watchElementContentOffset(container), + watchElementSize(container) + ]) + .pipe( + takeUntil(done$) + ) + .subscribe(([offset, size]) => { + const content = getElementContentSize(container) + prev.hidden = offset.x < 16 + next.hidden = offset.x > content.width - size.width - 16 + }) + + /* Paginate content tab container on click */ + merge( + fromEvent(prev, "click").pipe(map(() => -1)), + fromEvent(next, "click").pipe(map(() => +1)) + ) + .pipe( + takeUntil(done$) + ) + .subscribe(direction => { + const { width } = getElementSize(container) + container.scrollBy({ + left: width * direction, + behavior: "smooth" + }) + }) + + /* Set up linking of content tabs, if enabled */ + if (feature("content.tabs.link")) + push$.pipe( + skip(1), + withLatestFrom(viewport$) + ) + .subscribe(([{ active }, { offset }]) => { + const tab = active.innerText.trim() + if (active.hasAttribute("data-md-switching")) { + active.removeAttribute("data-md-switching") + + /* Determine viewport offset of active tab */ + } else { + const y = el.offsetTop - offset.y + + /* Passively activate other tabs */ + for (const set of getElements("[data-tabs]")) + for (const input of getElements<HTMLInputElement>( + ":scope > input", set + )) { + const label = getElement(`label[for="${input.id}"]`) + if ( + label !== active && + label.innerText.trim() === tab + ) { + label.setAttribute("data-md-switching", "") + input.click() + break + } + } + + /* Bring active tab into view */ + window.scrollTo({ + top: el.offsetTop - y + }) + + /* Persist active tabs in local storage */ + const tabs = __md_get<string[]>("__tabs") || [] + __md_set("__tabs", [...new Set([tab, ...tabs])]) + } + }) + + /* Pause media (audio, video) on switch - see https://bit.ly/3Bk6cel */ + push$.pipe(takeUntil(done$)) + .subscribe(() => { + for (const media of getElements<HTMLAudioElement>("audio, video", el)) + media.pause() + }) + + /* Create and return component */ + return watchContentTabs(el) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) + .pipe( + subscribeOn(asyncScheduler) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/dialog/index.ts b/docs/src/templates/assets/javascripts/components/dialog/index.ts new file mode 100644 index 00000000..6ff1bd44 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/dialog/index.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + defer, + delay, + finalize, + map, + merge, + of, + switchMap, + tap +} from "rxjs" + +import { getElement } from "~/browser" + +import { Component } from "../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Dialog + */ +export interface Dialog { + message: string /* Dialog message */ + active: boolean /* Dialog is active */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + alert$: Subject<string> /* Alert subject */ +} + +/** + * Mount options + */ +interface MountOptions { + alert$: Subject<string> /* Alert subject */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch dialog + * + * @param _el - Dialog element + * @param options - Options + * + * @returns Dialog observable + */ +export function watchDialog( + _el: HTMLElement, { alert$ }: WatchOptions +): Observable<Dialog> { + return alert$ + .pipe( + switchMap(message => merge( + of(true), + of(false).pipe(delay(2000)) + ) + .pipe( + map(active => ({ message, active })) + ) + ) + ) +} + +/** + * Mount dialog + * + * This function reveals the dialog in the right corner when a new alert is + * emitted through the subject that is passed as part of the options. + * + * @param el - Dialog element + * @param options - Options + * + * @returns Dialog component observable + */ +export function mountDialog( + el: HTMLElement, options: MountOptions +): Observable<Component<Dialog>> { + const inner = getElement(".md-typeset", el) + return defer(() => { + const push$ = new Subject<Dialog>() + push$.subscribe(({ message, active }) => { + el.classList.toggle("md-dialog--active", active) + inner.textContent = message + }) + + /* Create and return component */ + return watchDialog(el, options) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/header/_/index.ts b/docs/src/templates/assets/javascripts/components/header/_/index.ts new file mode 100644 index 00000000..0f33eb48 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/header/_/index.ts @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + bufferCount, + combineLatest, + combineLatestWith, + defer, + distinctUntilChanged, + distinctUntilKeyChanged, + endWith, + filter, + ignoreElements, + map, + of, + shareReplay, + startWith, + switchMap, + takeUntil +} from "rxjs" + +import { feature } from "~/_" +import { + Viewport, + watchElementSize, + watchToggle +} from "~/browser" + +import { Component } from "../../_" +import { Main } from "../../main" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Header + */ +export interface Header { + height: number /* Header visible height */ + hidden: boolean /* Header is hidden */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + viewport$: Observable<Viewport> /* Viewport observable */ +} + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ + main$: Observable<Main> /* Main area observable */ +} + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Compute whether the header is hidden + * + * If the user scrolls past a certain threshold, the header can be hidden when + * scrolling down, and shown when scrolling up. + * + * @param options - Options + * + * @returns Toggle observable + */ +function isHidden({ viewport$ }: WatchOptions): Observable<boolean> { + if (!feature("header.autohide")) + return of(false) + + /* Compute direction and turning point */ + const direction$ = viewport$ + .pipe( + map(({ offset: { y } }) => y), + bufferCount(2, 1), + map(([a, b]) => [a < b, b] as const), + distinctUntilKeyChanged(0) + ) + + /* Compute whether header should be hidden */ + const hidden$ = combineLatest([viewport$, direction$]) + .pipe( + filter(([{ offset }, [, y]]) => Math.abs(y - offset.y) > 100), + map(([, [direction]]) => direction), + distinctUntilChanged() + ) + + /* Compute threshold for hiding */ + const search$ = watchToggle("search") + return combineLatest([viewport$, search$]) + .pipe( + map(([{ offset }, search]) => offset.y > 400 && !search), + distinctUntilChanged(), + switchMap(active => active ? hidden$ : of(false)), + startWith(false) + ) +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch header + * + * @param el - Header element + * @param options - Options + * + * @returns Header observable + */ +export function watchHeader( + el: HTMLElement, options: WatchOptions +): Observable<Header> { + return defer(() => combineLatest([ + watchElementSize(el), + isHidden(options) + ])) + .pipe( + map(([{ height }, hidden]) => ({ + height, + hidden + })), + distinctUntilChanged((a, b) => ( + a.height === b.height && + a.hidden === b.hidden + )), + shareReplay(1) + ) +} + +/** + * Mount header + * + * This function manages the different states of the header, i.e. whether it's + * hidden or rendered with a shadow. This depends heavily on the main area. + * + * @param el - Header element + * @param options - Options + * + * @returns Header component observable + */ +export function mountHeader( + el: HTMLElement, { header$, main$ }: MountOptions +): Observable<Component<Header>> { + return defer(() => { + const push$ = new Subject<Main>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + push$ + .pipe( + distinctUntilKeyChanged("active"), + combineLatestWith(header$) + ) + .subscribe(([{ active }, { hidden }]) => { + el.classList.toggle("md-header--shadow", active && !hidden) + el.hidden = hidden + }) + + /* Link to main area */ + main$.subscribe(push$) + + /* Create and return component */ + return header$ + .pipe( + takeUntil(done$), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/header/index.ts b/docs/src/templates/assets/javascripts/components/header/index.ts new file mode 100644 index 00000000..cf23ec1a --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/header/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" +export * from "./title" diff --git a/docs/src/templates/assets/javascripts/components/header/title/index.ts b/docs/src/templates/assets/javascripts/components/header/title/index.ts new file mode 100644 index 00000000..f3bc0d08 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/header/title/index.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2016-2023 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 { + EMPTY, + Observable, + Subject, + defer, + distinctUntilKeyChanged, + finalize, + map, + tap +} from "rxjs" + +import { + Viewport, + getElementSize, + getOptionalElement, + watchViewportAt +} from "~/browser" + +import { Component } from "../../_" +import { Header } from "../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Header + */ +export interface HeaderTitle { + active: boolean /* Header title is active */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ +} + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch header title + * + * @param el - Heading element + * @param options - Options + * + * @returns Header title observable + */ +export function watchHeaderTitle( + el: HTMLElement, { viewport$, header$ }: WatchOptions +): Observable<HeaderTitle> { + return watchViewportAt(el, { viewport$, header$ }) + .pipe( + map(({ offset: { y } }) => { + const { height } = getElementSize(el) + return { + active: y >= height + } + }), + distinctUntilKeyChanged("active") + ) +} + +/** + * Mount header title + * + * This function swaps the header title from the site title to the title of the + * current page when the user scrolls past the first headline. + * + * @param el - Header title element + * @param options - Options + * + * @returns Header title component observable + */ +export function mountHeaderTitle( + el: HTMLElement, options: MountOptions +): Observable<Component<HeaderTitle>> { + return defer(() => { + const push$ = new Subject<HeaderTitle>() + push$.subscribe({ + + /* Handle emission */ + next({ active }) { + el.classList.toggle("md-header__title--active", active) + }, + + /* Handle complete */ + complete() { + el.classList.remove("md-header__title--active") + } + }) + + /* Obtain headline, if any */ + const heading = getOptionalElement(".md-content h1") + if (typeof heading === "undefined") + return EMPTY + + /* Create and return component */ + return watchHeaderTitle(heading, options) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/index.ts b/docs/src/templates/assets/javascripts/components/index.ts new file mode 100644 index 00000000..3d4391d1 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" +export * from "./announce" +export * from "./consent" +export * from "./content" +export * from "./dialog" +export * from "./header" +export * from "./main" +export * from "./palette" +export * from "./progress" +export * from "./search" +export * from "./sidebar" +export * from "./source" +export * from "./tabs" +export * from "./toc" +export * from "./top" diff --git a/docs/src/templates/assets/javascripts/components/main/index.ts b/docs/src/templates/assets/javascripts/components/main/index.ts new file mode 100644 index 00000000..2509f9b9 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/main/index.ts @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + combineLatest, + distinctUntilChanged, + distinctUntilKeyChanged, + map, + switchMap +} from "rxjs" + +import { + Viewport, + watchElementSize +} from "~/browser" + +import { Header } from "../header" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Main area + */ +export interface Main { + offset: number /* Main area top offset */ + height: number /* Main area visible height */ + active: boolean /* Main area is active */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch main area + * + * This function returns an observable that computes the visual parameters of + * the main area which depends on the viewport vertical offset and height, as + * well as the height of the header element, if the header is fixed. + * + * @param el - Main area element + * @param options - Options + * + * @returns Main area observable + */ +export function watchMain( + el: HTMLElement, { viewport$, header$ }: WatchOptions +): Observable<Main> { + + /* Compute necessary adjustment for header */ + const adjust$ = header$ + .pipe( + map(({ height }) => height), + distinctUntilChanged() + ) + + /* Compute the main area's top and bottom borders */ + const border$ = adjust$ + .pipe( + switchMap(() => watchElementSize(el) + .pipe( + map(({ height }) => ({ + top: el.offsetTop, + bottom: el.offsetTop + height + })), + distinctUntilKeyChanged("bottom") + ) + ) + ) + + /* Compute the main area's offset, visible height and if we scrolled past */ + return combineLatest([adjust$, border$, viewport$]) + .pipe( + map(([header, { top, bottom }, { offset: { y }, size: { height } }]) => { + height = Math.max(0, height + - Math.max(0, top - y, header) + - Math.max(0, height + y - bottom) + ) + return { + offset: top - header, + height, + active: top - header <= y + } + }), + distinctUntilChanged((a, b) => ( + a.offset === b.offset && + a.height === b.height && + a.active === b.active + )) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/palette/index.ts b/docs/src/templates/assets/javascripts/components/palette/index.ts new file mode 100644 index 00000000..cf578f60 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/palette/index.ts @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + asyncScheduler, + defer, + finalize, + fromEvent, + map, + mergeMap, + observeOn, + of, + shareReplay, + startWith, + tap +} from "rxjs" + +import { getElements } from "~/browser" +import { h } from "~/utilities" + +import { + Component, + getComponentElement +} from "../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Palette colors + */ +export interface PaletteColor { + scheme?: string /* Color scheme */ + primary?: string /* Primary color */ + accent?: string /* Accent color */ +} + +/** + * Palette + */ +export interface Palette { + index: number /* Palette index */ + color: PaletteColor /* Palette colors */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch color palette + * + * @param inputs - Color palette element + * + * @returns Color palette observable + */ +export function watchPalette( + inputs: HTMLInputElement[] +): Observable<Palette> { + const current = __md_get<Palette>("__palette") || { + index: inputs.findIndex(input => matchMedia( + input.getAttribute("data-md-color-media")! + ).matches) + } + + /* Emit changes in color palette */ + return of(...inputs) + .pipe( + mergeMap(input => fromEvent(input, "change") + .pipe( + map(() => input) + ) + ), + startWith(inputs[Math.max(0, current.index)]), + map(input => ({ + index: inputs.indexOf(input), + color: { + scheme: input.getAttribute("data-md-color-scheme"), + primary: input.getAttribute("data-md-color-primary"), + accent: input.getAttribute("data-md-color-accent") + } + } as Palette)), + shareReplay(1) + ) +} + +/** + * Mount color palette + * + * @param el - Color palette element + * + * @returns Color palette component observable + */ +export function mountPalette( + el: HTMLElement +): Observable<Component<Palette>> { + const meta = h("meta", { name: "theme-color" }) + document.head.appendChild(meta) + + // Add color scheme meta tag + const scheme = h("meta", { name: "color-scheme" }) + document.head.appendChild(scheme) + + /* Mount component on subscription */ + return defer(() => { + const push$ = new Subject<Palette>() + push$.subscribe(palette => { + document.body.setAttribute("data-md-color-switching", "") + + /* Set color palette */ + for (const [key, value] of Object.entries(palette.color)) + document.body.setAttribute(`data-md-color-${key}`, value) + + /* Toggle visibility */ + for (let index = 0; index < inputs.length; index++) { + const label = inputs[index].nextElementSibling + if (label instanceof HTMLElement) + label.hidden = palette.index !== index + } + + /* Persist preference in local storage */ + __md_set("__palette", palette) + }) + + /* Update theme-color meta tag */ + push$ + .pipe( + map(() => { + const header = getComponentElement("header") + const style = window.getComputedStyle(header) + + // Set color scheme + scheme.content = style.colorScheme + + /* Return color in hexadecimal format */ + return style.backgroundColor.match(/\d+/g)! + .map(value => (+value).toString(16).padStart(2, "0")) + .join("") + }) + ) + .subscribe(color => meta.content = `#${color}`) + + /* Revert transition durations after color switch */ + push$.pipe(observeOn(asyncScheduler)) + .subscribe(() => { + document.body.removeAttribute("data-md-color-switching") + }) + + /* Create and return component */ + const inputs = getElements<HTMLInputElement>("input", el) + return watchPalette(inputs) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/progress/index.ts b/docs/src/templates/assets/javascripts/components/progress/index.ts new file mode 100644 index 00000000..30c722b8 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/progress/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + defer, + finalize, + map, + tap +} from "rxjs" + +import { Component } from "../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Progress indicator + */ +export interface Progress { + value: number // Progress value +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + progress$: Subject<number> // Progress subject +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount progress indicator + * + * @param el - Progress indicator element + * @param options - Options + * + * @returns Progress indicator component observable + */ +export function mountProgress( + el: HTMLElement, { progress$ }: MountOptions +): Observable<Component<Progress>> { + + // Mount component on subscription + return defer(() => { + const push$ = new Subject<Progress>() + push$.subscribe(({ value }) => { + el.style.setProperty("--md-progress-value", `${value}`) + }) + + // Create and return component + return progress$ + .pipe( + tap(value => push$.next({ value })), + finalize(() => push$.complete()), + map(value => ({ ref: el, value })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/search/_/index.ts b/docs/src/templates/assets/javascripts/components/search/_/index.ts new file mode 100644 index 00000000..aa963b47 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/_/index.ts @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2016-2023 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 { + NEVER, + Observable, + ObservableInput, + filter, + fromEvent, + merge, + mergeWith +} from "rxjs" + +import { configuration } from "~/_" +import { + Keyboard, + getActiveElement, + getElements, + setToggle +} from "~/browser" +import { + SearchIndex, + SearchResult, + setupSearchWorker +} from "~/integrations" + +import { + Component, + getComponentElement, + getComponentElements +} from "../../_" +import { + SearchQuery, + mountSearchQuery +} from "../query" +import { mountSearchResult } from "../result" +import { + SearchShare, + mountSearchShare +} from "../share" +import { + SearchSuggest, + mountSearchSuggest +} from "../suggest" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search + */ +export type Search = + | SearchQuery + | SearchResult + | SearchShare + | SearchSuggest + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + index$: ObservableInput<SearchIndex> /* Search index observable */ + keyboard$: Observable<Keyboard> /* Keyboard observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search + * + * This function sets up the search functionality, including the underlying + * web worker and all keyboard bindings. + * + * @param el - Search element + * @param options - Options + * + * @returns Search component observable + */ +export function mountSearch( + el: HTMLElement, { index$, keyboard$ }: MountOptions +): Observable<Component<Search>> { + const config = configuration() + try { + const worker$ = setupSearchWorker(config.search, index$) + + /* Retrieve query and result components */ + const query = getComponentElement("search-query", el) + const result = getComponentElement("search-result", el) + + /* Always close search on result selection */ + fromEvent<PointerEvent>(el, "click") + .pipe( + filter(({ target }) => ( + target instanceof Element && !!target.closest("a") + )) + ) + .subscribe(() => setToggle("search", false)) + + /* Set up search keyboard handlers */ + keyboard$ + .pipe( + filter(({ mode }) => mode === "search") + ) + .subscribe(key => { + const active = getActiveElement() + switch (key.type) { + + /* Enter: go to first (best) result */ + case "Enter": + if (active === query) { + const anchors = new Map<HTMLAnchorElement, number>() + for (const anchor of getElements<HTMLAnchorElement>( + ":first-child [href]", result + )) { + const article = anchor.firstElementChild! + anchors.set(anchor, parseFloat( + article.getAttribute("data-md-score")! + )) + } + + /* Go to result with highest score, if any */ + if (anchors.size) { + const [[best]] = [...anchors].sort(([, a], [, b]) => b - a) + best.click() + } + + /* Otherwise omit form submission */ + key.claim() + } + break + + /* Escape or Tab: close search */ + case "Escape": + case "Tab": + setToggle("search", false) + query.blur() + break + + /* Vertical arrows: select previous or next search result */ + case "ArrowUp": + case "ArrowDown": + if (typeof active === "undefined") { + query.focus() + } else { + const els = [query, ...getElements( + ":not(details) > [href], summary, details[open] [href]", + result + )] + const i = Math.max(0, ( + Math.max(0, els.indexOf(active)) + els.length + ( + key.type === "ArrowUp" ? -1 : +1 + ) + ) % els.length) + els[i].focus() + } + + /* Prevent scrolling of page */ + key.claim() + break + + /* All other keys: hand to search query */ + default: + if (query !== getActiveElement()) + query.focus() + } + }) + + /* Set up global keyboard handlers */ + keyboard$ + .pipe( + filter(({ mode }) => mode === "global") + ) + .subscribe(key => { + switch (key.type) { + + /* Open search and select query */ + case "f": + case "s": + case "/": + query.focus() + query.select() + + /* Prevent scrolling of page */ + key.claim() + break + } + }) + + /* Create and return component */ + const query$ = mountSearchQuery(query, { worker$ }) + return merge( + query$, + mountSearchResult(result, { worker$, query$ }) + ) + .pipe( + mergeWith( + + /* Search sharing */ + ...getComponentElements("search-share", el) + .map(child => mountSearchShare(child, { query$ })), + + /* Search suggestions */ + ...getComponentElements("search-suggest", el) + .map(child => mountSearchSuggest(child, { worker$, keyboard$ })) + ) + ) + + /* Gracefully handle broken search */ + } catch (err) { + el.hidden = true + return NEVER + } +} diff --git a/docs/src/templates/assets/javascripts/components/search/highlight/.eslintrc b/docs/src/templates/assets/javascripts/components/search/highlight/.eslintrc new file mode 100644 index 00000000..38a5714d --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/highlight/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "no-null/no-null": "off" + } +} diff --git a/docs/src/templates/assets/javascripts/components/search/highlight/index.ts b/docs/src/templates/assets/javascripts/components/search/highlight/index.ts new file mode 100644 index 00000000..bc3f94c9 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/highlight/index.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + ObservableInput, + combineLatest, + filter, + map, + startWith +} from "rxjs" + +import { getLocation } from "~/browser" +import { + SearchIndex, + setupSearchHighlighter +} from "~/integrations" +import { h } from "~/utilities" + +import { Component } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search highlighting + */ +export interface SearchHighlight { + nodes: Map<ChildNode, string> /* Map of replacements */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + index$: ObservableInput<SearchIndex> /* Search index observable */ + location$: Observable<URL> /* Location observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search highlighting + * + * @param el - Content element + * @param options - Options + * + * @returns Search highlighting component observable + */ +export function mountSearchHiglight( + el: HTMLElement, { index$, location$ }: MountOptions +): Observable<Component<SearchHighlight>> { + return combineLatest([ + index$, + location$ + .pipe( + startWith(getLocation()), + filter(url => !!url.searchParams.get("h")) + ) + ]) + .pipe( + map(([index, url]) => setupSearchHighlighter(index.config)( + url.searchParams.get("h")! + )), + map(fn => { + const nodes = new Map<ChildNode, string>() + + /* Traverse text nodes and collect matches */ + const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT) + for (let node = it.nextNode(); node; node = it.nextNode()) { + if (node.parentElement?.offsetHeight) { + const original = node.textContent! + const replaced = fn(original) + if (replaced.length > original.length) + nodes.set(node as ChildNode, replaced) + } + } + + /* Replace original nodes with matches */ + for (const [node, text] of nodes) { + const { childNodes } = h("span", null, text) + node.replaceWith(...Array.from(childNodes)) + } + + /* Return component */ + return { ref: el, nodes } + }) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/search/index.ts b/docs/src/templates/assets/javascripts/components/search/index.ts new file mode 100644 index 00000000..846d8685 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" +export * from "./highlight" +export * from "./query" +export * from "./result" +export * from "./share" +export * from "./suggest" diff --git a/docs/src/templates/assets/javascripts/components/search/query/index.ts b/docs/src/templates/assets/javascripts/components/search/query/index.ts new file mode 100644 index 00000000..4ce21279 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/query/index.ts @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + combineLatest, + distinctUntilChanged, + distinctUntilKeyChanged, + endWith, + finalize, + first, + fromEvent, + ignoreElements, + map, + merge, + shareReplay, + takeUntil, + tap +} from "rxjs" + +import { + getElement, + getLocation, + setToggle, + watchElementFocus, + watchToggle +} from "~/browser" +import { + SearchMessage, + SearchMessageType, + isSearchReadyMessage +} from "~/integrations" + +import { Component } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search query + */ +export interface SearchQuery { + value: string /* Query value */ + focus: boolean /* Query focus */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + worker$: Subject<SearchMessage> /* Search worker */ +} + +/** + * Mount options + */ +interface MountOptions { + worker$: Subject<SearchMessage> /* Search worker */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch search query + * + * Note that the focus event which triggers re-reading the current query value + * is delayed by `1ms` so the input's empty state is allowed to propagate. + * + * @param el - Search query element + * @param options - Options + * + * @returns Search query observable + */ +export function watchSearchQuery( + el: HTMLInputElement, { worker$ }: WatchOptions +): Observable<SearchQuery> { + + /* Support search deep linking */ + const { searchParams } = getLocation() + if (searchParams.has("q")) { + setToggle("search", true) + + /* Set query from parameter */ + el.value = searchParams.get("q")! + el.focus() + + /* Remove query parameter on close */ + watchToggle("search") + .pipe( + first(active => !active) + ) + .subscribe(() => { + const url = getLocation() + url.searchParams.delete("q") + history.replaceState({}, "", `${url}`) + }) + } + + /* Intercept focus and input events */ + const focus$ = watchElementFocus(el) + const value$ = merge( + worker$.pipe(first(isSearchReadyMessage)), + fromEvent(el, "keyup"), + focus$ + ) + .pipe( + map(() => el.value), + distinctUntilChanged() + ) + + /* Combine into single observable */ + return combineLatest([value$, focus$]) + .pipe( + map(([value, focus]) => ({ value, focus })), + shareReplay(1) + ) +} + +/** + * Mount search query + * + * @param el - Search query element + * @param options - Options + * + * @returns Search query component observable + */ +export function mountSearchQuery( + el: HTMLInputElement, { worker$ }: MountOptions +): Observable<Component<SearchQuery, HTMLInputElement>> { + const push$ = new Subject<SearchQuery>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + + /* Handle value change */ + combineLatest([ + worker$.pipe(first(isSearchReadyMessage)), + push$ + ], (_, query) => query) + .pipe( + distinctUntilKeyChanged("value") + ) + .subscribe(({ value }) => worker$.next({ + type: SearchMessageType.QUERY, + data: value + })) + + /* Handle focus change */ + push$ + .pipe( + distinctUntilKeyChanged("focus") + ) + .subscribe(({ focus }) => { + if (focus) + setToggle("search", focus) + }) + + /* Handle reset */ + fromEvent(el.form!, "reset") + .pipe( + takeUntil(done$) + ) + .subscribe(() => el.focus()) + + // Focus search query on label click - note that this is necessary to bring + // up the keyboard on iOS and other mobile platforms, as the search dialog is + // not visible at first, and programatically focusing an input element must + // be triggered by a user interaction - see https://t.ly/Cb30n + const label = getElement("header [for=__search]") + fromEvent(label, "click") + .subscribe(() => el.focus()) + + /* Create and return component */ + return watchSearchQuery(el, { worker$ }) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })), + shareReplay(1) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/search/result/index.ts b/docs/src/templates/assets/javascripts/components/search/result/index.ts new file mode 100644 index 00000000..c3c9ef20 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/result/index.ts @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2016-2023 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 { + EMPTY, + Observable, + Subject, + bufferCount, + filter, + finalize, + first, + fromEvent, + map, + merge, + mergeMap, + of, + share, + skipUntil, + switchMap, + takeUntil, + tap, + withLatestFrom, + zipWith +} from "rxjs" + +import { translation } from "~/_" +import { + getElement, + getOptionalElement, + watchElementBoundary, + watchToggle +} from "~/browser" +import { + SearchMessage, + SearchResult, + isSearchReadyMessage, + isSearchResultMessage +} from "~/integrations" +import { renderSearchResultItem } from "~/templates" +import { round } from "~/utilities" + +import { Component } from "../../_" +import { SearchQuery } from "../query" + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + query$: Observable<SearchQuery> /* Search query observable */ + worker$: Subject<SearchMessage> /* Search worker */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search result list + * + * This function performs a lazy rendering of the search results, depending on + * the vertical offset of the search result container. + * + * @param el - Search result list element + * @param options - Options + * + * @returns Search result list component observable + */ +export function mountSearchResult( + el: HTMLElement, { worker$, query$ }: MountOptions +): Observable<Component<SearchResult>> { + const push$ = new Subject<SearchResult>() + const boundary$ = watchElementBoundary(el.parentElement!) + .pipe( + filter(Boolean) + ) + + /* Retrieve container */ + const container = el.parentElement! + + /* Retrieve nested components */ + const meta = getElement(":scope > :first-child", el) + const list = getElement(":scope > :last-child", el) + + /* Reveal to accessibility tree – see https://bit.ly/3iAA7t8 */ + watchToggle("search") + .subscribe(active => list.setAttribute( + "role", active ? "list" : "presentation" + )) + + /* Update search result metadata */ + push$ + .pipe( + withLatestFrom(query$), + skipUntil(worker$.pipe(first(isSearchReadyMessage))) + ) + .subscribe(([{ items }, { value }]) => { + switch (items.length) { + + /* No results */ + case 0: + meta.textContent = value.length + ? translation("search.result.none") + : translation("search.result.placeholder") + break + + /* One result */ + case 1: + meta.textContent = translation("search.result.one") + break + + /* Multiple result */ + default: + const count = round(items.length) + meta.textContent = translation("search.result.other", count) + } + }) + + /* Render search result item */ + const render$ = push$ + .pipe( + tap(() => list.innerHTML = ""), + switchMap(({ items }) => merge( + of(...items.slice(0, 10)), + of(...items.slice(10)) + .pipe( + bufferCount(4), + zipWith(boundary$), + switchMap(([chunk]) => chunk) + ) + )), + map(renderSearchResultItem), + share() + ) + + /* Update search result list */ + render$.subscribe(item => list.appendChild(item)) + render$ + .pipe( + mergeMap(item => { + const details = getOptionalElement("details", item) + if (typeof details === "undefined") + return EMPTY + + /* Keep position of details element stable */ + return fromEvent(details, "toggle") + .pipe( + takeUntil(push$), + map(() => details) + ) + }) + ) + .subscribe(details => { + if ( + details.open === false && + details.offsetTop <= container.scrollTop + ) + container.scrollTo({ top: details.offsetTop }) + }) + + /* Filter search result message */ + const result$ = worker$ + .pipe( + filter(isSearchResultMessage), + map(({ data }) => data) + ) + + /* Create and return component */ + return result$ + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/search/share/index.ts b/docs/src/templates/assets/javascripts/components/search/share/index.ts new file mode 100644 index 00000000..3db382c8 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/share/index.ts @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + endWith, + finalize, + fromEvent, + ignoreElements, + map, + takeUntil, + tap +} from "rxjs" + +import { getLocation } from "~/browser" + +import { Component } from "../../_" +import { SearchQuery } from "../query" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search sharing + */ +export interface SearchShare { + url: URL /* Deep link for sharing */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + query$: Observable<SearchQuery> /* Search query observable */ +} + +/** + * Mount options + */ +interface MountOptions { + query$: Observable<SearchQuery> /* Search query observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search sharing + * + * @param _el - Search sharing element + * @param options - Options + * + * @returns Search sharing observable + */ +export function watchSearchShare( + _el: HTMLElement, { query$ }: WatchOptions +): Observable<SearchShare> { + return query$ + .pipe( + map(({ value }) => { + const url = getLocation() + url.hash = "" + + /* Compute readable query strings */ + value = value + .replace(/\s+/g, "+") /* Collapse whitespace */ + .replace(/&/g, "%26") /* Escape '&' character */ + .replace(/=/g, "%3D") /* Escape '=' character */ + + /* Replace query string */ + url.search = `q=${value}` + return { url } + }) + ) +} + +/** + * Mount search sharing + * + * @param el - Search sharing element + * @param options - Options + * + * @returns Search sharing component observable + */ +export function mountSearchShare( + el: HTMLAnchorElement, options: MountOptions +): Observable<Component<SearchShare>> { + const push$ = new Subject<SearchShare>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + push$.subscribe(({ url }) => { + el.setAttribute("data-clipboard-text", el.href) + el.href = `${url}` + }) + + /* Prevent following of link */ + fromEvent(el, "click") + .pipe( + takeUntil(done$) + ) + .subscribe(ev => ev.preventDefault()) + + /* Create and return component */ + return watchSearchShare(el, options) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/search/suggest/index.ts b/docs/src/templates/assets/javascripts/components/search/suggest/index.ts new file mode 100644 index 00000000..e7881475 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/search/suggest/index.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + asyncScheduler, + combineLatestWith, + distinctUntilChanged, + filter, + finalize, + fromEvent, + map, + merge, + observeOn, + tap +} from "rxjs" + +import { Keyboard } from "~/browser" +import { + SearchMessage, + SearchResult, + isSearchResultMessage +} from "~/integrations" + +import { Component, getComponentElement } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search suggestions + */ +export interface SearchSuggest {} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + keyboard$: Observable<Keyboard> /* Keyboard observable */ + worker$: Subject<SearchMessage> /* Search worker */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search suggestions + * + * This function will perform a lazy rendering of the search results, depending + * on the vertical offset of the search result container. + * + * @param el - Search result list element + * @param options - Options + * + * @returns Search result list component observable + */ +export function mountSearchSuggest( + el: HTMLElement, { worker$, keyboard$ }: MountOptions +): Observable<Component<SearchSuggest>> { + const push$ = new Subject<SearchResult>() + + /* Retrieve query component and track all changes */ + const query = getComponentElement("search-query") + const query$ = merge( + fromEvent(query, "keydown"), + fromEvent(query, "focus") + ) + .pipe( + observeOn(asyncScheduler), + map(() => query.value), + distinctUntilChanged(), + ) + + /* Update search suggestions */ + push$ + .pipe( + combineLatestWith(query$), + map(([{ suggest }, value]) => { + const words = value.split(/([\s-]+)/) + if (suggest?.length && words[words.length - 1]) { + const last = suggest[suggest.length - 1] + if (last.startsWith(words[words.length - 1])) + words[words.length - 1] = last + } else { + words.length = 0 + } + return words + }) + ) + .subscribe(words => el.innerHTML = words + .join("") + .replace(/\s/g, " ") + ) + + /* Set up search keyboard handlers */ + keyboard$ + .pipe( + filter(({ mode }) => mode === "search") + ) + .subscribe(key => { + switch (key.type) { + + /* Right arrow: accept current suggestion */ + case "ArrowRight": + if ( + el.innerText.length && + query.selectionStart === query.value.length + ) + query.value = el.innerText + break + } + }) + + /* Filter search result message */ + const result$ = worker$ + .pipe( + filter(isSearchResultMessage), + map(({ data }) => data) + ) + + /* Create and return component */ + return result$ + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(() => ({ ref: el })) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/sidebar/index.ts b/docs/src/templates/assets/javascripts/components/sidebar/index.ts new file mode 100644 index 00000000..82f3d03e --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/sidebar/index.ts @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + animationFrameScheduler, + asyncScheduler, + auditTime, + combineLatest, + defer, + distinctUntilChanged, + endWith, + finalize, + first, + from, + fromEvent, + ignoreElements, + map, + mergeMap, + observeOn, + takeUntil, + tap, + withLatestFrom +} from "rxjs" + +import { + Viewport, + getElement, + getElementContainer, + getElementOffset, + getElementSize, + getElements +} from "~/browser" + +import { Component } from "../_" +import { Header } from "../header" +import { Main } from "../main" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Sidebar + */ +export interface Sidebar { + height: number /* Sidebar height */ + locked: boolean /* Sidebar is locked */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + main$: Observable<Main> /* Main area observable */ +} + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ + main$: Observable<Main> /* Main area observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch sidebar + * + * This function returns an observable that computes the visual parameters of + * the sidebar which depends on 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 + * + * @returns Sidebar observable + */ +export function watchSidebar( + el: HTMLElement, { viewport$, main$ }: WatchOptions +): Observable<Sidebar> { + const parent = el.closest<HTMLElement>(".md-grid")! + const adjust = + parent.offsetTop - + parent.parentElement!.offsetTop + + /* Compute the sidebar's available height and if it should be locked */ + return combineLatest([main$, viewport$]) + .pipe( + map(([{ offset, height }, { offset: { y } }]) => { + height = height + + Math.min(adjust, Math.max(0, y - offset)) + - adjust + return { + height, + locked: y >= offset + adjust + } + }), + distinctUntilChanged((a, b) => ( + a.height === b.height && + a.locked === b.locked + )) + ) +} + +/** + * Mount sidebar + * + * This function doesn't set the height of the actual sidebar, but of its first + * child – the `.md-sidebar__scrollwrap` element in order to mitigiate jittery + * sidebars when the footer is scrolled into view. At some point we switched + * from `absolute` / `fixed` positioning to `sticky` positioning, significantly + * reducing jitter in some browsers (respectively Firefox and Safari) when + * scrolling from the top. However, top-aligned sticky positioning means that + * the sidebar snaps to the bottom when the end of the container is reached. + * This is what leads to the mentioned jitter, as the sidebar's height may be + * updated too slowly. + * + * This behaviour can be mitigiated by setting the height of the sidebar to `0` + * while preserving the padding, and the height on its first element. + * + * @param el - Sidebar element + * @param options - Options + * + * @returns Sidebar component observable + */ +export function mountSidebar( + el: HTMLElement, { header$, ...options }: MountOptions +): Observable<Component<Sidebar>> { + const inner = getElement(".md-sidebar__scrollwrap", el) + const { y } = getElementOffset(inner) + return defer(() => { + const push$ = new Subject<Sidebar>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + const next$ = push$ + .pipe( + auditTime(0, animationFrameScheduler) + ) + + /* Update sidebar height and offset */ + next$.pipe(withLatestFrom(header$)) + .subscribe({ + + /* Handle emission */ + next([{ height }, { height: offset }]) { + inner.style.height = `${height - 2 * y}px` + el.style.top = `${offset}px` + }, + + /* Handle complete */ + complete() { + inner.style.height = "" + el.style.top = "" + } + }) + + /* Bring active item into view on initial load */ + next$.pipe(first()) + .subscribe(() => { + for (const item of getElements(".md-nav__link--active[href]", el)) { + const container = getElementContainer(item) + if (typeof container !== "undefined") { + const offset = item.offsetTop - container.offsetTop + const { height } = getElementSize(container) + container.scrollTo({ + top: offset - height / 2 + }) + } + } + }) + + /* Handle accessibility for expandable items, see https://bit.ly/3jaod9p */ + from(getElements<HTMLLabelElement>("label[tabindex]", el)) + .pipe( + mergeMap(label => fromEvent(label, "click") + .pipe( + observeOn(asyncScheduler), + map(() => label), + takeUntil(done$) + ) + ) + ) + .subscribe(label => { + const input = getElement<HTMLInputElement>(`[id="${label.htmlFor}"]`) + const nav = getElement(`[aria-labelledby="${label.id}"]`) + nav.setAttribute("aria-expanded", `${input.checked}`) + }) + + /* Create and return component */ + return watchSidebar(el, options) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/source/_/index.ts b/docs/src/templates/assets/javascripts/components/source/_/index.ts new file mode 100644 index 00000000..5f6c4d11 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/source/_/index.ts @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2016-2023 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 { + EMPTY, + Observable, + Subject, + catchError, + defer, + filter, + finalize, + map, + of, + shareReplay, + tap +} from "rxjs" + +import { getElement } from "~/browser" +import { ConsentDefaults } from "~/components/consent" +import { renderSourceFacts } from "~/templates" + +import { + Component, + getComponentElements +} from "../../_" +import { + SourceFacts, + fetchSourceFacts +} from "../facts" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Repository information + */ +export interface Source { + facts: SourceFacts /* Repository facts */ +} + +/* ---------------------------------------------------------------------------- + * Data + * ------------------------------------------------------------------------- */ + +/** + * Repository information observable + */ +let fetch$: Observable<Source> + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch repository information + * + * This function tries to read the repository facts from session storage, and + * if unsuccessful, fetches them from the underlying provider. + * + * @param el - Repository information element + * + * @returns Repository information observable + */ +export function watchSource( + el: HTMLAnchorElement +): Observable<Source> { + return fetch$ ||= defer(() => { + const cached = __md_get<SourceFacts>("__source", sessionStorage) + if (cached) { + return of(cached) + } else { + + /* Check if consent is configured and was given */ + const els = getComponentElements("consent") + if (els.length) { + const consent = __md_get<ConsentDefaults>("__consent") + if (!(consent && consent.github)) + return EMPTY + } + + /* Fetch repository facts */ + return fetchSourceFacts(el.href) + .pipe( + tap(facts => __md_set("__source", facts, sessionStorage)) + ) + } + }) + .pipe( + catchError(() => EMPTY), + filter(facts => Object.keys(facts).length > 0), + map(facts => ({ facts })), + shareReplay(1) + ) +} + +/** + * Mount repository information + * + * @param el - Repository information element + * + * @returns Repository information component observable + */ +export function mountSource( + el: HTMLAnchorElement +): Observable<Component<Source>> { + const inner = getElement(":scope > :last-child", el) + return defer(() => { + const push$ = new Subject<Source>() + push$.subscribe(({ facts }) => { + inner.appendChild(renderSourceFacts(facts)) + inner.classList.add("md-source__repository--active") + }) + + /* Create and return component */ + return watchSource(el) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/source/facts/_/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/_/index.ts new file mode 100644 index 00000000..154f229f --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/source/facts/_/index.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2016-2023 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 { EMPTY, Observable } from "rxjs" + +import { fetchSourceFactsFromGitHub } from "../github" +import { fetchSourceFactsFromGitLab } from "../gitlab" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Repository facts for repositories + */ +export interface RepositoryFacts { + stars?: number /* Number of stars */ + forks?: number /* Number of forks */ + version?: string /* Latest version */ +} + +/** + * Repository facts for organizations + */ +export interface OrganizationFacts { + repositories?: number /* Number of repositories */ +} + +/* ------------------------------------------------------------------------- */ + +/** + * Repository facts + */ +export type SourceFacts = + | RepositoryFacts + | OrganizationFacts + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Fetch repository facts + * + * @param url - Repository URL + * + * @returns Repository facts observable + */ +export function fetchSourceFacts( + url: string +): Observable<SourceFacts> { + + /* Try to match GitHub repository */ + let match = url.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i) + if (match) { + const [, user, repo] = match + return fetchSourceFactsFromGitHub(user, repo) + } + + /* Try to match GitLab repository */ + match = url.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i) + if (match) { + const [, base, slug] = match + return fetchSourceFactsFromGitLab(base, slug) + } + + /* Fallback */ + return EMPTY +} diff --git a/docs/src/templates/assets/javascripts/components/source/facts/github/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/github/index.ts new file mode 100644 index 00000000..12cc55e0 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/source/facts/github/index.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2016-2023 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 { Repo, User } from "github-types" +import { + EMPTY, + Observable, + catchError, + defaultIfEmpty, + map, + zip +} from "rxjs" + +import { requestJSON } from "~/browser" + +import { SourceFacts } from "../_" + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * GitHub release (partial) + */ +interface Release { + tag_name: string /* Tag name */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Fetch GitHub repository facts + * + * @param user - GitHub user or organization + * @param repo - GitHub repository + * + * @returns Repository facts observable + */ +export function fetchSourceFactsFromGitHub( + user: string, repo?: string +): Observable<SourceFacts> { + if (typeof repo !== "undefined") { + const url = `https://api.github.com/repos/${user}/${repo}` + return zip( + + /* Fetch version */ + requestJSON<Release>(`${url}/releases/latest`) + .pipe( + catchError(() => EMPTY), // @todo refactor instant loading + map(release => ({ + version: release.tag_name + })), + defaultIfEmpty({}) + ), + + /* Fetch stars and forks */ + requestJSON<Repo>(url) + .pipe( + catchError(() => EMPTY), // @todo refactor instant loading + map(info => ({ + stars: info.stargazers_count, + forks: info.forks_count + })), + defaultIfEmpty({}) + ) + ) + .pipe( + map(([release, info]) => ({ ...release, ...info })) + ) + + /* User or organization */ + } else { + const url = `https://api.github.com/users/${user}` + return requestJSON<User>(url) + .pipe( + map(info => ({ + repositories: info.public_repos + })), + defaultIfEmpty({}) + ) + } +} diff --git a/docs/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts new file mode 100644 index 00000000..d85d4afd --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2016-2023 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 { ProjectSchema } from "gitlab" +import { + EMPTY, + Observable, + catchError, + defaultIfEmpty, + map +} from "rxjs" + +import { requestJSON } from "~/browser" + +import { SourceFacts } from "../_" + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Fetch GitLab repository facts + * + * @param base - GitLab base + * @param project - GitLab project + * + * @returns Repository facts observable + */ +export function fetchSourceFactsFromGitLab( + base: string, project: string +): Observable<SourceFacts> { + const url = `https://${base}/api/v4/projects/${encodeURIComponent(project)}` + return requestJSON<ProjectSchema>(url) + .pipe( + catchError(() => EMPTY), // @todo refactor instant loading + map(({ star_count, forks_count }) => ({ + stars: star_count, + forks: forks_count + })), + defaultIfEmpty({}) + ) +} diff --git a/docs/src/templates/assets/javascripts/components/source/facts/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/index.ts new file mode 100644 index 00000000..f9bda64d --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/source/facts/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" +export * from "./github" +export * from "./gitlab" diff --git a/docs/src/templates/assets/javascripts/components/source/index.ts b/docs/src/templates/assets/javascripts/components/source/index.ts new file mode 100644 index 00000000..7fac4813 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/source/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2016-2023 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. + */ + +export * from "./_" +export * from "./facts" diff --git a/docs/src/templates/assets/javascripts/components/tabs/index.ts b/docs/src/templates/assets/javascripts/components/tabs/index.ts new file mode 100644 index 00000000..1e69df28 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/tabs/index.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + defer, + distinctUntilKeyChanged, + finalize, + map, + of, + switchMap, + tap +} from "rxjs" + +import { feature } from "~/_" +import { + Viewport, + watchElementSize, + watchViewportAt +} from "~/browser" + +import { Component } from "../_" +import { Header } from "../header" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Navigation tabs + */ +export interface Tabs { + hidden: boolean /* Navigation tabs are hidden */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ +} + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch navigation tabs + * + * @param el - Navigation tabs element + * @param options - Options + * + * @returns Navigation tabs observable + */ +export function watchTabs( + el: HTMLElement, { viewport$, header$ }: WatchOptions +): Observable<Tabs> { + return watchElementSize(document.body) + .pipe( + switchMap(() => watchViewportAt(el, { header$, viewport$ })), + map(({ offset: { y } }) => { + return { + hidden: y >= 10 + } + }), + distinctUntilKeyChanged("hidden") + ) +} + +/** + * Mount navigation tabs + * + * This function hides the navigation tabs when scrolling past the threshold + * and makes them reappear in a nice CSS animation when scrolling back up. + * + * @param el - Navigation tabs element + * @param options - Options + * + * @returns Navigation tabs component observable + */ +export function mountTabs( + el: HTMLElement, options: MountOptions +): Observable<Component<Tabs>> { + return defer(() => { + const push$ = new Subject<Tabs>() + push$.subscribe({ + + /* Handle emission */ + next({ hidden }) { + el.hidden = hidden + }, + + /* Handle complete */ + complete() { + el.hidden = false + } + }) + + /* Create and return component */ + return ( + feature("navigation.tabs.sticky") + ? of({ hidden: false }) + : watchTabs(el, options) + ) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/toc/index.ts b/docs/src/templates/assets/javascripts/components/toc/index.ts new file mode 100644 index 00000000..04b8d85f --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/toc/index.ts @@ -0,0 +1,379 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + asyncScheduler, + bufferCount, + combineLatestWith, + debounceTime, + defer, + distinctUntilChanged, + distinctUntilKeyChanged, + endWith, + filter, + finalize, + ignoreElements, + map, + merge, + observeOn, + of, + repeat, + scan, + share, + skip, + startWith, + switchMap, + takeUntil, + tap, + withLatestFrom +} from "rxjs" + +import { feature } from "~/_" +import { + Viewport, + getElement, + getElementContainer, + getElementSize, + getElements, + getLocation, + getOptionalElement, + watchElementSize +} from "~/browser" + +import { + Component, + getComponentElement +} from "../_" +import { Header } from "../header" +import { Main } from "../main" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Table of contents + */ +export interface TableOfContents { + prev: HTMLAnchorElement[][] /* Anchors (previous) */ + next: HTMLAnchorElement[][] /* Anchors (next) */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ +} + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ + main$: Observable<Main> /* Main area observable */ + target$: Observable<HTMLElement> /* Location target observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch table of contents + * + * 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 table of contents + * needs to be repainted. + * + * This implementation tracks an anchor element's entire path starting from its + * level up to the top-most anchor element, e.g. `[h3, h2, h1]`. Although the + * Material theme currently doesn't make use of this information, it enables + * the styling of the entire hierarchy through customization. + * + * Note that the current anchor is the last item of the `prev` anchor list. + * + * @param el - Table of contents element + * @param options - Options + * + * @returns Table of contents observable + */ +export function watchTableOfContents( + el: HTMLElement, { viewport$, header$ }: WatchOptions +): Observable<TableOfContents> { + const table = new Map<HTMLAnchorElement, HTMLElement>() + + /* Compute anchor-to-target mapping */ + const anchors = getElements<HTMLAnchorElement>("[href^=\\#]", el) + for (const anchor of anchors) { + const id = decodeURIComponent(anchor.hash.substring(1)) + const target = getOptionalElement(`[id="${id}"]`) + if (typeof target !== "undefined") + table.set(anchor, target) + } + + /* Compute necessary adjustment for header */ + const adjust$ = header$ + .pipe( + distinctUntilKeyChanged("height"), + map(({ height }) => { + const main = getComponentElement("main") + const grid = getElement(":scope > :first-child", main) + return height + 0.8 * ( + grid.offsetTop - + main.offsetTop + ) + }), + share() + ) + + /* Compute partition of previous and next anchors */ + const partition$ = watchElementSize(document.body) + .pipe( + distinctUntilKeyChanged("height"), + + /* Build index to map anchor paths to vertical offsets */ + switchMap(body => defer(() => { + let path: HTMLAnchorElement[] = [] + return of([...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 + } + } + + /* If the current anchor is hidden, continue with its parent */ + let offset = target.offsetTop + while (!offset && target.parentElement) { + target = target.parentElement + offset = target.offsetTop + } + + /* Fix anchor offsets in tables - see https://bit.ly/3CUFOcn */ + let parent = target.offsetParent as HTMLElement + for (; parent; parent = parent.offsetParent as HTMLElement) + offset += parent.offsetTop + + /* Map reversed anchor path to vertical offset */ + return index.set( + [...path = [...path, anchor]].reverse(), + offset + ) + }, new Map<HTMLAnchorElement[], number>())) + }) + .pipe( + + /* Sort index by vertical offset (see https://bit.ly/30z6QSO) */ + map(index => new Map([...index].sort(([, a], [, b]) => a - b))), + combineLatestWith(adjust$), + + /* Re-compute partition when viewport offset changes */ + switchMap(([index, adjust]) => viewport$ + .pipe( + scan(([prev, next], { offset: { y }, size }) => { + const last = y + size.height >= Math.floor(body.height) + + /* Look forward */ + while (next.length) { + const [, offset] = next[0] + if (offset - adjust < y || last) { + prev = [...prev, next.shift()!] + } else { + break + } + } + + /* Look backward */ + while (prev.length) { + const [, offset] = prev[prev.length - 1] + if (offset - adjust >= y && !last) { + next = [prev.pop()!, ...next] + } else { + break + } + } + + /* Return partition */ + return [prev, next] + }, [[], [...index]]), + distinctUntilChanged((a, b) => ( + a[0] === b[0] && + a[1] === b[1] + )) + ) + ) + ) + ) + ) + + /* Compute and return anchor list migrations */ + return partition$ + .pipe( + map(([prev, next]) => ({ + prev: prev.map(([path]) => path), + next: next.map(([path]) => path) + })), + + /* Extract anchor list migrations */ + startWith({ prev: [], next: [] }), + bufferCount(2, 1), + map(([a, b]) => { + + /* Moving down */ + if (a.prev.length < b.prev.length) { + return { + prev: b.prev.slice(Math.max(0, a.prev.length - 1), b.prev.length), + next: [] + } + + /* Moving up */ + } else { + return { + prev: b.prev.slice(-1), + next: b.next.slice(0, b.next.length - a.next.length) + } + } + }) + ) +} + +/* ------------------------------------------------------------------------- */ + +/** + * Mount table of contents + * + * @param el - Table of contents element + * @param options - Options + * + * @returns Table of contents component observable + */ +export function mountTableOfContents( + el: HTMLElement, { viewport$, header$, main$, target$ }: MountOptions +): Observable<Component<TableOfContents>> { + return defer(() => { + const push$ = new Subject<TableOfContents>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + push$.subscribe(({ prev, next }) => { + + /* Look forward */ + for (const [anchor] of next) { + anchor.classList.remove("md-nav__link--passed") + anchor.classList.remove("md-nav__link--active") + } + + /* Look backward */ + for (const [index, [anchor]] of prev.entries()) { + anchor.classList.add("md-nav__link--passed") + anchor.classList.toggle( + "md-nav__link--active", + index === prev.length - 1 + ) + } + }) + + /* Set up following, if enabled */ + if (feature("toc.follow")) { + + /* Toggle smooth scrolling only for anchor clicks */ + const smooth$ = merge( + viewport$.pipe(debounceTime(1), map(() => undefined)), + viewport$.pipe(debounceTime(250), map(() => "smooth" as const)) + ) + + /* Bring active anchor into view */ // @todo: refactor + push$ + .pipe( + filter(({ prev }) => prev.length > 0), + combineLatestWith(main$.pipe(observeOn(asyncScheduler))), + withLatestFrom(smooth$) + ) + .subscribe(([[{ prev }], behavior]) => { + const [anchor] = prev[prev.length - 1] + if (anchor.offsetHeight) { + + /* Retrieve overflowing container and scroll */ + const container = getElementContainer(anchor) + if (typeof container !== "undefined") { + const offset = anchor.offsetTop - container.offsetTop + const { height } = getElementSize(container) + container.scrollTo({ + top: offset - height / 2, + behavior + }) + } + } + }) + } + + /* Set up anchor tracking, if enabled */ + if (feature("navigation.tracking")) + viewport$ + .pipe( + takeUntil(done$), + distinctUntilKeyChanged("offset"), + debounceTime(250), + skip(1), + takeUntil(target$.pipe(skip(1))), + repeat({ delay: 250 }), + withLatestFrom(push$) + ) + .subscribe(([, { prev }]) => { + const url = getLocation() + + /* Set hash fragment to active anchor */ + const anchor = prev[prev.length - 1] + if (anchor && anchor.length) { + const [active] = anchor + const { hash } = new URL(active.href) + if (url.hash !== hash) { + url.hash = hash + history.replaceState({}, "", `${url}`) + } + + /* Reset anchor when at the top */ + } else { + url.hash = "" + history.replaceState({}, "", `${url}`) + } + }) + + /* Create and return component */ + return watchTableOfContents(el, { viewport$, header$ }) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) + }) +} diff --git a/docs/src/templates/assets/javascripts/components/top/index.ts b/docs/src/templates/assets/javascripts/components/top/index.ts new file mode 100644 index 00000000..82e88b61 --- /dev/null +++ b/docs/src/templates/assets/javascripts/components/top/index.ts @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2016-2023 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 { + Observable, + Subject, + bufferCount, + combineLatest, + distinctUntilChanged, + distinctUntilKeyChanged, + endWith, + finalize, + fromEvent, + ignoreElements, + map, + repeat, + skip, + takeUntil, + tap +} from "rxjs" + +import { Viewport } from "~/browser" + +import { Component } from "../_" +import { Header } from "../header" +import { Main } from "../main" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Back-to-top button + */ +export interface BackToTop { + hidden: boolean /* Back-to-top button is hidden */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + main$: Observable<Main> /* Main area observable */ + target$: Observable<HTMLElement> /* Location target observable */ +} + +/** + * Mount options + */ +interface MountOptions { + viewport$: Observable<Viewport> /* Viewport observable */ + header$: Observable<Header> /* Header observable */ + main$: Observable<Main> /* Main area observable */ + target$: Observable<HTMLElement> /* Location target observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch back-to-top + * + * @param _el - Back-to-top element + * @param options - Options + * + * @returns Back-to-top observable + */ +export function watchBackToTop( + _el: HTMLElement, { viewport$, main$, target$ }: WatchOptions +): Observable<BackToTop> { + + /* Compute direction */ + const direction$ = viewport$ + .pipe( + map(({ offset: { y } }) => y), + bufferCount(2, 1), + map(([a, b]) => a > b && b > 0), + distinctUntilChanged() + ) + + /* Compute whether main area is active */ + const active$ = main$ + .pipe( + map(({ active }) => active) + ) + + /* Compute threshold for hiding */ + return combineLatest([active$, direction$]) + .pipe( + map(([active, direction]) => !(active && direction)), + distinctUntilChanged(), + takeUntil(target$.pipe(skip(1))), + endWith(true), + repeat({ delay: 250 }), + map(hidden => ({ hidden })) + ) +} + +/* ------------------------------------------------------------------------- */ + +/** + * Mount back-to-top + * + * @param el - Back-to-top element + * @param options - Options + * + * @returns Back-to-top component observable + */ +export function mountBackToTop( + el: HTMLElement, { viewport$, header$, main$, target$ }: MountOptions +): Observable<Component<BackToTop>> { + const push$ = new Subject<BackToTop>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + push$.subscribe({ + + /* Handle emission */ + next({ hidden }) { + el.hidden = hidden + if (hidden) { + el.setAttribute("tabindex", "-1") + el.blur() + } else { + el.removeAttribute("tabindex") + } + }, + + /* Handle complete */ + complete() { + el.style.top = "" + el.hidden = true + el.removeAttribute("tabindex") + } + }) + + /* Watch header height */ + header$ + .pipe( + takeUntil(done$), + distinctUntilKeyChanged("height") + ) + .subscribe(({ height }) => { + el.style.top = `${height + 16}px` + }) + + /* Go back to top */ + fromEvent(el, "click") + .subscribe(ev => { + ev.preventDefault() + window.scrollTo({ top: 0 }) + }) + + /* Create and return component */ + return watchBackToTop(el, { viewport$, main$, target$ }) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) +} |
