mirror of
https://github.com/lbr77/blog-astro.git
synced 2026-04-09 00:19:12 +00:00
369 lines
12 KiB
Plaintext
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>
|