Skip to content

Commit 1b55d4a

Browse files
committed
Add sidebar heading navigation
This adds dynamic navigation of headers of the current page in the sidebar. This is intended to help the user see what is on the current page, and to be able to more easily navigate it. The "current" header is tracked based on the scrolling behavior of the user, and is marked with a small circle. This includes automatic folding to help keep it from being too unwieldy on a page with a lot of nested headers. This includes the `output.html.sidebar-header-nav` option to disable it. I'm sure there are tweaks, fixes, and improvements that can be made. I'd like to get this out now, and iterate on it over time to make improvements.
1 parent ac16748 commit 1b55d4a

23 files changed

+731
-5
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)