b7addb5c7a
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
105 lines
3.1 KiB
Vue
105 lines
3.1 KiB
Vue
<script setup lang="ts">
|
|
import { getHeadingsFromBody } from '~/utils/content-ast'
|
|
import type { Heading } from '~/utils/content-ast'
|
|
|
|
const props = defineProps<{
|
|
body?: Record<string, any> | null
|
|
}>()
|
|
|
|
const headings = ref<Heading[]>([])
|
|
const activeId = ref('')
|
|
let observer: IntersectionObserver | undefined
|
|
|
|
const NAV_HEIGHT = 40
|
|
|
|
const minLevel = computed(() => {
|
|
if (!headings.value.length) return 1
|
|
return Math.min(...headings.value.map((h) => h.level))
|
|
})
|
|
|
|
function indentClass(level: number): string {
|
|
const depth = Math.max(0, level - minLevel.value)
|
|
return ['pl-3', 'pl-6', 'pl-9', 'pl-12', 'pl-14', 'pl-16'][depth] || 'pl-16'
|
|
}
|
|
|
|
function buildHeadings() {
|
|
headings.value = getHeadingsFromBody(props.body)
|
|
}
|
|
|
|
function startObserver() {
|
|
observer?.disconnect()
|
|
if (!headings.value.length) return
|
|
nextTick(() => {
|
|
const els = headings.value
|
|
.map((h) => document.getElementById(h.id))
|
|
.filter(Boolean) as HTMLElement[]
|
|
if (!els.length) return
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const visible = entries
|
|
.filter((e) => e.isIntersecting)
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
|
if (visible.length > 0) {
|
|
activeId.value = (visible[0].target as HTMLElement).id
|
|
}
|
|
},
|
|
{ rootMargin: `${-(NAV_HEIGHT + 24)}px 0px -60% 0px`, threshold: 0 }
|
|
)
|
|
for (const el of els) observer.observe(el)
|
|
const firstVisible = els.find((el) => {
|
|
const r = el.getBoundingClientRect()
|
|
return r.bottom > NAV_HEIGHT + 24
|
|
})
|
|
activeId.value = (firstVisible || els[0]).id
|
|
})
|
|
}
|
|
|
|
buildHeadings()
|
|
|
|
onMounted(() => { startObserver() })
|
|
|
|
watch(() => props.body, () => {
|
|
buildHeadings()
|
|
if (headings.value.length) startObserver()
|
|
}, { deep: true })
|
|
|
|
onUnmounted(() => { observer?.disconnect() })
|
|
|
|
function handleClick(e: MouseEvent, id: string) {
|
|
const el = document.getElementById(id)
|
|
if (!el) return
|
|
e.preventDefault()
|
|
activeId.value = id
|
|
const top = el.getBoundingClientRect().top + window.scrollY - (NAV_HEIGHT + 16)
|
|
window.scrollTo({ top, behavior: 'instant' })
|
|
history.replaceState(null, '', `#${id}`)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<aside
|
|
v-if="headings.length > 0"
|
|
class="hidden xl:block fixed top-24 right-[max(1rem,calc((100vw-48rem)/2-18rem))] w-56 max-h-[calc(100vh-8rem)] overflow-y-auto pr-2 text-sm z-30"
|
|
aria-label="目录"
|
|
>
|
|
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">目录</div>
|
|
<ul class="space-y-1 border-l border-border">
|
|
<li v-for="h in headings" :key="h.id">
|
|
<a
|
|
:href="`#${h.id}`"
|
|
class="block py-1 pr-2 border-l-2 -ml-px transition-colors"
|
|
:class="[
|
|
indentClass(h.level),
|
|
activeId === h.id
|
|
? 'border-primary text-foreground font-medium'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/40',
|
|
]"
|
|
@click="handleClick($event, h.id)"
|
|
>
|
|
{{ h.text }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</aside>
|
|
</template>
|