Files
jiang13-forum/frontend/src/components/ArticleOutline.tsx
freefire 822eef96be 新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:58:22 +08:00

150 lines
4.4 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { ListTree } from 'lucide-react';
import type { PostHeading } from '../utils/postHeadings';
import { cn } from '@/lib/utils';
interface Props {
headings: PostHeading[];
/** 滚动容器;不传则用 viewport */
scrollRoot?: HTMLElement | null;
title?: string;
className?: string;
}
/** 根据滚动位置取当前应高亮的标题 id */
function resolveActiveHeadingId(
headings: PostHeading[],
root: HTMLElement | null,
offsetPx = 28,
): string {
if (headings.length === 0) return '';
const rootTop = root ? root.getBoundingClientRect().top : 0;
const marker = rootTop + offsetPx;
let current = headings[0].id;
for (const h of headings) {
const el = document.getElementById(h.id);
if (!el) continue;
if (el.getBoundingClientRect().top <= marker) {
current = h.id;
} else {
break;
}
}
return current;
}
/** 文章目录树:点击跳转,滚动时高亮当前标题 */
export default function ArticleOutline({
headings,
scrollRoot,
title = '文章目录',
className,
}: Props) {
const [activeId, setActiveId] = useState(headings[0]?.id ?? '');
/** 点击跳转期间锁定高亮,避免 Intersection/滚动回调来回抢 */
const lockUntilRef = useRef(0);
const lockIdRef = useRef('');
const rafRef = useRef(0);
const minLevel = useMemo(
() => (headings.length ? Math.min(...headings.map(h => h.level)) : 2),
[headings],
);
useEffect(() => {
setActiveId(headings[0]?.id ?? '');
lockUntilRef.current = 0;
lockIdRef.current = '';
}, [headings]);
useEffect(() => {
if (headings.length === 0) return undefined;
const root: HTMLElement | Window = scrollRoot ?? window;
const syncActive = () => {
if (Date.now() < lockUntilRef.current) {
if (lockIdRef.current) setActiveId(lockIdRef.current);
return;
}
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
if (next) setActiveId(next);
};
const onScroll = () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(syncActive);
};
syncActive();
root.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
return () => {
root.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [headings, scrollRoot]);
const jumpTo = (id: string) => {
const el = document.getElementById(id);
if (!el) return;
// 立即高亮并锁定一段时间,覆盖 smooth 滚动过程中的中间态
setActiveId(id);
lockIdRef.current = id;
lockUntilRef.current = Date.now() + 900;
const root = scrollRoot;
if (root) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// 滚动结束后再按位置校正一次(若用户中途手动滑会自然解锁)
window.setTimeout(() => {
if (lockIdRef.current !== id) return;
lockUntilRef.current = 0;
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
if (next) setActiveId(next);
}, 920);
};
return (
<div className={cn('article-outline', className)}>
<div className="sidebar-section article-outline-head">
<ListTree size={12} aria-hidden />
<span>{title}</span>
</div>
{headings.length === 0 ? (
<p className="article-outline-empty"></p>
) : (
<nav className="article-outline-nav" aria-label="文章目录">
{headings.map(h => (
<button
key={h.id}
type="button"
className={cn(
'article-outline-item',
`article-outline-item--l${Math.min(6, Math.max(1, h.level - minLevel + 1))}`,
activeId === h.id && 'active',
)}
onClick={() => jumpTo(h.id)}
title={h.text}
>
{h.text}
</button>
))}
</nav>
)}
</div>
);
}