Files
jiang13-bbs/frontend/components/PostToc.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

123 lines
4.0 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";
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<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 变化时把当前目录项滚入列表可视区(仅在列表容器内最小滚动)。
// 不用 scrollIntoView:移动端目录下沉到文末(普通文档流、容器本身不可滚),
// scrollIntoView(nearest) 会连带滚动窗口,把正在阅读的页面拽到底部目录,
// 表现为"进页自动下滚 / 无法向上滑动"。
useEffect(() => {
if (skipFollow.current) {
skipFollow.current = false;
return;
}
const list = navRef.current?.querySelector<HTMLElement>(".j13-rail-scroll");
const el = list?.querySelector<HTMLElement>(`[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 (
<nav
ref={navRef}
aria-label="文章目录"
className={`${bare ? "py-4" : "panel p-4"} flex min-h-0 flex-col`}
style={
bare
? {
background: "var(--panel)",
borderTop: "1px solid var(--line)",
borderBottom: "1px solid var(--line)",
}
: undefined
}
>
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
style={{ color: "var(--ink-3)" }}
>
{title}
</p>
<ul
className="j13-rail-scroll min-h-0 flex-1 space-y-0.5 pr-0.5"
style={bare ? undefined : { 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>
);
}