1- import React , { useEffect , useState , useMemo , useCallback } from "react"
1+ import React , { useEffect , useState , useCallback , useRef } from "react"
22import { ChevronRight , List } from "lucide-react"
33import { ScrollArea } from "@/components/ui/scroll-area"
44import { Separator } from "@/components/ui/separator"
@@ -9,99 +9,46 @@ import {
99} from "@/components/ui/collapsible"
1010import { Button } from "@/components/ui/button"
1111
12- interface TableOfContentsItem {
13- id : string
14- title : string
15- level : number
16- children ?: TableOfContentsItem [ ]
17- }
18-
1912interface 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-
7317const 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
0 commit comments