All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
93 lines
3.1 KiB
Vue
93 lines
3.1 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Renders markdown (with LaTeX) into styled prose HTML.
|
|
* Used on article pages and in the editor's live preview pane.
|
|
*/
|
|
const props = defineProps<{
|
|
content: string
|
|
/** Intercept clicks on internal links and use client-side routing. */
|
|
interceptLinks?: boolean
|
|
/**
|
|
* Prefix for the heading ids this article is given. Several articles are
|
|
* open at once in the reader, so their sections are namespaced by slug to
|
|
* keep two articles' "Proof" headings from sharing one id.
|
|
*/
|
|
namespace?: string
|
|
/**
|
|
* The article's own language: callout boxes (```theorem, …) label
|
|
* themselves in it, whatever language the interface happens to speak.
|
|
*/
|
|
articleLang?: string
|
|
}>()
|
|
|
|
const rendered = computed(() => renderMarkdown(props.content, props.namespace, props.articleLang))
|
|
|
|
const router = useRouter()
|
|
|
|
/* Callout boxes fold away under their label. The browser's <details> carries
|
|
the state, but a state flip alone has no motion: the toggle click is caught
|
|
here and the fold — height and opacity of the box's body — runs through the
|
|
Web Animations API. Opening sets `open` right away (the body needs to be
|
|
laid out before it can be measured); closing flips it only once the body
|
|
has folded away. The box's intended state rides along as data-open, so
|
|
re-clicking mid-fold picks the animation up where it stands instead of
|
|
fighting it. */
|
|
const folds = new WeakMap<HTMLDetailsElement, Animation>()
|
|
|
|
function foldBox(details: HTMLDetailsElement, event: MouseEvent) {
|
|
const body = details.querySelector<HTMLElement>('.md-box-body')
|
|
const isOpen = details.dataset.open ?? String(details.open)
|
|
const wantOpen = isOpen !== 'true'
|
|
details.dataset.open = String(wantOpen)
|
|
|
|
// nothing to shrink, or the reader asks for no motion: let <details> answer
|
|
if (!body || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
|
|
|
event.preventDefault()
|
|
const running = folds.get(details)
|
|
// a fold in flight shows its height — the new animation starts there
|
|
const from = running ? getComputedStyle(body).height : wantOpen ? '0px' : `${body.scrollHeight}px`
|
|
running?.cancel()
|
|
if (wantOpen) details.open = true
|
|
|
|
body.style.overflow = 'hidden'
|
|
const anim = body.animate(
|
|
[
|
|
{ height: from, opacity: wantOpen ? 0 : 1 },
|
|
{ height: wantOpen ? `${body.scrollHeight}px` : '0px', opacity: wantOpen ? 1 : 0 },
|
|
],
|
|
{ duration: 260, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' },
|
|
)
|
|
folds.set(details, anim)
|
|
anim.onfinish = () => {
|
|
details.open = wantOpen
|
|
body.style.overflow = ''
|
|
folds.delete(details)
|
|
}
|
|
}
|
|
|
|
function onClick(e: MouseEvent) {
|
|
const target = e.target as HTMLElement
|
|
const summary = target.closest?.('summary')
|
|
if (summary) {
|
|
const details = summary.closest('details')
|
|
if (details) {
|
|
foldBox(details, e)
|
|
return
|
|
}
|
|
}
|
|
if (!props.interceptLinks) return
|
|
const anchor = target.closest?.('a')
|
|
if (!anchor) return
|
|
const href = anchor.getAttribute('href') || ''
|
|
if (href.startsWith('/')) {
|
|
e.preventDefault()
|
|
router.push(href)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="prose" v-html="rendered.html" @click="onClick" />
|
|
</template>
|