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

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.
*/
import ClipboardJS from "clipboard"
import {
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 }))
)
}
export * from "./_"
export * from "./annotation"