@@ -71,6 +71,36 @@ function buildTocTree(toc: TocItem[]): TocItem[] {
71
71
return items ;
72
72
}
73
73
74
+ function getTocItems ( main : HTMLElement ) {
75
+ return Array . from ( main . querySelectorAll ( 'h2, h3' ) )
76
+ . map ( el => {
77
+ const title = el . textContent ?. trim ( ) ;
78
+ if ( ! el . id || ! title ) {
79
+ return null ;
80
+ }
81
+ // This is a relatively new API, that checks if the element is visible in the document
82
+ // With this, we filter out e.g. sections hidden via CSS
83
+ if ( typeof el . checkVisibility === 'function' && ! el . checkVisibility ( ) ) {
84
+ return null ;
85
+ }
86
+ return {
87
+ depth : el . tagName === 'H2' ? 2 : 3 ,
88
+ url : `#${ el . id } ` ,
89
+ title,
90
+ element : el ,
91
+ isActive : false ,
92
+ } ;
93
+ } )
94
+ . filter ( isNotNil ) ;
95
+ }
96
+
97
+ function getMainElement ( ) {
98
+ if ( typeof document === 'undefined' ) {
99
+ return null ;
100
+ }
101
+ return document . getElementById ( 'main' ) ;
102
+ }
103
+
74
104
// The full, rendered page is required in order to generate the table of
75
105
// contents since headings can come from child components, included MDX files,
76
106
// etc. Even though this should hypothetically be doable on the server, methods
@@ -83,33 +113,51 @@ export function SidebarTableOfContents() {
83
113
84
114
// gather the toc items on mount
85
115
useEffect ( ( ) => {
86
- if ( typeof document === 'undefined' ) {
116
+ const main = getMainElement ( ) ;
117
+ if ( ! main ) {
87
118
return ;
88
119
}
89
- const main = document . getElementById ( 'main' ) ;
120
+
121
+ setTocItems ( getTocItems ( main ) ) ;
122
+ } , [ ] ) ;
123
+
124
+ // ensure toc items are kept up-to-date if the DOM changes
125
+ useEffect ( ( ) => {
126
+ const main = getMainElement ( ) ;
90
127
if ( ! main ) {
91
- throw new Error ( '#main element not found' ) ;
128
+ return ( ) => { } ;
92
129
}
93
- const tocItems_ = Array . from ( main . querySelectorAll ( 'h2, h3' ) )
94
- . map ( el => {
95
- const title = el . textContent ?. trim ( ) ?? '' ;
96
- if ( ! el . id ) {
97
- return null ;
98
- }
99
- return {
100
- depth : el . tagName === 'H2' ? 2 : 3 ,
101
- url : `#${ el . id } ` ,
102
- title,
103
- element : el ,
104
- isActive : false ,
105
- } ;
106
- } )
107
- . filter ( isNotNil ) ;
108
- setTocItems ( tocItems_ ) ;
109
- } , [ ] ) ;
110
130
131
+ const observer = new MutationObserver ( ( ) => {
132
+ const newTocItems = getTocItems ( main ) ;
133
+
134
+ // Avoid flashing sidebar elements if nothing changes
135
+ if (
136
+ newTocItems . length === tocItems . length &&
137
+ newTocItems . every ( ( item , index ) => item . url === tocItems [ index ] . url )
138
+ ) {
139
+ return ;
140
+ }
141
+ setTocItems ( newTocItems ) ;
142
+ } ) ;
143
+
144
+ // Start observing the target node for any changes in its subtree
145
+ // We only care about:
146
+ // * Children being added/removed (childList)
147
+ // Any id, class, or style attribute being changed (this approximates CSS changes)
148
+ observer . observe ( main , {
149
+ childList : true ,
150
+ subtree : true ,
151
+ attributes : true ,
152
+ attributeFilter : [ 'class' , 'id' , 'style' ] ,
153
+ } ) ;
154
+
155
+ return ( ) => observer . disconnect ( ) ;
156
+ } , [ tocItems ] ) ;
157
+
158
+ // Mark the active item based on the scroll position
111
159
useEffect ( ( ) => {
112
- if ( tocItems . length === 0 ) {
160
+ if ( ! tocItems . length ) {
113
161
return ( ) => { } ;
114
162
}
115
163
// account for the header height
0 commit comments