@@ -72,3 +72,312 @@ class MDBookSidebarScrollbox extends HTMLElement {
7272 }
7373}
7474window.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