1
0
mirror of https://github.com/squidfunk/mkdocs-material.git synced 2024-11-14 10:57:41 +01:00

Refactored code annotations

This commit is contained in:
squidfunk 2021-11-28 13:01:27 +01:00
parent f6503920b1
commit abe475e151
28 changed files with 639 additions and 373 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -34,7 +34,7 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block styles %} {% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.6378942d.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/main.ad626c1e.min.css' | url }}">
{% if config.theme.palette %} {% if config.theme.palette %}
{% set palette = config.theme.palette %} {% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.9204c3b2.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/palette.9204c3b2.min.css' | url }}">
@ -184,9 +184,11 @@
"base": base_url, "base": base_url,
"features": features, "features": features,
"translations": {}, "translations": {},
"search": "assets/javascripts/workers/search.01824240.min.js" | url, "search": "assets/javascripts/workers/search.01824240.min.js" | url
"version": config.extra.version or None
} -%} } -%}
{%- if config.extra.version -%}
{%- set _ = app.update({ "version": config.extra.version }) -%}
{%- endif -%}
{%- set translations = app.translations -%} {%- set translations = app.translations -%}
{%- for key in [ {%- for key in [
"clipboard.copy", "clipboard.copy",
@ -211,7 +213,7 @@
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/bundle.0885dc41.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.7c769d4b.min.js' | url }}"></script>
{% for path in config["extra_javascript"] %} {% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script> <script src="{{ path | url }}"></script>
{% endfor %} {% endfor %}

View File

@ -16,5 +16,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{ 'overrides/assets/javascripts/bundle.a8b5c64f.min.js' | url }}"></script> <script src="{{ 'overrides/assets/javascripts/bundle.afb943e6.min.js' | url }}"></script>
{% endblock %} {% endblock %}

View File

@ -33,8 +33,8 @@ export type Flag =
| "content.code.annotate" /* Code annotations */ | "content.code.annotate" /* Code annotations */
| "header.autohide" /* Hide header */ | "header.autohide" /* Hide header */
| "navigation.expand" /* Automatic expansion */ | "navigation.expand" /* Automatic expansion */
| "navigation.instant" /* Instant loading */
| "navigation.indexes" /* Section pages */ | "navigation.indexes" /* Section pages */
| "navigation.instant" /* Instant loading */
| "navigation.sections" /* Section navigation */ | "navigation.sections" /* Section navigation */
| "navigation.tabs" /* Tabs navigation */ | "navigation.tabs" /* Tabs navigation */
| "navigation.tabs.sticky" /* Tabs navigation (sticky) */ | "navigation.tabs.sticky" /* Tabs navigation (sticky) */

View File

@ -53,7 +53,9 @@ export interface ElementOffset {
* *
* @returns Element offset * @returns Element offset
*/ */
export function getElementOffset(el: HTMLElement): ElementOffset { export function getElementOffset(
el: HTMLElement
): ElementOffset {
return { return {
x: el.offsetLeft, x: el.offsetLeft,
y: el.offsetTop y: el.offsetTop

View File

@ -43,7 +43,9 @@ import { ElementOffset } from "../_"
* *
* @returns Element content offset * @returns Element content offset
*/ */
export function getElementContentOffset(el: HTMLElement): ElementOffset { export function getElementContentOffset(
el: HTMLElement
): ElementOffset {
return { return {
x: el.scrollLeft, x: el.scrollLeft,
y: el.scrollTop y: el.scrollTop

View File

@ -28,6 +28,7 @@ import {
filter, filter,
finalize, finalize,
map, map,
merge,
of, of,
shareReplay, shareReplay,
startWith, startWith,
@ -73,7 +74,7 @@ const observer$ = defer(() => of(
}) })
)) ))
.pipe( .pipe(
switchMap(observer => NEVER.pipe(startWith(observer)) switchMap(observer => merge(NEVER, of(observer))
.pipe( .pipe(
finalize(() => observer.disconnect()) finalize(() => observer.disconnect())
) )
@ -92,7 +93,9 @@ const observer$ = defer(() => of(
* *
* @returns Element size * @returns Element size
*/ */
export function getElementSize(el: HTMLElement): ElementSize { export function getElementSize(
el: HTMLElement
): ElementSize {
return { return {
width: el.offsetWidth, width: el.offsetWidth,
height: el.offsetHeight height: el.offsetHeight

View File

@ -33,7 +33,9 @@ import { ElementSize } from "../_"
* *
* @returns Element content size * @returns Element content size
*/ */
export function getElementContentSize(el: HTMLElement): ElementSize { export function getElementContentSize(
el: HTMLElement
): ElementSize {
return { return {
width: el.scrollWidth, width: el.scrollWidth,
height: el.scrollHeight height: el.scrollHeight

View File

@ -29,6 +29,7 @@ import {
filter, filter,
finalize, finalize,
map, map,
merge,
of, of,
shareReplay, shareReplay,
startWith, startWith,
@ -68,7 +69,7 @@ const observer$ = defer(() => of(
}) })
)) ))
.pipe( .pipe(
switchMap(observer => NEVER.pipe(startWith(observer)) switchMap(observer => merge(NEVER, of(observer))
.pipe( .pipe(
finalize(() => observer.disconnect()) finalize(() => observer.disconnect())
) )

View File

@ -22,10 +22,14 @@
import { Observable, merge } from "rxjs" import { Observable, merge } from "rxjs"
import { Viewport, getElements } from "~/browser" import { getElements } from "~/browser"
import { Component } from "../../_" import { Component } from "../../_"
import { CodeBlock, mountCodeBlock } from "../code" import {
Annotation,
CodeBlock,
mountCodeBlock
} from "../code"
import { Details, mountDetails } from "../details" import { Details, mountDetails } from "../details"
import { DataTable, mountDataTable } from "../table" import { DataTable, mountDataTable } from "../table"
import { ContentTabs, mountContentTabs } from "../tabs" import { ContentTabs, mountContentTabs } from "../tabs"
@ -38,6 +42,7 @@ import { ContentTabs, mountContentTabs } from "../tabs"
* Content * Content
*/ */
export type Content = export type Content =
| Annotation
| ContentTabs | ContentTabs
| CodeBlock | CodeBlock
| DataTable | DataTable
@ -52,7 +57,6 @@ export type Content =
*/ */
interface MountOptions { interface MountOptions {
target$: Observable<HTMLElement> /* Location target observable */ target$: Observable<HTMLElement> /* Location target observable */
viewport$: Observable<Viewport> /* Viewport observable */
hover$: Observable<boolean> /* Media hover observable */ hover$: Observable<boolean> /* Media hover observable */
print$: Observable<boolean> /* Media print observable */ print$: Observable<boolean> /* Media print observable */
} }
@ -73,13 +77,13 @@ interface MountOptions {
* @returns Content component observable * @returns Content component observable
*/ */
export function mountContent( export function mountContent(
el: HTMLElement, { target$, viewport$, hover$, print$ }: MountOptions el: HTMLElement, { target$, hover$, print$ }: MountOptions
): Observable<Component<Content>> { ): Observable<Component<Content>> {
return merge( return merge(
/* Code blocks */ /* Code blocks */
...getElements("pre > code", el) ...getElements("pre > code", el)
.map(child => mountCodeBlock(child, { viewport$, hover$, print$ })), .map(child => mountCodeBlock(child, { hover$, print$ })),
/* Data tables */ /* Data tables */
...getElements("table:not([class])", el) ...getElements("table:not([class])", el)

View File

@ -0,0 +1,219 @@
/*
* 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,
distinctUntilKeyChanged,
finalize,
map,
mergeWith,
switchMap,
takeLast,
takeUntil,
tap,
withLatestFrom
} 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 {
hover$: Observable<boolean> /* Media hover observable */
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global sequence number for Clipboard.js integration
*/
let sequence = 0
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find the code annotations belonging to a code block
*
* @param el - Code block element
*
* @returns Code annotation list or nothing
*/
function findAnnotationList(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 findAnnotationList(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, { hover$, ...options }: MountOptions
): Observable<Component<CodeBlock | Annotation>> {
return defer(() => {
const push$ = new Subject<CodeBlock>()
push$
.pipe(
withLatestFrom(hover$)
)
.subscribe(([{ scrollable: scroll }, hover]) => {
if (scroll && 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(".highlighttable") ||
el.closest(".highlight")
if (container instanceof HTMLElement) {
const list = findAnnotationList(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))),
switchMap(({ width, height }) => width && height
? 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

@ -0,0 +1,142 @@
/*
* 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 {
EMPTY,
Observable,
Subject,
combineLatest,
defer,
finalize,
fromEvent,
map,
switchMap,
take,
tap
} from "rxjs"
import {
ElementOffset,
watchElementContentOffset,
watchElementFocus,
watchElementOffset
} from "~/browser"
import { Component } from "../../../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Code annotation
*/
export interface Annotation {
active: boolean /* Code annotation is visible */
offset: ElementOffset /* Code annotation offset */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch code annotation
*
* @param el - Code annotation element
* @param container - Containing code block element
*
* @returns Code annotation observable
*/
export function watchAnnotation(
el: HTMLElement, container: HTMLElement
): Observable<Annotation> {
const offset$ = defer(() => combineLatest([
watchElementOffset(el),
watchElementContentOffset(container)
]))
.pipe(
map(([{ x, y }, scroll]) => ({
x: x - scroll.x,
y: y - scroll.y
}))
)
/* Actively watch code annotation on focus */
return watchElementFocus(el)
.pipe(
switchMap(active => offset$
.pipe(
map(offset => ({ active, offset })),
take(+!active || Infinity)
)
)
)
}
/**
* Mount code annotation
*
* @param el - Code annotation element
* @param container - Containing code block element
*
* @returns Code annotation component observable
*/
export function mountAnnotation(
el: HTMLElement, container: HTMLElement
): Observable<Component<Annotation>> {
return defer(() => {
const push$ = new Subject<Annotation>()
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")
}
})
/* Blur open annotation on click (= close) */
const index = el.lastElementChild!
const blur$ = fromEvent(index, "mousedown", { once: true })
push$
.pipe(
switchMap(({ active }) => active ? blur$ : EMPTY),
tap(ev => ev.preventDefault())
)
.subscribe(() => el.blur())
/* Create and return component */
return watchAnnotation(el, container)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@ -0,0 +1,24 @@
/*
* 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.
*/
export * from "./_"
export * from "./list"

View File

@ -0,0 +1,156 @@
/*
* 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 {
Observable,
Subject,
defer,
finalize,
merge,
share,
startWith,
takeLast,
takeUntil
} from "rxjs"
import {
getElement,
getElements
} from "~/browser"
import { renderAnnotation } from "~/templates"
import { Component } from "../../../../_"
import {
Annotation,
mountAnnotation
} from "../_"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find all code annotation markers in the given code block
*
* @param container - Containing code block element
*
* @returns Code annotation markers
*/
function findAnnotationMarkers(container: HTMLElement): Text[] {
const markers: Text[] = []
for (const comment of getElements(".c, .c1, .cm", container)) {
let match: RegExpExecArray | null
let text = comment.firstChild as Text
/* Split text at marker and add to list */
while ((match = /\((\d+)\)/.exec(text.textContent!))) {
const marker = text.splitText(match.index)
text = marker.splitText(match[0].length)
markers.push(marker)
}
}
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 code annotation list
*
* @param el - Code annotation list element
* @param container - Containing code block element
* @param options - Options
*
* @returns Code annotation list component observable
*/
export function mountAnnotationList(
el: HTMLElement, container: HTMLElement, options: MountOptions
): Observable<Component<Annotation>> {
const { print$ } = options
/* Find and replace all markers with empty annotations */
const annotations = new Map<number, HTMLElement>()
for (const marker of findAnnotationMarkers(container)) {
const [, id] = marker.textContent!.match(/\((\d+)\)/)!
annotations.set(+id, renderAnnotation(+id))
marker.replaceWith(annotations.get(+id)!)
}
/* Create and return component */
return defer(() => {
const push$ = new Subject<Annotation>()
/* Handle print mode - see https://bit.ly/3rgPdpt */
print$
.pipe(
startWith(false),
takeUntil(push$.pipe(takeLast(1)))
)
.subscribe(active => {
el.hidden = !active
/* Move annotation contents back into list */
for (const [id, annotation] of annotations) {
const tooltip = getElement(".md-typeset", annotation)
const child = getElement(`li:nth-child(${id})`, el)
if (!active)
swap(child, tooltip)
else
swap(tooltip, child)
}
})
/* Create and return component */
return merge(
...[...annotations].map(([, annotation]) => (
mountAnnotation(annotation, container)
))
)
.pipe(
finalize(() => push$.complete()),
share()
)
})
}

View File

@ -20,295 +20,5 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import ClipboardJS from "clipboard" export * from "./_"
import { export * from "./annotation"
NEVER,
Observable,
Subject,
combineLatest,
defer,
distinctUntilKeyChanged,
finalize,
fromEvent,
map,
mapTo,
merge,
mergeMap,
mergeWith,
of,
share,
tap,
withLatestFrom
} from "rxjs"
import { feature } from "~/_"
import {
resetFocusable,
setFocusable
} from "~/actions"
import {
Viewport,
getElement,
getElementContentSize,
getElementSize,
getElements,
getOptionalElement,
watchElementContentOffset,
watchElementOffset
} from "~/browser"
import {
renderAnnotation,
renderClipboardButton
} from "~/templates"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Code block
*/
export interface CodeBlock {
scrollable: boolean /* Code block overflows */
annotations: HTMLElement[] /* Code block annotations */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
print$: Observable<boolean> /* Media print observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
hover$: Observable<boolean> /* Media hover observable */
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global sequence number for Clipboard.js integration
*/
let sequence = 0
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find the code annotations belonging to a code block
*
* @param el - Code block element
*
* @returns Code annotations or nothing
*/
function findAnnotationsList(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 findAnnotationsList(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
* @param options - Options
*
* @returns Code block observable
*/
export function watchCodeBlock(
el: HTMLElement, { viewport$, print$ }: WatchOptions
): Observable<CodeBlock> {
/* Trigger re-rendering when code blocks are revealed */
const reveal$ = defer(() => {
const container = el.closest("[data-tabs]")
if (container instanceof HTMLElement) {
return merge(
...getElements(":scope > input", container)
.map(input => fromEvent(input, "change"))
)
}
return NEVER
})
.pipe(
mapTo(undefined),
share()
)
/* Check for code annotations */
const annotations: HTMLElement[] = []
const container =
el.closest(".highlighttable") ||
el.closest(".highlight")
if (container instanceof HTMLElement) {
const list = findAnnotationsList(container)
if (typeof list !== "undefined" && (
container.classList.contains("annotate") ||
feature("content.code.annotate")
)) {
list.remove()
/* Replace comments with annotations */
const items = getElements(":scope > li", list)
for (const comment of getElements(".c, .c1, .cm", el)) {
/* Split comment at annotations */
let match: RegExpExecArray | null
let text = comment.firstChild as Text
do {
match = /\((\d+)\)/.exec(text.textContent!)
if (match && match.index) {
const index = text.splitText(match.index)
text = index.splitText(match[0].length)
/* Render and insert code annotation */
const [, j = -1] = match
const content = items[+j - 1]
if (typeof content !== "undefined") {
const annotation = renderAnnotation(+j, content.childNodes)
index.replaceWith(annotation)
annotations.push(annotation)
}
}
} while (match)
}
/* Move elements back on print */ // TODO: fix instant loading memleak
print$.subscribe(active => {
if (active) {
container.insertAdjacentElement("afterend", list)
for (const annotation of annotations) {
const id = parseInt(annotation.getAttribute("data-index")!, 10)
const typeset = getOptionalElement(":scope .md-typeset", annotation)!
items[id - 1].append(...Array.from(typeset.childNodes))
}
} else {
list.remove()
for (const annotation of annotations) {
const id = parseInt(annotation.getAttribute("data-index")!, 10)
const nodes = items[id - 1].childNodes
getElement(":scope .md-typeset", annotation)
.append(...Array.from(nodes))
}
}
})
}
}
const change$ = viewport$
.pipe(
distinctUntilKeyChanged("size"),
mergeWith(reveal$),
mapTo(undefined)
)
/* Compute code annotation position */ // TODO: fix instant loading memleak
of(...annotations)
.pipe(
mergeMap(annotation => combineLatest([
of(annotation),
watchElementOffset(annotation),
watchElementContentOffset(el),
change$
]))
)
// TODO: return to mountCodeBlock and render and complete there
.subscribe(([annotation, { x, y }, scroll]) => {
annotation.style.setProperty(
"--md-tooltip-x", `${x - scroll.x}px`
)
annotation.style.setProperty(
"--md-tooltip-y", `${y - scroll.y}px`
)
})
/* Compute overflow state on resize and content tab change */
return change$
.pipe(
map(() => {
const visible = getElementSize(el)
const content = getElementContentSize(el)
return {
scrollable: content.width > visible.width,
annotations
}
}),
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.
*
* @param el - Code block element
* @param options - Options
*
* @returns Code block component observable
*/
export function mountCodeBlock(
el: HTMLElement, { hover$, ...options }: MountOptions
): Observable<Component<CodeBlock>> {
const internal$ = new Subject<CodeBlock>()
internal$
.pipe(
withLatestFrom(hover$)
)
.subscribe(([{ scrollable: scroll }, hover]) => {
if (scroll && hover)
setFocusable(el)
else
resetFocusable(el)
})
/* Render button for Clipboard.js integration */
if (ClipboardJS.isSupported()) {
const parent = el.closest("pre")!
parent.id = `__code_${++sequence}`
parent.insertBefore(
renderClipboardButton(parent.id),
el
)
}
/* Create and return component */
return watchCodeBlock(el, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -92,7 +92,7 @@ interface MountOptions {
export function watchHeaderTitle( export function watchHeaderTitle(
el: HTMLHeadingElement, { viewport$, header$ }: WatchOptions el: HTMLHeadingElement, { viewport$, header$ }: WatchOptions
): Observable<HeaderTitle> { ): Observable<HeaderTitle> {
return watchViewportAt(el, { header$, viewport$ }) return watchViewportAt(el, { viewport$, header$ })
.pipe( .pipe(
map(({ offset: { y } }) => { map(({ offset: { y } }) => {
const { height } = getElementSize(el) const { height } = getElementSize(el)

View File

@ -27,22 +27,17 @@ import { h } from "~/utilities"
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
/** /**
* Render a a code annotation * Render an empty code annotation
* *
* @param id - Unique identifier * @param id - Annotation identifier
* @param content - Annotation content
* *
* @returns Element * @returns Element
*/ */
export function renderAnnotation( export function renderAnnotation(id: number): HTMLElement {
id: number, content: NodeListOf<ChildNode>
): HTMLElement {
return ( return (
<aside class="md-annotation" data-index={id} tabIndex={0}> <aside class="md-annotation" tabIndex={0}>
<div class="md-annotation__inner md-tooltip"> <div class="md-annotation__inner md-tooltip">
<div class="md-tooltip__inner md-typeset"> <div class="md-tooltip__inner md-typeset"></div>
{...Array.from(content)}
</div>
</div> </div>
<span class="md-annotation__index">{id}</span> <span class="md-annotation__index">{id}</span>
</aside> </aside>

View File

@ -183,10 +183,10 @@
transition: z-index 250ms; transition: z-index 250ms;
user-select: none; user-select: none;
// Code annotation index bubble the bubble must be positioned absolutely // Code annotation marker the marker must be positioned absolutely behind
// behind the index, because it shouldn't impact the rendering of a code // the index, because it shouldn't impact the rendering of a code block.
// block. Otherwise, small rounding differences in browsers can sometimes // Otherwise, small rounding differences in browsers can sometimes mess up
// mess up alignment of text following a code annotation. // alignment of text following a code annotation.
&::after { &::after {
position: absolute; position: absolute;
top: 0.1ch; top: 0.1ch;
@ -210,12 +210,12 @@
animation: none; animation: none;
} }
// Code annotation index bubble on focus/hover // Code annotation marker on focus/hover
:is(:focus-within, :hover) > & { :is(:focus-within, :hover) > & {
background-color: var(--md-accent-fg-color); background-color: var(--md-accent-fg-color);
} }
// Code annotation index bubble on focus // Code annotation marker on focus
:focus-within > & { :focus-within > & {
transition: transition:
color 250ms, color 250ms,

View File

@ -329,10 +329,14 @@
"base": base_url, "base": base_url,
"features": features, "features": features,
"translations": {}, "translations": {},
"search": "assets/javascripts/workers/search.js" | url, "search": "assets/javascripts/workers/search.js" | url
"version": config.extra.version or None
} -%} } -%}
<!-- Versioning -->
{%- if config.extra.version -%}
{%- set _ = app.update({ "version": config.extra.version }) -%}
{%- endif -%}
<!-- Translations --> <!-- Translations -->
{%- set translations = app.translations -%} {%- set translations = app.translations -%}
{%- for key in [ {%- for key in [