"use client"; import { useEffect, useRef, useState } from "react"; export interface TocItem { level: 2 | 3; text: string; id: string; } /** * 文章目录树:右栏 flex 限高列中的可收缩模块。 * - 标题常驻,目录列表在超高时于面板内部滚动(细滚动条,不顶出侧栏); * - IntersectionObserver 高亮当前小节; * - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。 */ export default function PostToc({ items }: { items: TocItem[] }) { const [active, setActive] = useState(items[0]?.id ?? ""); const navRef = useRef(null); // 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动 const skipFollow = useRef(true); useEffect(() => { const heads = items .map((i) => document.getElementById(i.id)) .filter((el): el is HTMLElement => Boolean(el)); if (heads.length === 0) return; const obs = new IntersectionObserver( (entries) => { for (const e of entries) if (e.isIntersecting) setActive(e.target.id); }, // 顶部让出固定头部高度,底部提前切换到下一节 { rootMargin: "-80px 0px -70% 0px", threshold: 0 } ); heads.forEach((h) => obs.observe(h)); return () => obs.disconnect(); }, [items]); // active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动) useEffect(() => { if (skipFollow.current) { skipFollow.current = false; return; } const el = navRef.current?.querySelector(`[data-toc-id="${active}"]`); if (!el) return; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; el.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" }); }, [active]); const jump = (id: string) => (e: React.MouseEvent) => { e.preventDefault(); document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }; return ( ); }