Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

118 lines
3.4 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
type TOCItem = {
title: ReactNode;
url: string;
depth: number;
};
const MAX_LINE_WIDTH = 36; // h2 active width, used as fixed container width
function getLineWidth(depth: number, isActive: boolean): number {
const base = depth <= 2 ? 24 : depth === 3 ? 16 : 10;
return isActive ? base + 12 : base;
}
export function BlogTOC({ items }: { items: TOCItem[] }) {
const [activeId, setActiveId] = useState<string | null>(null);
const [isHovered, setIsHovered] = useState(false);
const listRef = useRef<HTMLElement>(null);
const isClickScrolling = useRef(false);
const clickTimer = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
if (items.length === 0) return;
const headingIds = items.map((item) => item.url.slice(1));
const observer = new IntersectionObserver(
(entries) => {
if (isClickScrolling.current) return;
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
break;
}
}
},
{
rootMargin: "-80px 0px -70% 0px",
threshold: 0,
},
);
for (const id of headingIds) {
const element = document.getElementById(id);
if (element) {
observer.observe(element);
}
}
return () => observer.disconnect();
}, [items]);
const handleClick = (id: string) => {
setActiveId(id);
isClickScrolling.current = true;
if (clickTimer.current) clearTimeout(clickTimer.current);
clickTimer.current = setTimeout(() => {
isClickScrolling.current = false;
}, 800);
};
if (items.length === 0) return null;
return (
<nav
ref={listRef}
className="fixed top-1/2 right-6 z-40 -translate-y-1/2 max-lg:hidden"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ul className="flex flex-col" style={{ width: MAX_LINE_WIDTH }}>
{items.map((item) => {
const id = item.url.slice(1);
const isActive = activeId === id;
const lineWidth = getLineWidth(item.depth, isActive);
return (
<li key={item.url}>
<a
href={item.url}
onClick={() => handleClick(id)}
className="group relative flex h-6 items-center justify-end"
>
<div
className={cn(
"h-px shrink-0 rounded-full transition-all duration-200",
isActive
? "bg-foreground"
: "bg-foreground/20 group-hover:bg-foreground/40",
)}
style={{ width: lineWidth }}
/>
<span
className={cn(
"absolute right-full mr-3 text-[12px] leading-none whitespace-nowrap transition-all duration-200",
isHovered
? "translate-x-0 opacity-100"
: "pointer-events-none -translate-x-2 opacity-0",
isActive
? "text-foreground font-medium"
: "text-muted-foreground group-hover:text-foreground",
)}
>
{item.title}
</span>
</a>
</li>
);
})}
</ul>
</nav>
);
}