"use client"; import { getDocsTocs } from "@/lib/markdown"; import clsx from "clsx"; import Link from "next/link"; import { useState, useRef, useEffect, useCallback } from "react"; import { motion } from "framer-motion"; import { ScrollToTop } from "./scroll-to-top"; 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 [internalActiveId, setInternalActiveId] = useState(null); const observer = useRef(null); const [clickedId, setClickedId] = useState(null); const itemRefs = useRef>(new Map()); // Use external activeId if provided, otherwise use internal state const activeId = externalActiveId !== undefined ? externalActiveId : internalActiveId; const setActiveId = onActiveIdChange || setInternalActiveId; // Handle intersection observer for auto-highlighting useEffect(() => { const handleIntersect = (entries: IntersectionObserverEntry[]) => { const visibleEntries = entries.filter(entry => entry.isIntersecting); // Find the most recently scrolled-into-view element const mostVisibleEntry = visibleEntries.reduce((prev, current) => { // Prefer the entry that's more visible or higher on the page const prevRatio = prev?.intersectionRatio || 0; const currentRatio = current.intersectionRatio; if (currentRatio > prevRatio) return current; if (currentRatio === prevRatio && current.boundingClientRect.top < prev.boundingClientRect.top) { return current; } return prev; }, visibleEntries[0]); if (mostVisibleEntry && !clickedId) { const newActiveId = mostVisibleEntry.target.id; if (newActiveId !== activeId) { setActiveId(newActiveId); } } }; observer.current = new IntersectionObserver(handleIntersect, { root: null, rootMargin: "-20% 0px -70% 0px", // Adjusted margins for better section detection threshold: [0, 0.1, 0.5, 0.9, 1], // Multiple thresholds for better accuracy }); const elements = data.map((item) => document.getElementById(item.href.slice(1)) ); elements.forEach((el) => { if (el && observer.current) { observer.current.observe(el); } }); // Set initial active ID if none is set if (!activeId && elements[0]) { setActiveId(elements[0].id); } return () => { if (observer.current) { elements.forEach((el) => { if (el) { observer.current!.unobserve(el); } }); } }; }, [data, clickedId, activeId, setActiveId]); const handleLinkClick = useCallback((id: string) => { setClickedId(id); setActiveId(id); // Reset the clicked state after a delay to allow for smooth scrolling const timer = setTimeout(() => { setClickedId(null); }, 1000); return () => clearTimeout(timer); }, [setActiveId]); // 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); const [activeSectionIndex, setActiveSectionIndex] = 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); }; window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, [activeId]); // Update active section index when activeId changes useEffect(() => { if (activeId) { const index = data.findIndex(item => item.href.slice(1) === activeId); if (index !== -1) { setActiveSectionIndex(index); } } }, [activeId, data]); return (
{data.map(({ href, level, text }, index) => { const id = href.slice(1); const isActive = activeId === id; const indent = level > 1 ? (level - 1) * 20 : 0; const isParent = hasChildren(id, level); const isLastInLevel = index === data.length - 1 || data[index + 1].level <= level; return (
{/* Simple L-shaped connector */} {level > 1 && (
{/* Vertical line */}
{isActive && ( )}
{/* Horizontal line */}
{isActive && ( )}
)} handleLinkClick(id)} className={clsx( "relative flex items-center py-2 transition-colors", { "text-primary dark:text-primary-400 font-medium": isActive, "text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200": !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 */}
{isActive && ( )}
{text}
); })}
{/* Add scroll to top link at the bottom of TOC */}
); }