Files
woonoow-docs/components/TocObserver.tsx

198 lines
7.1 KiB
TypeScript

"use client"
import clsx from "clsx"
import Link from "next/link"
import { useState, useRef, useEffect, useCallback } from "react"
import { motion } from "framer-motion"
import { ScrollToTop } from "./ScrollToTop"
import { TocItem } from "@/lib/toc"
interface TocObserverProps {
data: TocItem[]
activeId?: string | null
onActiveIdChange?: (id: string | null) => void
}
export default function TocObserver({
data,
activeId: externalActiveId,
onActiveIdChange,
}: TocObserverProps) {
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map())
const activeId = externalActiveId ?? null
const handleLinkClick = useCallback(
(id: string) => {
onActiveIdChange?.(id)
},
[onActiveIdChange]
)
// Function to check if an item has children
const hasChildren = (currentId: string, currentLevel: number) => {
const currentIndex = data.findIndex((item) => item.href.slice(1) === currentId)
if (currentIndex === -1 || currentIndex === data.length - 1) return false
const nextItem = data[currentIndex + 1]
return nextItem.level > currentLevel
}
// Calculate scroll progress for the active section
const [scrollProgress, setScrollProgress] = useState(0)
useEffect(() => {
const handleScroll = () => {
if (!activeId) return
const activeElement = document.getElementById(activeId)
if (!activeElement) return
const rect = activeElement.getBoundingClientRect()
const windowHeight = window.innerHeight
const elementTop = rect.top
const elementHeight = rect.height
// Calculate how much of the element is visible
let progress = 0
if (elementTop < windowHeight) {
progress = Math.min(1, (windowHeight - elementTop) / (windowHeight + elementHeight))
}
setScrollProgress(progress)
}
const container = document.getElementById("scroll-container") || window
container.addEventListener("scroll", handleScroll, { passive: true })
// Initial calculation
handleScroll()
return () => container.removeEventListener("scroll", handleScroll)
}, [activeId])
return (
<div className="relative">
<div className="text-foreground/70 hover:text-foreground relative text-sm transition-colors">
<div className="flex flex-col gap-0">
{data.map(({ href, level, text }, index) => {
const id = href.slice(1)
const isActive = activeId === id
const indent = level > 1 ? (level - 1) * 20 : 0
// Prefix with underscore to indicate intentionally unused
const _isParent = hasChildren(id, level)
const _isLastInLevel = index === data.length - 1 || data[index + 1].level <= level
return (
<div key={href} className="relative">
{/* Simple L-shaped connector */}
{level > 1 && (
<div
className={clsx("absolute top-0 h-full w-6", {
"left-[6px]": indent === 20, // Level 2
"left-[22px]": indent === 40, // Level 3
"left-[38px]": indent === 60, // Level 4
})}
>
{/* Vertical line */}
<div
className={clsx(
"absolute left-0 top-0 h-full w-px",
isActive
? "bg-primary/20 dark:bg-primary/30"
: "bg-border/50 dark:bg-border/50"
)}
>
{isActive && (
<motion.div
className="bg-primary absolute left-0 top-0 h-full w-full origin-top"
initial={{ scaleY: 0 }}
animate={{ scaleY: scrollProgress }}
transition={{ duration: 0.3 }}
/>
)}
</div>
{/* Horizontal line */}
<div
className={clsx(
"absolute left-0 top-1/2 h-px w-6",
isActive
? "bg-primary/20 dark:bg-primary/30"
: "bg-border/50 dark:bg-border/50"
)}
>
{isActive && (
<motion.div
className="bg-primary dark:bg-accent absolute left-0 top-0 h-full w-full origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: scrollProgress }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
)}
</div>
</div>
)}
<Link
href={href}
onClick={() => handleLinkClick(id)}
className={clsx("relative flex items-center py-2 transition-colors", {
"text-primary dark:text-primary font-medium": isActive,
"text-muted-foreground hover:text-foreground dark:hover:text-foreground/90":
!isActive,
})}
style={{
paddingLeft: `${indent}px`,
marginLeft: level > 1 ? "12px" : "0",
}}
ref={(el) => {
const map = itemRefs.current
if (el) {
map.set(id, el)
} else {
map.delete(id)
}
}}
>
{/* Circle indicator */}
<div className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<div
className={clsx(
"relative z-10 h-1.5 w-1.5 rounded-full transition-all duration-300",
{
"bg-primary dark:bg-primary/90 scale-100": isActive,
"bg-muted-foreground/30 dark:bg-muted-foreground/30 group-hover:bg-primary/50 dark:group-hover:bg-primary/50 scale-75 group-hover:scale-100":
!isActive,
}
)}
>
{isActive && (
<motion.div
className="bg-primary/20 dark:bg-primary/30 absolute inset-0 rounded-full"
initial={{ scale: 1 }}
animate={{ scale: 1.8 }}
transition={{
duration: 2,
repeat: Infinity,
repeatType: "reverse",
}}
/>
)}
</div>
</div>
<span className="truncate text-sm">{text}</span>
</Link>
</div>
)
})}
</div>
</div>
{/* Add scroll to top link at the bottom of TOC */}
<ScrollToTop className="mt-6" />
</div>
)
}