diff options
| author | aman333nolawz <amananudhiu@gmail.com> | 2026-03-02 21:49:13 +0530 |
|---|---|---|
| committer | aman333nolawz <amananudhiu@gmail.com> | 2026-03-02 21:49:13 +0530 |
| commit | d41eff9eccf66644c4c60f53cb516bf079937a28 (patch) | |
| tree | 248a0863a3181369f943d678ec4273911d35efac /src/components/TOCSidebar.astro | |
Using astro-erudite for static site generation
Diffstat (limited to 'src/components/TOCSidebar.astro')
| -rw-r--r-- | src/components/TOCSidebar.astro | 365 |
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> |
