Files
blog-frontend/src/components/post/TOC.tsx
wypark 701bbdea4a
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m4s
.
2026-05-28 22:49:38 +09:00

123 lines
3.3 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { clsx } from 'clsx';
import GithubSlugger from 'github-slugger';
interface TOCProps {
content: string;
}
interface HeadingItem {
text: string;
level: number;
slug: string;
}
export default function TOC({ content }: TOCProps) {
const [activeId, setActiveId] = useState<string>('');
const headings = useMemo(() => {
const slugger = new GithubSlugger();
const lines = content.split('\n');
const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
if (line.trim().startsWith('```')) {
return {
...acc,
inCodeBlock: !acc.inCodeBlock,
};
}
if (acc.inCodeBlock) return acc;
const match = line.match(/^(#{1,3})\s+(.+)$/);
if (match) {
const level = match[1].length;
const text = match[2].replace(/(\*\*|__)/g, '').trim();
const slug = slugger.slug(text);
return {
...acc,
headings: [...acc.headings, { text, level, slug }],
};
}
return acc;
}, { headings: [], inCodeBlock: false });
return result.headings;
}, [content]);
useEffect(() => {
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: '-10% 0px -80% 0px' }
);
headings.forEach((heading) => {
const element = document.getElementById(heading.slug);
if (element) observer.observe(element);
});
return () => observer.disconnect();
}, [headings]);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault();
const element = document.getElementById(slug);
if (element) {
const headerOffset = 100;
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
history.pushState(null, '', `#${slug}`);
setActiveId(slug);
}
};
if (headings.length === 0) return null;
return (
<aside className="w-full">
<div className="border-l border-[var(--color-line)] pl-4">
<h4 className="mb-4 text-xs font-semibold uppercase text-[var(--color-text-subtle)]"></h4>
<ul className="space-y-2.5">
{headings.map((heading, index) => (
<li
key={`${heading.slug}-${index}`}
className={clsx(
"text-sm transition-colors duration-150",
heading.level === 3 ? "ml-3 text-xs" : "",
activeId === heading.slug
? "font-semibold text-[var(--color-accent)]"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
)}
>
<a
href={`#${heading.slug}`}
onClick={(e) => handleClick(e, heading.slug)}
className="block truncate leading-snug"
>
{heading.text}
</a>
</li>
))}
</ul>
</div>
</aside>
);
}