"use client"; import { useEffect, useRef, useState } from "react"; import { scrollBehavior } from "@/lib/motion"; import { flashElement } from "@/lib/flash"; export interface TocItem { level: 2 | 3; text: string; id: string; } /** * 文章目录树:右栏 flex 限高列中的可收缩模块。 * - 标题常驻,目录列表在超高时于面板内部滚动(细滚动条,不顶出侧栏); * - IntersectionObserver 高亮当前小节; * - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。 */ export default function PostToc({ items, title = "目录", bare = false, }: { items: TocItem[]; title?: string; bare?: boolean; }) { 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 变化时把当前目录项滚入列表可视区(仅在列表容器内最小滚动)。 // 不用 scrollIntoView:移动端目录下沉到文末(普通文档流、容器本身不可滚), // scrollIntoView(nearest) 会连带滚动窗口,把正在阅读的页面拽到底部目录, // 表现为"进页自动下滚 / 无法向上滑动"。 useEffect(() => { if (skipFollow.current) { skipFollow.current = false; return; } const list = navRef.current?.querySelector(".j13-rail-scroll"); const el = list?.querySelector(`[data-toc-id="${active}"]`); if (!list || !el) return; const listRect = list.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); if (elRect.top < listRect.top) { list.scrollBy({ top: elRect.top - listRect.top, behavior: scrollBehavior() }); } else if (elRect.bottom > listRect.bottom) { list.scrollBy({ top: elRect.bottom - listRect.bottom, behavior: scrollBehavior() }); } }, [active]); const jump = (id: string) => (e: React.MouseEvent) => { e.preventDefault(); const target = document.getElementById(id); if (!target) return; target.scrollIntoView({ behavior: scrollBehavior(), block: "start" }); flashElement(target); // 主题色浅底高亮渐隐,标记落点 setActive(id); }; return ( ); }