Restructured content observables

This commit is contained in:
squidfunk
2021-12-05 16:20:50 +01:00
parent 86a25e802d
commit bac1301710
24 changed files with 263 additions and 289 deletions

View File

@@ -25,11 +25,8 @@ import { Observable, merge } from "rxjs"
import { getElements } from "~/browser"
import { Component } from "../../_"
import {
Annotation,
CodeBlock,
mountCodeBlock
} from "../code"
import { Annotation } from "../annotation"
import { CodeBlock, mountCodeBlock } from "../code"
import { Details, mountDetails } from "../details"
import { DataTable, mountDataTable } from "../table"
import { ContentTabs, mountContentTabs } from "../tabs"

View File

@@ -45,18 +45,18 @@ import {
watchElementOffset
} from "~/browser"
import { Component } from "../../../../_"
import { Component } from "../../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Code annotation
* Annotation
*/
export interface Annotation {
active: boolean /* Code annotation is visible */
offset: ElementOffset /* Code annotation offset */
active: boolean /* Annotation is active */
offset: ElementOffset /* Annotation offset */
}
/* ----------------------------------------------------------------------------
@@ -64,12 +64,12 @@ export interface Annotation {
* ------------------------------------------------------------------------- */
/**
* Watch code annotation
* Watch annotation
*
* @param el - Code annotation element
* @param container - Containing code block element
* @param el - Annotation element
* @param container - Containing element
*
* @returns Code annotation observable
* @returns Annotation observable
*/
export function watchAnnotation(
el: HTMLElement, container: HTMLElement
@@ -88,7 +88,7 @@ export function watchAnnotation(
})
)
/* Actively watch code annotation on focus */
/* Actively watch annotation on focus */
return watchElementFocus(el)
.pipe(
switchMap(active => offset$
@@ -101,12 +101,12 @@ export function watchAnnotation(
}
/**
* Mount code annotation
* Mount annotation
*
* @param el - Code annotation element
* @param container - Containing code block element
* @param el - Annotation element
* @param container - Containing element
*
* @returns Code annotation component observable
* @returns Annotation component observable
*/
export function mountAnnotation(
el: HTMLElement, container: HTMLElement

View File

@@ -38,7 +38,7 @@ import {
} from "~/browser"
import { renderAnnotation } from "~/templates"
import { Component } from "../../../../_"
import { Component } from "../../../_"
import {
Annotation,
mountAnnotation
@@ -60,11 +60,11 @@ interface MountOptions {
* ------------------------------------------------------------------------- */
/**
* Find all code annotation markers in the given code block
* Find all annotation markers in the given code block
*
* @param container - Containing code block element
* @param container - Containing element
*
* @returns Code annotation markers
* @returns Annotation markers
*/
function findAnnotationMarkers(container: HTMLElement): Text[] {
const markers: Text[] = []
@@ -97,18 +97,18 @@ function swap(source: HTMLElement, target: HTMLElement): void {
* ------------------------------------------------------------------------- */
/**
* Mount code annotation list
* Mount annotation list
*
* This function analyzes the given container code block and checks for markers
* referring to elements in the given code annotation list. If no markers are
* found, the list is left untouched. Otherwise, list elements are rendered as
* code annotations inside the code block.
* 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 - Code annotation list element
* @param container - Containing code block element
* @param el - Annotation list element
* @param container - Containing element
* @param options - Options
*
* @returns Code annotation list component observable
* @returns Annotation component observable
*/
export function mountAnnotationList(
el: HTMLElement, container: HTMLElement, { print$ }: MountOptions
@@ -138,7 +138,7 @@ export function mountAnnotationList(
.subscribe(active => {
el.hidden = !active
/* Move annotation contents back into list */
/* Show annotations in code block or list (print) */
for (const [id, annotation] of annotations) {
const inner = getElement(".md-typeset", annotation)
const child = getElement(`li:nth-child(${id})`, el)

View File

@@ -1,216 +0,0 @@
/*
* Copyright (c) 2016-2021 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,
finalize,
map,
mergeWith,
switchMap,
takeLast,
takeUntil,
tap
} from "rxjs"
import { feature } from "~/_"
import {
getElementContentSize,
watchElementSize
} 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 {
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global sequence number for Clipboard.js integration
*/
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.
*
* @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)")
return 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()) {
const parent = el.closest("pre")!
parent.id = `__code_${++sequence}`
parent.insertBefore(
renderClipboardButton(parent.id),
el
)
}
/* Handle code annotations */
const container = el.closest([
":not(td.code) > .highlight", /* Code blocks */
".highlighttable" /* Code blocks with line numbers */
].join(", "))
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(
takeUntil(push$.pipe(takeLast(1))),
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 }))
)
})
}

View File

@@ -20,5 +20,197 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./annotation"
import ClipboardJS from "clipboard"
import {
EMPTY,
Observable,
Subject,
defer,
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
mergeWith,
switchMap,
takeLast,
takeUntil,
tap
} from "rxjs"
import { feature } from "~/_"
import {
getElementContentSize,
watchElementSize
} 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 {
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global sequence number for Clipboard.js integration
*/
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.
*
* @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)")
return 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()) {
const parent = el.closest("pre")!
parent.id = `__code_${++sequence}`
parent.insertBefore(
renderClipboardButton(parent.id),
el
)
}
/* Handle code annotations */
const container = el.closest([
":not(td.code) > .highlight", /* Code blocks */
".highlighttable" /* Code blocks with line numbers */
].join(", "))
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(
takeUntil(push$.pipe(takeLast(1))),
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 }))
)
})
}

View File

@@ -43,7 +43,7 @@ import { Component } from "../../_"
*/
export interface Details {
action: "open" | "close" /* Details state */
scroll?: boolean /* Scroll into view */
reveal?: boolean /* Details is revealed */
}
/* ----------------------------------------------------------------------------
@@ -89,7 +89,7 @@ export function watchDetails(
.pipe(
map(target => target.closest("details:not([open])")!),
filter(details => el === details),
mapTo<Details>({ action: "open", scroll: true })
mapTo<Details>({ action: "open", reveal: true })
),
/* Open details on print and close afterwards */
@@ -120,12 +120,12 @@ export function mountDetails(
): Observable<Component<Details>> {
return defer(() => {
const push$ = new Subject<Details>()
push$.subscribe(({ action, scroll }) => {
push$.subscribe(({ action, reveal }) => {
if (action === "open")
el.setAttribute("open", "")
else
el.removeAttribute("open")
if (scroll)
if (reveal)
el.scrollIntoView()
})

View File

@@ -21,6 +21,7 @@
*/
export * from "./_"
export * from "./annotation"
export * from "./code"
export * from "./details"
export * from "./table"

View File

@@ -46,7 +46,7 @@ import { Component } from "../_"
*/
export interface Dialog {
message: string /* Dialog message */
open: boolean /* Dialog is visible */
active: boolean /* Dialog is active */
}
/* ----------------------------------------------------------------------------
@@ -89,7 +89,7 @@ export function watchDialog(
of(false).pipe(delay(2000))
)
.pipe(
map(open => ({ message, open }))
map(active => ({ message, active }))
)
)
)
@@ -112,9 +112,9 @@ export function mountDialog(
const inner = getElement(".md-typeset", el)
return defer(() => {
const push$ = new Subject<Dialog>()
push$.subscribe(({ message, open }) => {
push$.subscribe(({ message, active }) => {
inner.textContent = message
if (open)
if (active)
el.setAttribute("data-md-state", "open")
else
el.removeAttribute("data-md-state")

View File

@@ -59,7 +59,7 @@ import { Main } from "../../main"
export interface Header {
height: number /* Header visible height */
sticky: boolean /* Header stickyness */
hidden: boolean /* User scrolled past threshold */
hidden: boolean /* Header is hidden */
}
/* ----------------------------------------------------------------------------

View File

@@ -49,7 +49,7 @@ import { Header } from "../_"
* Header
*/
export interface HeaderTitle {
active: boolean /* User scrolled past first headline */
active: boolean /* Header title is active */
}
/* ----------------------------------------------------------------------------

View File

@@ -46,7 +46,7 @@ import { Header } from "../header"
export interface Main {
offset: number /* Main area top offset */
height: number /* Main area visible height */
active: boolean /* User scrolled past header */
active: boolean /* Main area is active */
}
/* ----------------------------------------------------------------------------

View File

@@ -53,7 +53,7 @@ import { Main } from "../main"
*/
export interface Sidebar {
height: number /* Sidebar height */
locked: boolean /* User scrolled past header */
locked: boolean /* Sidebar is locked */
}
/* ----------------------------------------------------------------------------

View File

@@ -50,7 +50,7 @@ import { Header } from "../header"
* Navigation tabs
*/
export interface Tabs {
hidden: boolean /* User scrolled past tabs */
hidden: boolean /* Navigation tabs are hidden */
}
/* ----------------------------------------------------------------------------

View File

@@ -49,7 +49,7 @@ import { Main } from "../main"
* Back-to-top button
*/
export interface BackToTop {
hidden: boolean /* User scrolled up */
hidden: boolean /* Back-to-top button is hidden */
}
/* ----------------------------------------------------------------------------

View File

@@ -27,7 +27,7 @@ import { h } from "~/utilities"
* ------------------------------------------------------------------------- */
/**
* Render an empty code annotation
* Render an empty annotation
*
* @param id - Annotation identifier
*