87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|