mirror of
https://github.com/lbr77/blog-astro.git
synced 2026-04-09 00:19:12 +00:00
Initial commit
This commit is contained in:
365
src/components/TOCSidebar.astro
Normal file
365
src/components/TOCSidebar.astro
Normal file
@@ -0,0 +1,365 @@
|
||||
---
|
||||
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-md 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 underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
|
||||
data-heading-link={heading.slug}
|
||||
>
|
||||
{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 underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
|
||||
data-heading-link={
|
||||
section.subpostId === currentPostId
|
||||
? 'top'
|
||||
: `${section.subpostId}-top`
|
||||
}
|
||||
>
|
||||
{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 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}`
|
||||
}
|
||||
>
|
||||
{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>
|
||||
Reference in New Issue
Block a user