1
- import { component$ , useSignal } from "@qwik.dev/core" ;
2
- import { useContent } from "@qwik.dev/router" ;
1
+ import { cn } from "~/utils/cn" ;
2
+ import { component$ , useSignal , $ , useOnWindow } from '@qwik.dev/core' ;
3
+ import { ContentHeading } from '@qwik.dev/router' ;
3
4
4
- export const Toc = component$ ( ( ) => {
5
- const { headings } = useContent ( ) ;
6
- const lastScrollIdSig = useSignal ( "" ) ;
7
- return (
8
- < div class = "fixed flex w-full flex-col px-6 pt-28 text-xl text-black dark:text-white" >
9
- < span class = "mb-2 text-lg font-bold uppercase" > On this page</ span >
10
- { ( headings || [ ] ) . map ( ( { text, id } , idx ) => (
11
- < ul
12
- key = { idx }
13
- class = "border-l-[4px] py-1 pl-4 hover:border-blue-700"
14
- onMouseOver$ = { ( ) => {
15
- if ( lastScrollIdSig . value !== id ) {
16
- const el = document . querySelector ( `#${ id } ` ) ;
17
- if ( el ) {
18
- el . scrollIntoView ( { behavior : "smooth" } ) ;
19
- lastScrollIdSig . value = id ;
20
- }
21
- }
22
- } }
23
- >
24
- < li class = "text-base hover:text-blue-500" >
25
- < a href = { `#${ id } ` } > { text } </ a >
5
+ export const TOC = component$ (
6
+ ( { headings } : { headings : ContentHeading [ ] } ) => {
7
+ if ( headings . length === 0 ) {
8
+ return null ;
9
+ }
10
+ return (
11
+ < div class = "space-y-2 sticky top-24 max-h-[calc(80vh)] p-1 dark:text-white text-black hidden xl:block" >
12
+ < div class = "font-medium" > On This Page</ div >
13
+ < TableOfContents headings = { headings } />
14
+ </ div >
15
+ ) ;
16
+ } ,
17
+ ) ;
18
+
19
+ type TableOfContentsProps = { headings : ContentHeading [ ] } ;
20
+
21
+ interface Node extends ContentHeading {
22
+ children : Node [ ] ;
23
+ activeItem : string ;
24
+ }
25
+ type Tree = Array < Node > ;
26
+
27
+ const TableOfContents = component$ < TableOfContentsProps > ( ( { headings } ) => {
28
+ const sanitizedHeadings = headings . map ( ( { text, id, level } ) => ( { text, id, level } ) ) ;
29
+ const itemIds = headings . map ( ( { id } ) => id ) ;
30
+ const activeHeading = useActiveItem ( itemIds ) ;
31
+ const tree = buildTree ( sanitizedHeadings ) ;
32
+ const fixStartingBug : Node = { ...tree , children : [ tree ] } ;
33
+ return < RecursiveList tree = { fixStartingBug } activeItem = { activeHeading . value ?? '' } /> ;
34
+ } ) ;
35
+
36
+ function deltaToStrg (
37
+ currNode : Node ,
38
+ nextNode : Node ,
39
+ ) : 'same level' | 'down one level' | 'up one level' | 'upwards discontinuous' {
40
+ const delta = currNode . level - nextNode . level ;
41
+ if ( delta > 1 ) {
42
+ return 'upwards discontinuous' ;
43
+ }
44
+ if ( delta === 1 ) {
45
+ return 'up one level' ;
46
+ }
47
+ if ( delta === 0 ) {
48
+ return 'same level' ;
49
+ }
50
+ if ( delta === - 1 ) {
51
+ return 'down one level' ;
52
+ }
53
+
54
+ throw new Error (
55
+ `bad headings: are downwards discontinous from: #${ currNode . id } to #${ nextNode . id } bc from ${ currNode . level } to ${ nextNode . level } ` ,
56
+ ) ;
57
+ }
58
+
59
+ function buildTree ( nodes : ContentHeading [ ] ) {
60
+ let currNode = nodes [ 0 ] as Node ;
61
+ currNode . children = [ ] ;
62
+ const tree = [ currNode ] ;
63
+ const childrenMap = new Map < number , Tree > ( ) ;
64
+ childrenMap . set ( currNode . level , currNode . children ) ;
65
+ for ( let index = 1 ; index < nodes . length ; index ++ ) {
66
+ const nextNode = nodes [ index ] as Node ;
67
+ nextNode . children = [ ] ;
68
+ childrenMap . set ( nextNode . level , nextNode . children ) ;
69
+ const deltaStrg = deltaToStrg ( currNode , nextNode ) ;
70
+ switch ( deltaStrg ) {
71
+ case 'upwards discontinuous' : {
72
+ const delta = currNode . level - nextNode . level ;
73
+ if ( childrenMap . has ( delta - 1 ) ) {
74
+ const nthParent = childrenMap . get ( delta - 1 ) ;
75
+ nthParent ?. push ( nextNode ) ;
76
+ }
77
+ break ;
78
+ }
79
+ case 'up one level' : {
80
+ const grandParent = childrenMap . get ( currNode . level - 2 ) ;
81
+ grandParent ?. push ( nextNode ) ;
82
+ break ;
83
+ }
84
+ case 'same level' : {
85
+ const parent = childrenMap . get ( currNode . level - 1 ) ;
86
+ parent ?. push ( nextNode ) ;
87
+ break ;
88
+ }
89
+ case 'down one level' : {
90
+ currNode . children . push ( nextNode ) ;
91
+ break ;
92
+ }
93
+ default :
94
+ break ;
95
+ }
96
+ currNode = nextNode ;
97
+ }
98
+ return tree [ 0 ] ;
99
+ }
100
+
101
+ type RecursiveListProps = {
102
+ tree : Node ;
103
+ activeItem : string ;
104
+ limit ?: number ;
105
+ } ;
106
+
107
+ const RecursiveList = component$ < RecursiveListProps > (
108
+ ( { tree, activeItem, limit = 3 } ) => {
109
+ return tree ?. children ?. length && tree . level < limit ? (
110
+ < ul class = { cn ( 'm-0 list-none' , { 'pl-4' : tree . level !== 1 } ) } >
111
+ { tree . children . map ( ( childNode ) => (
112
+ < li key = { childNode . id } class = "mt-0 list-none pt-2" >
113
+ < Anchor node = { childNode } activeItem = { activeItem } />
114
+ { childNode . children . length > 0 && (
115
+ < RecursiveList tree = { childNode } activeItem = { activeItem } />
116
+ ) }
26
117
</ li >
27
- </ ul >
28
- ) ) }
29
- </ div >
118
+ ) ) }
119
+ </ ul >
120
+ ) : null ;
121
+ } ,
122
+ ) ;
123
+
124
+ const useActiveItem = ( itemIds : string [ ] ) => {
125
+ const activeId = useSignal < string > ( ) ;
126
+
127
+ useOnWindow (
128
+ 'scroll' ,
129
+ $ ( ( ) => {
130
+ const observer = new IntersectionObserver (
131
+ ( entries ) => {
132
+ entries . forEach ( ( entry ) => {
133
+ if ( entry . isIntersecting ) {
134
+ activeId . value = entry . target . id ;
135
+ }
136
+ } ) ;
137
+ } ,
138
+ { rootMargin : '0% 0% -85% 0%' } ,
139
+ ) ;
140
+
141
+ itemIds . forEach ( ( id ) => {
142
+ const element = document . getElementById ( id ) ;
143
+ if ( element ) {
144
+ observer . observe ( element ) ;
145
+ }
146
+ } ) ;
147
+
148
+ return ( ) => {
149
+ itemIds . forEach ( ( id ) => {
150
+ const element = document . getElementById ( id ) ;
151
+ if ( element ) {
152
+ observer . unobserve ( element ) ;
153
+ }
154
+ } ) ;
155
+ } ;
156
+ } ) ,
30
157
) ;
31
- } ) ;
158
+
159
+ return activeId ;
160
+ } ;
161
+
162
+ type AnchorProps = {
163
+ node : Node ;
164
+ activeItem : string ;
165
+ } ;
166
+
167
+ const Anchor = component$ < AnchorProps > ( ( { node, activeItem } ) => {
168
+ const isActive = node . id === activeItem ;
169
+ return (
170
+ < a
171
+ href = { `#${ node . id } ` }
172
+ onClick$ = { [
173
+ $ ( ( ) => {
174
+ const element = document . getElementById ( node . id ) ;
175
+ if ( element ) {
176
+ const navbarHeight = 90 ;
177
+ const position =
178
+ element . getBoundingClientRect ( ) . top + window . scrollY - navbarHeight ;
179
+ window . scrollTo ( { top : position , behavior : 'auto' } ) ;
180
+ }
181
+ } ) ,
182
+ ] }
183
+ class = { cn (
184
+ node . level > 2 && 'ml-2' ,
185
+ 'inline-block no-underline transition-colors' ,
186
+ isActive ? 'text-blue-500 dark:text-blue-300' : 'text-muted-foreground' ,
187
+ ) }
188
+ >
189
+ { node . text }
190
+ </ a >
191
+ ) ;
192
+ } ) ;
0 commit comments