aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/TOCSidebar.astro
diff options
context:
space:
mode:
authoraman333nolawz <amananudhiu@gmail.com>2026-03-02 21:49:13 +0530
committeraman333nolawz <amananudhiu@gmail.com>2026-03-02 21:49:13 +0530
commitd41eff9eccf66644c4c60f53cb516bf079937a28 (patch)
tree248a0863a3181369f943d678ec4273911d35efac /src/components/TOCSidebar.astro
Using astro-erudite for static site generation
Diffstat (limited to 'src/components/TOCSidebar.astro')
-rw-r--r--src/components/TOCSidebar.astro365
1 files changed, 365 insertions, 0 deletions
diff --git a/src/components/TOCSidebar.astro b/src/components/TOCSidebar.astro
new file mode 100644
index 0000000..77be7c6
--- /dev/null
+++ b/src/components/TOCSidebar.astro
@@ -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>