Files
jiang13-bbs/frontend/components/PostToc.tsx
freefire 0b60c4299b feat: 公文三栏单页布局,并修复时间线目录跳转与样式
公告/单页对齐帖子详情三栏壳;时间线按日锚点可跳转,条目改为日志行并用暖金日期区分 feat 色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 21:15:15 +08:00

90 lines
2.9 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();
const target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
setActive(id);
};
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>
);
}