Skip to content

Commit 62eb48b

Browse files
committed
refactor : toc refactor
1 parent 557d011 commit 62eb48b

File tree

3 files changed

+95
-114
lines changed

3 files changed

+95
-114
lines changed

gatsby-config.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,6 @@ module.exports = {
8686
{
8787
resolve: `gatsby-transformer-remark`,
8888
options: {
89-
tableOfContents: {
90-
absolute: false,
91-
pathToSlugField: "fields.slug",
92-
maxDepth: 6,
93-
heading: null,
94-
},
9589
plugins: [
9690
{
9791
resolve: `gatsby-remark-images`,

src/components/table-of-contents.tsx

Lines changed: 56 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState, useMemo, useCallback } from "react"
1+
import React, { useEffect, useState, useCallback, useRef } from "react"
22
import { ChevronRight, List } from "lucide-react"
33
import { ScrollArea } from "@/components/ui/scroll-area"
44
import { Separator } from "@/components/ui/separator"
@@ -9,99 +9,46 @@ import {
99
} from "@/components/ui/collapsible"
1010
import { Button } from "@/components/ui/button"
1111

12-
interface TableOfContentsItem {
13-
id: string
14-
title: string
15-
level: number
16-
children?: TableOfContentsItem[]
17-
}
18-
1912
interface TableOfContentsProps {
2013
tableOfContents: string
2114
className?: string
2215
}
2316

24-
// HTML 문자열을 파싱해서 구조화된 데이터로 변환
25-
const parseTableOfContents = (html: string): TableOfContentsItem[] => {
26-
if (!html || html.trim() === "") return []
27-
28-
const parser = new DOMParser()
29-
const doc = parser.parseFromString(html, "text/html")
30-
const links = doc.querySelectorAll("a[href^='#']")
31-
32-
const items: TableOfContentsItem[] = []
33-
34-
links.forEach(link => {
35-
const href = link.getAttribute("href")
36-
const title = link.textContent?.trim()
37-
38-
if (href && title) {
39-
// href에서 #을 제거하고 URL 디코딩 적용
40-
let id = href.replace("#", "")
41-
try {
42-
// URL 인코딩된 한글을 디코딩
43-
id = decodeURIComponent(id)
44-
} catch (error) {
45-
// 디코딩 실패 시 원본 사용
46-
}
47-
48-
// UL의 중첩 깊이로 레벨 계산
49-
let element = link.parentElement
50-
let level = 1
51-
52-
while (element) {
53-
if (element.tagName === "UL") {
54-
const parentLi = element.parentElement?.closest("li")
55-
if (parentLi) {
56-
level++
57-
}
58-
}
59-
element = element.parentElement
60-
}
61-
62-
items.push({
63-
id,
64-
title,
65-
level,
66-
})
67-
}
68-
})
69-
70-
return items
71-
}
72-
7317
const TableOfContents: React.FC<TableOfContentsProps> = ({
7418
tableOfContents,
7519
className = "",
7620
}) => {
7721
const [activeId, setActiveId] = useState<string>("")
7822
const [isOpen, setIsOpen] = useState(false)
7923
const [isMobile, setIsMobile] = useState(false)
24+
const tocRef = useRef<HTMLDivElement>(null)
25+
26+
// 목차 링크 클릭 이벤트 처리
27+
const handleTocClick = useCallback(
28+
(e: React.MouseEvent<HTMLDivElement>) => {
29+
const target = e.target as HTMLElement
30+
const link = target.closest('a[href^="#"]') as HTMLAnchorElement
31+
32+
if (link) {
33+
e.preventDefault()
34+
const href = link.getAttribute("href")
35+
if (href) {
36+
const id = decodeURIComponent(href.replace("#", ""))
37+
const element = document.getElementById(id)
38+
39+
if (element) {
40+
const offsetTop = element.offsetTop - 100
41+
window.scrollTo({
42+
top: offsetTop,
43+
behavior: "smooth",
44+
})
45+
}
8046

81-
const tocItems = useMemo(
82-
() => parseTableOfContents(tableOfContents),
83-
[tableOfContents]
84-
)
85-
86-
// 부드러운 스크롤 이동 (useCallback으로 메모이제이션)
87-
const handleClick = useCallback(
88-
(e: React.MouseEvent<HTMLAnchorElement> | Event, id: string) => {
89-
e.preventDefault()
90-
91-
const element = document.getElementById(id)
92-
93-
if (element) {
94-
const offsetTop = element.offsetTop - 100
95-
96-
window.scrollTo({
97-
top: offsetTop,
98-
behavior: "smooth",
99-
})
100-
}
101-
102-
// 모바일에서는 클릭 후 접기
103-
if (isMobile) {
104-
setIsOpen(false)
47+
// 모바일에서는 클릭 후 접기
48+
if (isMobile) {
49+
setIsOpen(false)
50+
}
51+
}
10552
}
10653
},
10754
[isMobile]
@@ -124,7 +71,7 @@ const TableOfContents: React.FC<TableOfContentsProps> = ({
12471

12572
// IntersectionObserver를 이용한 현재 섹션 하이라이트
12673
useEffect(() => {
127-
if (!tocItems.length) {
74+
if (!tableOfContents || !tableOfContents.trim()) {
12875
return
12976
}
13077

@@ -176,38 +123,39 @@ const TableOfContents: React.FC<TableOfContentsProps> = ({
176123
observer.disconnect()
177124
}
178125
}
179-
}, [tocItems])
126+
}, [tableOfContents])
127+
128+
// 활성 링크 스타일 업데이트
129+
useEffect(() => {
130+
if (!tocRef.current) return
131+
132+
const links = tocRef.current.querySelectorAll('a[href^="#"]')
133+
134+
links.forEach(link => {
135+
const href = link.getAttribute("href")
136+
if (href) {
137+
const id = decodeURIComponent(href.replace("#", ""))
138+
if (id === activeId) {
139+
link.classList.add("toc-active")
140+
} else {
141+
link.classList.remove("toc-active")
142+
}
143+
}
144+
})
145+
}, [activeId])
180146

181-
if (!tocItems.length) {
147+
if (!tableOfContents || !tableOfContents.trim()) {
182148
return null
183149
}
184150

185151
const TocContent = () => (
186152
<ScrollArea className="h-full max-h-[60vh] pr-3">
187-
<div className="space-y-1">
188-
{tocItems.map(item => (
189-
<div key={item.id}>
190-
<a
191-
href={`#${item.id}`}
192-
onClick={e => handleClick(e, item.id)}
193-
className={`
194-
block py-2 px-3 text-sm rounded-lg transition-all duration-200
195-
${item.level === 1 ? "font-medium" : ""}
196-
${item.level === 2 ? "ml-3 text-muted-foreground" : ""}
197-
${item.level === 3 ? "ml-6 text-muted-foreground text-xs" : ""}
198-
${item.level >= 4 ? "ml-9 text-muted-foreground text-xs" : ""}
199-
${
200-
activeId === item.id
201-
? "bg-primary text-primary-foreground font-semibold shadow-sm"
202-
: "hover:bg-accent hover:text-accent-foreground text-muted-foreground"
203-
}
204-
`}
205-
>
206-
{item.title}
207-
</a>
208-
</div>
209-
))}
210-
</div>
153+
<div
154+
ref={tocRef}
155+
className="toc-content"
156+
dangerouslySetInnerHTML={{ __html: tableOfContents }}
157+
onClick={handleTocClick}
158+
/>
211159
</ScrollArea>
212160
)
213161

src/style.css

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,42 @@
315315
.custom-class:hover {
316316
color: hsl(var(--primary));
317317
}
318+
319+
/* 목차 스타일 - 이전과 동일한 Tailwind 스타일 */
320+
.toc-content ul {
321+
@apply list-none m-0 p-0 space-y-1;
322+
}
323+
324+
.toc-content li {
325+
@apply m-0 p-0;
326+
}
327+
328+
.toc-content a {
329+
@apply block py-2 px-3 text-sm rounded-lg transition-all duration-200
330+
text-muted-foreground hover:bg-accent hover:text-accent-foreground;
331+
}
332+
333+
/* 레벨 1: 기본 스타일 (font-medium) */
334+
.toc-content > ul > li > a {
335+
@apply font-medium;
336+
}
337+
338+
/* 레벨 2: ml-3 + text-muted-foreground */
339+
.toc-content ul ul a {
340+
@apply ml-3 text-muted-foreground;
341+
}
342+
343+
/* 레벨 3: ml-6 + text-muted-foreground + text-xs */
344+
.toc-content ul ul ul a {
345+
@apply ml-6 text-muted-foreground text-xs;
346+
}
347+
348+
/* 레벨 4+: ml-9 + text-muted-foreground + text-xs */
349+
.toc-content ul ul ul ul a {
350+
@apply ml-9 text-muted-foreground text-xs;
351+
}
352+
353+
/* 활성 상태: bg-primary + text-primary-foreground + font-semibold + shadow-sm */
354+
.toc-content a.toc-active {
355+
@apply bg-primary text-primary-foreground font-semibold shadow-sm;
356+
}

0 commit comments

Comments
 (0)