Files
blog-astro/src/components/TOCSidebar.astro
2026-01-19 17:56:54 +08:00

369 lines
12 KiB
Plaintext

---
import { ScrollArea } from '@/components/ui/scroll-area'
import type { TOCSection } from '@/lib/data-utils'
import { getParentId, isSubpost } from '@/lib/data-utils'
import { cn, getHeadingMargin } from '@/lib/utils'
type Props = {
sections: TOCSection[]
currentPostId: string
}
const { sections, currentPostId } = Astro.props
const isCurrentSubpost = isSubpost(currentPostId)
const parentId = isCurrentSubpost ? getParentId(currentPostId) : currentPostId
---
{
sections.length > 0 && (
<div
id="toc-sidebar-container"
class="sticky top-20 col-start-1 row-span-1 mr-8 ml-auto hidden h-[calc(100vh-5rem)] max-w-[250px] xl:block"
>
<ScrollArea
client:load
className="flex max-h-[calc(100vh-8rem)] flex-col overflow-y-auto"
type="hover"
data-toc-scroll-area
>
<div class="flex flex-col gap-2 px-4">
<span class="text-lg font-medium">Table of Contents</span>
{sections.map((section, index) => {
const isFirstSubpost =
section.type === 'subpost' &&
(index === 0 || sections[index - 1].type === 'parent')
return (
<>
{isFirstSubpost && (
<div class="mt-2 flex items-center gap-2">
<div class="bg-border h-px flex-1" />
<span class="text-muted-foreground text-xs font-medium">
Subposts
</span>
<div class="bg-border h-px flex-1" />
</div>
)}
{section.type === 'parent' ? (
<ul class="flex list-none flex-col gap-y-2">
{section.headings.map((heading) => (
<li
class={cn(
'text-sm',
getHeadingMargin(heading.depth),
isCurrentSubpost
? 'text-foreground/40'
: 'text-foreground/60',
)}
>
<a
href={
isCurrentSubpost
? `/blog/${parentId}#${heading.slug}`
: `#${heading.slug}`
}
class="marker:text-foreground/30 list-none truncate underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
data-heading-link={heading.slug}
title={heading.text}
>
{heading.text}
</a>
</li>
))}
</ul>
) : (
<div
class={cn(
'rounded-md border p-2',
section.subpostId === currentPostId ? 'bg-muted/50' : '',
)}
>
<ul class="flex list-none flex-col gap-y-2">
<li
class={cn(
'text-xs font-medium',
section.subpostId === currentPostId
? 'text-foreground'
: 'text-foreground/60',
)}
>
<a
href={
section.subpostId === currentPostId
? '#'
: `/blog/${section.subpostId}`
}
class="marker:text-foreground/30 list-none truncate underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
data-heading-link={
section.subpostId === currentPostId
? 'top'
: `${section.subpostId}-top`
}
title={section.title}
>
{section.title}
</a>
</li>
{section.headings.map((heading) => (
<li
class={cn(
'text-xs',
getHeadingMargin(heading.depth),
section.subpostId === currentPostId
? 'text-foreground/60'
: 'text-foreground/30',
)}
>
<a
href={
section.subpostId === currentPostId
? `#${heading.slug}`
: `/blog/${section.subpostId}#${heading.slug}`
}
class="marker:text-foreground/30 hover:text-foreground/60 list-none truncate underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
data-heading-link={
section.subpostId === currentPostId
? heading.slug
: `${section.subpostId}-${heading.slug}`
}
title={heading.text}
>
{heading.text}
</a>
</li>
))}
</ul>
</div>
)}
</>
)
})}
</div>
</ScrollArea>
</div>
)
}
<script>
const HEADER_OFFSET = 150
class TOCState {
links: NodeListOf<Element> = document.querySelectorAll(
'[data-heading-link]',
)
activeIds: string[] = []
headings: HTMLElement[] = []
regions: { id: string; start: number; end: number }[] = []
scrollArea: HTMLElement | null = null
tocScrollArea: HTMLElement | null = null
reset() {
this.links = document.querySelectorAll(
'#toc-sidebar-container [data-heading-link]',
)
this.activeIds = []
this.headings = []
this.regions = []
const tocContainer = document.getElementById('toc-sidebar-container')
this.scrollArea =
tocContainer?.querySelector('[data-radix-scroll-area-viewport]') || null
this.tocScrollArea =
tocContainer?.querySelector('[data-toc-scroll-area]') || null
}
}
const state = new TOCState()
class HeadingRegions {
static build() {
state.headings = Array.from(
document.querySelectorAll<HTMLElement>(
'.prose h2, .prose h3, .prose h4, .prose h5, .prose h6',
),
)
if (state.headings.length === 0) {
state.regions = []
return
}
state.regions = state.headings.map((heading, index) => {
const nextHeading = state.headings[index + 1]
return {
id: heading.id,
start: heading.offsetTop,
end: nextHeading ? nextHeading.offsetTop : document.body.scrollHeight,
}
})
}
static getVisibleIds(): string[] {
if (state.headings.length === 0) return []
const viewportTop = window.scrollY + HEADER_OFFSET
const viewportBottom = window.scrollY + window.innerHeight
const visibleIds = new Set<string>()
const isInViewport = (top: number, bottom: number) =>
(top >= viewportTop && top <= viewportBottom) ||
(bottom >= viewportTop && bottom <= viewportBottom) ||
(top <= viewportTop && bottom >= viewportBottom)
state.headings.forEach((heading) => {
const headingBottom = heading.offsetTop + heading.offsetHeight
if (isInViewport(heading.offsetTop, headingBottom)) {
visibleIds.add(heading.id)
}
})
state.regions.forEach((region) => {
if (region.start <= viewportBottom && region.end >= viewportTop) {
const heading = document.getElementById(region.id)
if (heading) {
const headingBottom = heading.offsetTop + heading.offsetHeight
if (
region.end > headingBottom &&
(headingBottom < viewportBottom || viewportTop < region.end)
) {
visibleIds.add(region.id)
}
}
}
})
return Array.from(visibleIds)
}
}
class TOCScrollMask {
static update() {
if (!state.scrollArea || !state.tocScrollArea) return
const { scrollTop, scrollHeight, clientHeight } = state.scrollArea
const threshold = 5
const isAtTop = scrollTop <= threshold
const isAtBottom = scrollTop >= scrollHeight - clientHeight - threshold
state.tocScrollArea.classList.toggle('mask-t-from-90%', !isAtTop)
state.tocScrollArea.classList.toggle('mask-b-from-90%', !isAtBottom)
}
}
class TOCLinks {
static update(headingIds: string[]) {
state.links.forEach((link) => {
link.classList.remove('text-foreground')
})
headingIds.forEach((id) => {
if (id) {
const activeLink = document.querySelector(
`#toc-sidebar-container [data-heading-link="${id}"]`,
)
if (activeLink) {
activeLink.classList.add('text-foreground')
}
}
})
this.scrollToActive(headingIds)
}
static scrollToActive(headingIds: string[]) {
if (!state.scrollArea || !headingIds.length) return
const activeLink = document.querySelector(
`#toc-sidebar-container [data-heading-link="${headingIds[0]}"]`,
)
if (!activeLink) return
const { top: areaTop, height: areaHeight } =
state.scrollArea.getBoundingClientRect()
const { top: linkTop, height: linkHeight } =
activeLink.getBoundingClientRect()
const currentLinkTop = linkTop - areaTop + state.scrollArea.scrollTop
const targetScroll = Math.max(
0,
Math.min(
currentLinkTop - (areaHeight - linkHeight) / 2,
state.scrollArea.scrollHeight - state.scrollArea.clientHeight,
),
)
if (Math.abs(targetScroll - state.scrollArea.scrollTop) > 5) {
state.scrollArea.scrollTop = targetScroll
}
}
}
class TOCController {
static handleScroll() {
const newActiveIds = HeadingRegions.getVisibleIds()
if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
state.activeIds = newActiveIds
TOCLinks.update(state.activeIds)
}
}
static handleTOCScroll = () => TOCScrollMask.update()
static handleResize() {
HeadingRegions.build()
const newActiveIds = HeadingRegions.getVisibleIds()
if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
state.activeIds = newActiveIds
TOCLinks.update(state.activeIds)
}
TOCScrollMask.update()
}
static init() {
state.reset()
HeadingRegions.build()
if (state.headings.length === 0) {
TOCLinks.update([])
return
}
this.handleScroll()
setTimeout(TOCScrollMask.update, 100)
const options = { passive: true }
window.addEventListener('scroll', this.handleScroll, options)
window.addEventListener('resize', this.handleResize, options)
state.scrollArea?.addEventListener(
'scroll',
this.handleTOCScroll,
options,
)
}
static cleanup() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleResize)
state.scrollArea?.removeEventListener('scroll', this.handleTOCScroll)
Object.assign(state, {
activeIds: [],
headings: [],
regions: [],
scrollArea: null,
tocScrollArea: null,
})
}
}
document.addEventListener('astro:page-load', () => TOCController.init())
document.addEventListener('astro:after-swap', () => {
TOCController.cleanup()
TOCController.init()
})
document.addEventListener('astro:before-swap', () => TOCController.cleanup())
</script>