Files
jiang13-bbs/frontend/components/PostToc.tsx

87 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import { scrollBehavior } from "@/lib/motion";
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<string>(items[0]?.id ?? "");
const navRef = useRef<HTMLElement>(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<HTMLElement>(`[data-toc-id="${active}"]`);
if (!el) return;
el.scrollIntoView({ block: "nearest", behavior: scrollBehavior() });
}, [active]);
const jump = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
};
return (
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col">
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
style={{ color: "var(--ink-3)" }}
>
目录
</p>
<ul
className="j13-rail-scroll space-y-0.5 pr-0.5"
style={{ borderLeft: "1px solid var(--line)" }}
>
{items.map((it) => {
const isActive = it.id === active;
return (
<li key={it.id}>
<a
data-toc-id={it.id}
href={`#${it.id}`}
onClick={jump(it.id)}
className={`j13-toc-link${it.level === 3 ? " is-h3" : ""}${isActive ? " is-active" : ""}`}
>
{it.text}
</a>
</li>
);
})}
</ul>
</nav>
);
}