aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/templates/assets/javascripts/components/toc/index.ts
diff options
context:
space:
mode:
author简律纯 <i@jyunko.cn>2023-10-07 06:48:07 +0800
committer简律纯 <i@jyunko.cn>2023-10-07 06:48:07 +0800
commit991fd7a6d67ee017c57beaaa21fc31c4bee7944d (patch)
treee895202203fcaa50b0052f60ef6fc7d6d2928cf9 /src/templates/assets/javascripts/components/toc/index.ts
parentd62900046bb6f754a8e6e7e670a66a90134055d9 (diff)
downloadinfini-991fd7a6d67ee017c57beaaa21fc31c4bee7944d.tar.gz
infini-991fd7a6d67ee017c57beaaa21fc31c4bee7944d.zip
feat(version): versions
Diffstat (limited to 'src/templates/assets/javascripts/components/toc/index.ts')
-rw-r--r--src/templates/assets/javascripts/components/toc/index.ts379
1 files changed, 379 insertions, 0 deletions
diff --git a/src/templates/assets/javascripts/components/toc/index.ts b/src/templates/assets/javascripts/components/toc/index.ts
new file mode 100644
index 00000000..04b8d85f
--- /dev/null
+++ b/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 }))
+ )
+ })
+}