First commit
This commit is contained in:
73
frontend/app/components/TocNav.vue
Normal file
73
frontend/app/components/TocNav.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user