aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/TOCHeader.astro
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/TOCHeader.astro')
-rw-r--r--src/components/TOCHeader.astro398
1 files changed, 398 insertions, 0 deletions
diff --git a/src/components/TOCHeader.astro b/src/components/TOCHeader.astro
new file mode 100644
index 0000000..5500f17
--- /dev/null
+++ b/src/components/TOCHeader.astro
@@ -0,0 +1,398 @@
+---
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { cn, getHeadingMargin } from '@/lib/utils'
+import type { MarkdownHeading } from 'astro'
+import { Icon } from 'astro-icon/components'
+
+type Props = {
+ headings: MarkdownHeading[]
+}
+
+const { headings } = Astro.props
+---
+
+{
+ headings && headings.length > 0 && (
+ <div id="mobile-toc-container" class="w-full xl:hidden">
+ <details class="group">
+ <summary class="flex w-full cursor-pointer items-center justify-between">
+ <div class="mx-auto flex w-full max-w-3xl items-center px-4 py-3">
+ <div class="relative mr-2 size-4">
+ <svg class="h-4 w-4" viewBox="0 0 24 24">
+ <circle
+ class="text-primary/20"
+ cx="12"
+ cy="12"
+ r="10"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ />
+ <circle
+ id="mobile-toc-progress-circle"
+ class="text-primary"
+ cx="12"
+ cy="12"
+ r="10"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-dasharray="62.83"
+ stroke-dashoffset="62.83"
+ transform="rotate(-90 12 12)"
+ />
+ </svg>
+ </div>
+ <span
+ id="mobile-toc-current-section"
+ class="text-muted-foreground flex-grow truncate text-sm"
+ >
+ Overview
+ </span>
+ <span class="text-muted-foreground ml-2">
+ <Icon
+ name="lucide:chevron-down"
+ class="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
+ />
+ </span>
+ </div>
+ </summary>
+
+ <ScrollArea
+ client:load
+ className="mx-auto max-w-3xl"
+ data-toc-header-scroll
+ >
+ <div class="max-h-[30vh]">
+ <ul
+ class="flex list-none flex-col gap-y-2 px-4 pb-4"
+ id="mobile-table-of-contents"
+ >
+ {headings.map((heading) => (
+ <li
+ class={cn(
+ 'px-4 text-sm',
+ getHeadingMargin(heading.depth),
+ 'text-foreground/60',
+ )}
+ >
+ <a
+ href={`#${heading.slug}`}
+ class="mobile-toc-item underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
+ data-heading-id={heading.slug}
+ >
+ {heading.text}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </ScrollArea>
+ </details>
+ </div>
+ )
+}
+
+<script>
+ const INITIAL_OVERVIEW_TEXT = 'Overview'
+ const HEADER_OFFSET = 102 + 36
+ const PROGRESS_CIRCLE_RADIUS = 10
+ const PROGRESS_CIRCLE_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_CIRCLE_RADIUS
+
+ class MobileTOCState {
+ progressCircle: HTMLElement | null = null
+ currentSectionText: HTMLElement | null = null
+ detailsElement: HTMLDetailsElement | null = null
+ listElement: HTMLElement | null = null
+ scrollArea: HTMLElement | null = null
+ headings: HTMLElement[] = []
+ regions: { id: string; start: number; end: number }[] = []
+ activeIds: string[] = []
+
+ reset() {
+ this.progressCircle = document.getElementById(
+ 'mobile-toc-progress-circle',
+ )
+ this.currentSectionText = document.getElementById(
+ 'mobile-toc-current-section',
+ )
+ this.detailsElement = document.querySelector(
+ '#mobile-toc-container details',
+ )
+ this.listElement = document.getElementById('mobile-table-of-contents')
+ const tocContainer = document.getElementById('mobile-toc-container')
+ this.scrollArea =
+ tocContainer?.querySelector('[data-radix-scroll-area-viewport]') || null
+ this.headings = []
+ this.regions = []
+ this.activeIds = []
+
+ if (this.progressCircle) {
+ this.progressCircle.style.strokeDasharray =
+ PROGRESS_CIRCLE_CIRCUMFERENCE.toString()
+ this.progressCircle.style.strokeDashoffset =
+ PROGRESS_CIRCLE_CIRCUMFERENCE.toString()
+ }
+ }
+ }
+
+ const state = new MobileTOCState()
+
+ 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 TOCHeaderScrollMask {
+ static update() {
+ if (!state.scrollArea) return
+
+ const { scrollTop, scrollHeight, clientHeight } = state.scrollArea
+ const threshold = 5
+ const isAtTop = scrollTop <= threshold
+ const isAtBottom = scrollTop >= scrollHeight - clientHeight - threshold
+
+ state.scrollArea.classList.toggle('mask-t-from-80%', !isAtTop)
+ state.scrollArea.classList.toggle('mask-b-from-80%', !isAtBottom)
+ }
+ }
+
+ class MobileTOCLinks {
+ static update(headingIds: string[]) {
+ if (!state.listElement || !state.currentSectionText) return
+
+ state.listElement.querySelectorAll('.mobile-toc-item').forEach((item) => {
+ const tocItem = item as HTMLElement
+ const headingId = tocItem.dataset.headingId
+ if (headingId && headingIds.includes(headingId)) {
+ tocItem.classList.add('text-foreground')
+ } else {
+ tocItem.classList.remove('text-foreground')
+ }
+ })
+
+ if (headingIds.length > 0) {
+ this.scrollToActive(headingIds[0])
+ }
+
+ this.updateCurrentSectionText(headingIds)
+ }
+
+ static updateCurrentSectionText(headingIds: string[]) {
+ if (!state.currentSectionText) return
+
+ let textToShow = INITIAL_OVERVIEW_TEXT
+
+ if (headingIds.length > 0) {
+ const activeTexts = state.headings
+ .filter(
+ (heading) => headingIds.includes(heading.id) && heading.textContent,
+ )
+ .map((heading) => heading.textContent!.trim())
+
+ if (activeTexts.length > 0) {
+ textToShow = activeTexts.join(', ')
+ }
+ }
+
+ state.currentSectionText.textContent = textToShow
+ }
+
+ static scrollToActive(activeHeadingId: string) {
+ if (!state.listElement) return
+
+ const activeItem = state.listElement.querySelector(
+ `[data-heading-id="${activeHeadingId}"]`,
+ )
+ if (!activeItem) return
+
+ const scrollContainer = state.scrollArea
+ if (!scrollContainer) return
+
+ const { top: containerTop, height: containerHeight } =
+ scrollContainer.getBoundingClientRect()
+ const { top: itemTop, height: itemHeight } =
+ activeItem.getBoundingClientRect()
+
+ const currentItemTop = itemTop - containerTop + scrollContainer.scrollTop
+ const targetScroll = Math.max(
+ 0,
+ Math.min(
+ currentItemTop - (containerHeight - itemHeight) / 2,
+ scrollContainer.scrollHeight - scrollContainer.clientHeight,
+ ),
+ )
+
+ if (Math.abs(targetScroll - scrollContainer.scrollTop) > 5) {
+ scrollContainer.scrollTop = targetScroll
+ }
+ }
+
+ static setupInteraction() {
+ if (!state.listElement) return
+
+ state.listElement.querySelectorAll('.mobile-toc-item').forEach((item) => {
+ item.addEventListener('click', () => {
+ if (state.detailsElement) state.detailsElement.open = false
+ })
+ })
+
+ if (state.scrollArea) {
+ state.scrollArea.addEventListener(
+ 'scroll',
+ () => TOCHeaderScrollMask.update(),
+ { passive: true },
+ )
+ }
+
+ if (state.detailsElement) {
+ state.detailsElement.addEventListener('toggle', () => {
+ if (state.detailsElement?.open) {
+ setTimeout(TOCHeaderScrollMask.update, 100)
+ }
+ })
+ }
+ }
+ }
+
+ class ProgressCircle {
+ static update() {
+ if (!state.progressCircle) return
+
+ const scrollableDistance =
+ document.documentElement.scrollHeight - window.innerHeight
+ const scrollProgress =
+ scrollableDistance > 0
+ ? Math.min(Math.max(window.scrollY / scrollableDistance, 0), 1)
+ : 0
+
+ state.progressCircle.style.strokeDashoffset = (
+ PROGRESS_CIRCLE_CIRCUMFERENCE *
+ (1 - scrollProgress)
+ ).toString()
+ }
+ }
+
+ class MobileTOCController {
+ static handleScroll() {
+ const newActiveIds = HeadingRegions.getVisibleIds()
+
+ if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
+ state.activeIds = newActiveIds
+ MobileTOCLinks.update(state.activeIds)
+ }
+
+ ProgressCircle.update()
+ }
+
+ static handleResize() {
+ HeadingRegions.build()
+
+ const newActiveIds = HeadingRegions.getVisibleIds()
+ if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
+ state.activeIds = newActiveIds
+ MobileTOCLinks.update(state.activeIds)
+ }
+
+ ProgressCircle.update()
+ }
+
+ static init() {
+ state.reset()
+ HeadingRegions.build()
+
+ if (!state.currentSectionText) return
+
+ if (state.headings.length === 0) {
+ state.currentSectionText.textContent = INITIAL_OVERVIEW_TEXT
+ window.addEventListener('scroll', ProgressCircle.update, {
+ passive: true,
+ })
+ ProgressCircle.update()
+ return
+ }
+
+ state.activeIds = HeadingRegions.getVisibleIds()
+ MobileTOCLinks.update(state.activeIds)
+ ProgressCircle.update()
+
+ MobileTOCLinks.setupInteraction()
+ window.addEventListener('scroll', this.handleScroll, { passive: true })
+ window.addEventListener('resize', this.handleResize, { passive: true })
+ }
+
+ static cleanup() {
+ window.removeEventListener('scroll', this.handleScroll)
+ window.removeEventListener('scroll', ProgressCircle.update)
+ window.removeEventListener('resize', this.handleResize)
+ state.activeIds = []
+ state.headings = []
+ state.regions = []
+ }
+ }
+
+ document.addEventListener('astro:page-load', () => MobileTOCController.init())
+ document.addEventListener('astro:after-swap', () => {
+ MobileTOCController.cleanup()
+ MobileTOCController.init()
+ })
+ document.addEventListener('astro:before-swap', () =>
+ MobileTOCController.cleanup(),
+ )
+</script>