All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m4s
123 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|