Skip to content

Commit 06af133

Browse files
authored
Merge pull request #2822 from ehuss/dynamic-toc
Add sidebar heading navigation
2 parents ac16748 + 3274173 commit 06af133

24 files changed

+732
-6
lines changed

crates/mdbook-core/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,9 @@ pub struct HtmlConfig {
485485
///
486486
/// The default is `true`.
487487
pub hash_files: bool,
488+
/// If enabled, the sidebar includes navigation for headers on the current
489+
/// page. Default is `true`.
490+
pub sidebar_header_nav: bool,
488491
}
489492

490493
impl Default for HtmlConfig {
@@ -512,6 +515,7 @@ impl Default for HtmlConfig {
512515
live_reload_endpoint: None,
513516
redirect: HashMap::new(),
514517
hash_files: true,
518+
sidebar_header_nav: true,
515519
}
516520
}
517521
}

crates/mdbook-html/front-end/css/chrome.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,3 +730,21 @@ html:not(.sidebar-resizing) .sidebar {
730730
/* mdbook's margin for h2 is way too large. */
731731
margin: 10px;
732732
}
733+
734+
.current-header {
735+
/* Allows the circle positioning. */
736+
position: relative
737+
}
738+
739+
/* Places a circle just before the current header in the sidebar. */
740+
.current-header::before {
741+
content: '';
742+
position: absolute;
743+
left: -16px;
744+
top: 0;
745+
margin-top: 10px;
746+
width: 8px;
747+
height: 8px;
748+
background-color: var(--sidebar-active);
749+
border-radius: 50%;
750+
}

crates/mdbook-html/front-end/templates/toc.js.hbs

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,312 @@ class MDBookSidebarScrollbox extends HTMLElement {
7272
}
7373
}
7474
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}}

crates/mdbook-html/src/html_handlebars/hbs_renderer.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,10 @@ fn make_data(
615615
data.insert("print_enable".to_owned(), json!(html_config.print.enable));
616616
data.insert("fold_enable".to_owned(), json!(html_config.fold.enable));
617617
data.insert("fold_level".to_owned(), json!(html_config.fold.level));
618+
data.insert(
619+
"sidebar_header_nav".to_owned(),
620+
json!(html_config.sidebar_header_nav),
621+
);
618622

619623
let search = html_config.search.clone();
620624
if cfg!(feature = "search") {

guide/src/format/configuration/renderers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ edit-url-template = "https://github.com/rust-lang/mdBook/edit/master/guide/{path
108108
site-url = "/example-book/"
109109
cname = "myproject.rs"
110110
input-404 = "not-found.md"
111+
sidebar-header-nav = true
111112
```
112113

113114
The following configuration options are available:
@@ -166,6 +167,7 @@ The following configuration options are available:
166167
Chapter HTML files are not renamed.
167168
Static CSS and JS files can reference each other using `{{ resource "filename" }}` directives.
168169
Defaults to `true`.
170+
- **sidebar-header-nav:** If `true`, the sidebar will contain navigation for headers on the current page. Default is `true`.
169171

170172
[custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site
171173

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"dependencies": {
3-
"browser-ui-test": "0.21.1",
3+
"browser-ui-test": "0.21.2",
44
"eslint": "^9.34.0"
55
},
66
"scripts": {

0 commit comments

Comments
 (0)