@@ -72,3 +72,312 @@ class MDBookSidebarScrollbox extends HTMLElement {
72
72
}
73
73
}
74
74
window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox);
75
+
76
+ {{ #if sidebar_header_nav }}
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Support for dynamically adding headers to the sidebar.
80
+
81
+ // This is a debugging tool for the threshold which you can enable in the console.
82
+ // eslint-disable-next-line prefer-const
83
+ let mdbookThresholdDebug = false;
84
+
85
+ (function() {
86
+ // This is used to detect which direction the page has scrolled since the
87
+ // last scroll event.
88
+ let lastKnownScrollPosition = 0;
89
+ // This is the threshold in px from the top of the screen where it will
90
+ // consider a header the "current" header when scrolling down.
91
+ const defaultDownThreshold = 150;
92
+ // Same as defaultDownThreshold, except when scrolling up.
93
+ const defaultUpThreshold = 300;
94
+ // The threshold is a virtual horizontal line on the screen where it
95
+ // considers the "current" header to be above the line. The threshold is
96
+ // modified dynamically to handle headers that are near the bottom of the
97
+ // screen, and to slightly offset the behavior when scrolling up vs down.
98
+ let threshold = defaultDownThreshold;
99
+ // This is used to disable updates while scrolling. This is needed when
100
+ // clicking the header in the sidebar, which triggers a scroll event. It
101
+ // is somewhat finicky to detect when the scroll has finished, so this
102
+ // uses a relatively dumb system of disabling scroll updates for a short
103
+ // time after the click.
104
+ let disableScroll = false;
105
+ // Array of header elements on the page.
106
+ let headers;
107
+ // Array of li elements that are initially collapsed headers in the sidebar.
108
+ // I'm not sure why eslint seems to have a false positive here.
109
+ // eslint-disable-next-line prefer-const
110
+ let headerToggles = [];
111
+
112
+ function drawDebugLine() {
113
+ if (!document.body) {
114
+ return;
115
+ }
116
+ const id = 'mdbook-threshold-debug-line';
117
+ const existingLine = document.getElementById(id);
118
+ if (existingLine) {
119
+ existingLine.remove();
120
+ }
121
+ const line = document.createElement('div');
122
+ line.id = id;
123
+ line.style.cssText = `
124
+ position : fixed;
125
+ top : ${threshold}px;
126
+ left : 0;
127
+ width : 100vw;
128
+ height : 2px;
129
+ background-color : red;
130
+ z-index : 9999;
131
+ pointer-events : none;
132
+ ` ;
133
+ document.body.appendChild(line);
134
+ }
135
+
136
+ // Updates the threshold based on the scroll position.
137
+ function updateThreshold() {
138
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
139
+ const windowHeight = window.innerHeight;
140
+ const documentHeight = document.documentElement.scrollHeight;
141
+ // The number of pixels below the viewport, at most documentHeight.
142
+ // This is used to push the threshold down to the bottom of the page
143
+ // as the user scrolls towards the bottom.
144
+ const pixelsBelow = Math.max(0, documentHeight - (scrollTop + windowHeight));
145
+ // The number of pixels above the viewport, at most defaultDownThreshold.
146
+ // Similar to pixelsBelow, this is used to push the threshold back towards
147
+ // the top when reaching the top of the page.
148
+ const pixelsAbove = Math.max(0, defaultDownThreshold - scrollTop);
149
+ // How much the threshold should be offset once it gets close to the
150
+ // bottom of the page.
151
+ let bottomAdd = Math.max(0, windowHeight - pixelsBelow - defaultDownThreshold);
152
+
153
+ // Adjusts bottomAdd for a small document. The calculation above
154
+ // assumes the document is at least twice the windowheight in size. If
155
+ // it is less than that, then bottomAdd needs to be shrunk
156
+ // proportional to the difference in size.
157
+ if (documentHeight < windowHeight * 2) {
158
+ const maxPixelsBelow = documentHeight - windowHeight;
159
+ const t = 1 - pixelsBelow / maxPixelsBelow;
160
+ const clamp = Math.max(0, Math.min(1, t));
161
+ bottomAdd *= clamp;
162
+ }
163
+
164
+ let scrollingDown = true;
165
+ if (scrollTop < lastKnownScrollPosition) {
166
+ scrollingDown = false;
167
+ }
168
+
169
+ if (scrollingDown) {
170
+ // When scrolling down, move the threshold up towards the default
171
+ // downwards threshold position. If near the bottom of the page,
172
+ // bottomAdd will offset the threshold towards the bottom of the
173
+ // page.
174
+ const amountScrolledDown = scrollTop - lastKnownScrollPosition;
175
+ const adjustedDefault = defaultDownThreshold + bottomAdd;
176
+ threshold = Math.max(adjustedDefault, threshold - amountScrolledDown);
177
+ } else {
178
+ // When scrolling up, move the threshold down towards the default
179
+ // upwards threshold position. If near the bottom of the page,
180
+ // quickly transition the threshold back up where it normally
181
+ // belongs.
182
+ const amountScrolledUp = lastKnownScrollPosition - scrollTop;
183
+ const adjustedDefault = defaultUpThreshold - pixelsAbove
184
+ + Math.max(0, bottomAdd - defaultDownThreshold);
185
+ threshold = Math.min(adjustedDefault, threshold + amountScrolledUp);
186
+ }
187
+ lastKnownScrollPosition = scrollTop;
188
+ }
189
+
190
+ // Updates which headers in the sidebar should be expanded. If the current
191
+ // header is inside a collapsed group, then it, and all its parents should
192
+ // be expanded.
193
+ function updateHeaderExpanded(currentA) {
194
+ // Add expanded to all header-item li ancestors.
195
+ let current = currentA.parentElement.parentElement.parentElement;
196
+ while (current.tagName === 'LI') {
197
+ const prevSibling = current.previousElementSibling;
198
+ if (prevSibling !== null
199
+ && prevSibling.tagName === 'LI'
200
+ && prevSibling.classList.contains('header-item')) {
201
+ prevSibling.classList.add('expanded');
202
+ current = prevSibling.parentElement.parentElement;
203
+ } else {
204
+ break;
205
+ }
206
+ }
207
+ }
208
+
209
+ // Updates which header is marked as the "current" header in the sidebar.
210
+ // This is done with a virtual Y threshold, where headers at or below
211
+ // that line will be considered the current one.
212
+ function updateCurrentHeader() {
213
+ if (mdbookThresholdDebug) {
214
+ drawDebugLine();
215
+ }
216
+ if (!headers || !headers.length) {
217
+ return;
218
+ }
219
+
220
+ // Reset the classes, which will be rebuilt below.
221
+ const els = document.getElementsByClassName('current-header');
222
+ for (const el of els) {
223
+ el.classList.remove('current-header');
224
+ }
225
+ for (const toggle of headerToggles) {
226
+ toggle.classList.remove('expanded');
227
+ }
228
+
229
+ // Find the last header that is above the threshold.
230
+ let lastHeader = null;
231
+ for (const header of headers) {
232
+ const rect = header.getBoundingClientRect();
233
+ if (rect.top <= threshold) {
234
+ lastHeader = header;
235
+ } else {
236
+ break;
237
+ }
238
+ }
239
+ if (lastHeader === null) {
240
+ lastHeader = headers[0];
241
+ const rect = lastHeader.getBoundingClientRect();
242
+ const windowHeight = window.innerHeight;
243
+ if (rect.top >= windowHeight) {
244
+ return;
245
+ }
246
+ }
247
+
248
+ // Get the anchor in the summary.
249
+ const href = '#' + lastHeader.id;
250
+ const a = [...document.querySelectorAll('.header-in-summary')]
251
+ .find(element => element.getAttribute('href') === href);
252
+ if (!a) {
253
+ return;
254
+ }
255
+
256
+ a.classList.add('current-header');
257
+
258
+ updateHeaderExpanded(a);
259
+ }
260
+
261
+ // Updates which header is "current" based on the threshold line.
262
+ function reloadCurrentHeader() {
263
+ if (disableScroll) {
264
+ return;
265
+ }
266
+ updateThreshold();
267
+ updateCurrentHeader();
268
+ }
269
+
270
+
271
+ // When clicking on a header in the sidebar, this adjusts the threshold so
272
+ // that it is located next to the header. This is so that header becomes
273
+ // "current".
274
+ function headerThresholdClick(event) {
275
+ // See disableScroll description why this is done.
276
+ disableScroll = true;
277
+ setTimeout(() => {
278
+ disableScroll = false;
279
+ }, 100);
280
+ // requestAnimationFrame is used to delay the update of the "current"
281
+ // header until after the scroll is done, and the header is in the new
282
+ // position.
283
+ requestAnimationFrame(() => {
284
+ requestAnimationFrame(() => {
285
+ // Closest is needed because if it has child elements like <code>.
286
+ const a = event.target.closest('a');
287
+ const href = a.getAttribute('href');
288
+ const targetId = href.substring(1);
289
+ const targetElement = document.getElementById(targetId);
290
+ if (targetElement) {
291
+ threshold = targetElement.getBoundingClientRect().bottom;
292
+ updateCurrentHeader();
293
+ }
294
+ });
295
+ });
296
+ }
297
+
298
+ // Scans page for headers and adds them to the sidebar.
299
+ document.addEventListener('DOMContentLoaded', function() {
300
+ const activeSection = document.querySelector('#mdbook-sidebar .active');
301
+ if (activeSection === null) {
302
+ return;
303
+ }
304
+ const activeItem = activeSection.parentElement;
305
+ const activeList = activeItem.parentElement;
306
+
307
+ // Build a tree of headers in the sidebar.
308
+ const rootLi = document.createElement('li');
309
+ rootLi.classList.add('header-item');
310
+ rootLi.classList.add('expanded');
311
+ const rootOl = document.createElement('ol');
312
+ rootOl.classList.add('section');
313
+ rootLi.appendChild(rootOl);
314
+ const stack = [{ level: 0, ol: rootOl }];
315
+ // The level where it will start folding deeply nested headers.
316
+ const foldLevel = 3;
317
+
318
+ const main = document.getElementsByTagName('main')[0];
319
+ headers = Array.from(main.querySelectorAll('h2, h3, h4, h5, h6'))
320
+ .filter(h => h.id !== '' && h.children.length && h.children[0].tagName === 'A');
321
+
322
+ if (headers.length === 0) {
323
+ return;
324
+ }
325
+
326
+ for (let i = 0; i < headers.length; i++) {
327
+ const header = headers[i];
328
+ const level = parseInt(header.tagName.charAt(1));
329
+ const li = document.createElement('li');
330
+ li.classList.add('header-item');
331
+ li.classList.add('expanded');
332
+ if (level < foldLevel) {
333
+ li.classList.add('expanded');
334
+ }
335
+ const a = document.createElement('a');
336
+ a.href = '#' + header.id;
337
+ a.classList.add('header-in-summary');
338
+ a.innerHTML = header.children[0].innerHTML;
339
+ a.addEventListener('click', headerThresholdClick);
340
+ li.appendChild(a);
341
+ const nextHeader = headers[i + 1];
342
+ if (nextHeader !== undefined) {
343
+ const nextLevel = parseInt(nextHeader.tagName.charAt(1));
344
+ if (nextLevel > level && level >= foldLevel) {
345
+ const div = document.createElement('div');
346
+ div.textContent = '❱';
347
+ const toggle = document.createElement('a');
348
+ toggle.classList.add('toggle');
349
+ toggle.classList.add('header-toggle');
350
+ toggle.appendChild(div);
351
+ toggle.addEventListener('click', () => {
352
+ li.classList.toggle('expanded');
353
+ });
354
+ li.appendChild(toggle);
355
+ headerToggles.push(li);
356
+ }
357
+ }
358
+
359
+ // Find the appropriate parent level.
360
+ while (stack.length > 1 && stack[stack.length - 1].level >= level) {
361
+ stack.pop();
362
+ }
363
+
364
+ const currentParent = stack[stack.length - 1];
365
+ currentParent.ol.appendChild(li);
366
+
367
+ // Create new nested ol for potential children.
368
+ const nestedOl = document.createElement('ol');
369
+ nestedOl.classList.add('section');
370
+ const nestedLi = document.createElement('li');
371
+ nestedLi.appendChild(nestedOl);
372
+ currentParent.ol.appendChild(nestedLi);
373
+ stack.push({ level: level, ol: nestedOl });
374
+ }
375
+
376
+ activeList.insertBefore(rootLi, activeItem.nextSibling);
377
+ });
378
+
379
+ document.addEventListener('DOMContentLoaded', reloadCurrentHeader);
380
+ document.addEventListener('scroll', reloadCurrentHeader, { passive: true });
381
+ })();
382
+
383
+ {{/if}}
0 commit comments