74 lines
2.3 KiB
Vue
74 lines
2.3 KiB
Vue
<script setup lang="ts">
|
|
import type { TocEntry } from '~/utils/markdown'
|
|
|
|
const props = defineProps<{ entries: TocEntry[] }>()
|
|
const { t } = useI18n()
|
|
|
|
const navEl = ref<HTMLElement | null>(null)
|
|
const activeId = ref<string>('')
|
|
let observer: IntersectionObserver | null = null
|
|
|
|
/* The article in view scrolls inside its own page of the book, so the TOC
|
|
follows — and moves — that page's scroller rather than the window. */
|
|
function ownScroller(): HTMLElement | null {
|
|
return navEl.value?.closest<HTMLElement>('.pane-scroll') ?? null
|
|
}
|
|
|
|
function observe() {
|
|
observer?.disconnect()
|
|
if (!import.meta.client || !props.entries.length) return
|
|
const headings = props.entries
|
|
.map((e) => document.getElementById(e.id))
|
|
.filter((el): el is HTMLElement => !!el)
|
|
if (!headings.length) return
|
|
|
|
observer = new IntersectionObserver(
|
|
(records) => {
|
|
const visible = records
|
|
.filter((r) => r.isIntersecting)
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
|
if (visible.length) activeId.value = visible[0]!.target.id
|
|
},
|
|
{ root: ownScroller(), rootMargin: '-48px 0px -65% 0px', threshold: 0 }
|
|
)
|
|
headings.forEach((h) => observer!.observe(h))
|
|
}
|
|
|
|
onMounted(() => nextTick(observe))
|
|
watch(() => props.entries, () => nextTick(observe))
|
|
onBeforeUnmount(() => observer?.disconnect())
|
|
|
|
function goTo(id: string) {
|
|
activeId.value = id
|
|
const heading = document.getElementById(id)
|
|
if (!heading) return
|
|
const scroller = ownScroller()
|
|
if (!scroller) {
|
|
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
return
|
|
}
|
|
/* scroll the page itself — scrollIntoView would also shove the stack of
|
|
open pages sideways */
|
|
const top =
|
|
heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 18
|
|
scroller.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<nav v-if="entries.length" ref="navEl" class="toc" :aria-label="t('toc.aria')">
|
|
<div class="toc-title">{{ t('toc.title') }}</div>
|
|
<ol>
|
|
<li v-for="entry in entries" :key="entry.id" :class="`level-${entry.level}`">
|
|
<a
|
|
href="#"
|
|
:class="{ active: activeId === entry.id }"
|
|
@click.prevent="goTo(entry.id)"
|
|
>
|
|
{{ entry.text }}
|
|
</a>
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
</template>
|