Merge pull request #2893 from ehuss/fix-fold-sub-chapter

Fix heading nav with folded chapters
This commit is contained in:
Eric Huss
2025-10-21 00:37:48 +00:00
committed by GitHub
20 changed files with 212 additions and 127 deletions

View File

@@ -571,17 +571,18 @@ html:not(.sidebar-resizing) .sidebar {
line-height: 2.2em;
}
.chapter ol {
width: 100%;
}
.chapter li {
display: flex;
color: var(--sidebar-non-existant);
}
/* This is a span wrapping the chapter link and the fold chevron. */
.chapter-link-wrapper {
/* Used to position the chevron to the right, allowing the text to wrap before it. */
display: flex;
}
.chapter li a {
display: block;
padding: 0;
/* Remove underlines. */
text-decoration: none;
color: var(--sidebar-fg);
}
@@ -594,21 +595,22 @@ html:not(.sidebar-resizing) .sidebar {
color: var(--sidebar-active);
}
.chapter li > a.toggle {
/* This is the toggle chevron. */
.chapter-fold-toggle {
cursor: pointer;
display: block;
/* Positions the chevron to the side. */
margin-inline-start: auto;
padding: 0 10px;
user-select: none;
opacity: 0.68;
}
.chapter li > a.toggle div {
.chapter-fold-toggle div {
transition: transform 0.5s;
}
/* collapse the section */
.chapter li:not(.expanded) + li > ol {
.chapter li:not(.expanded) > ol {
display: none;
}
@@ -617,10 +619,12 @@ html:not(.sidebar-resizing) .sidebar {
margin-block-start: 0.6em;
}
.chapter li.expanded > a.toggle div {
/* When expanded, rotate the chevron to point down. */
.chapter li.expanded > span > .chapter-fold-toggle div {
transform: rotate(90deg);
}
/* Horizontal line in chapter list. */
.spacer {
width: 100%;
height: 3px;
@@ -630,6 +634,7 @@ html:not(.sidebar-resizing) .sidebar {
background-color: var(--sidebar-spacer);
}
/* On touch devices, add more vertical spacing to make it easier to tap links. */
@media (-moz-touch-enabled: 1), (pointer: coarse) {
.chapter li a { padding: 5px 0; }
.spacer { margin: 10px 0; }
@@ -741,7 +746,6 @@ html:not(.sidebar-resizing) .sidebar {
content: '';
position: absolute;
left: -16px;
top: 0;
margin-top: 10px;
width: 8px;
height: 8px;

View File

@@ -29,14 +29,9 @@ class MDBookSidebarScrollbox extends HTMLElement {
&& current_page.endsWith('/index.html')) {
link.classList.add('active');
let parent = link.parentElement;
if (parent && parent.classList.contains('chapter-item')) {
parent.classList.add('expanded');
}
while (parent) {
if (parent.tagName === 'LI' && parent.previousElementSibling) {
if (parent.previousElementSibling.classList.contains('chapter-item')) {
parent.previousElementSibling.classList.add('expanded');
}
if (parent.tagName === 'LI' && parent.classList.contains('chapter-item')) {
parent.classList.add('expanded');
}
parent = parent.parentElement;
}
@@ -62,9 +57,9 @@ class MDBookSidebarScrollbox extends HTMLElement {
}
}
// Toggle buttons
const sidebarAnchorToggles = document.querySelectorAll('#mdbook-sidebar a.toggle');
const sidebarAnchorToggles = document.querySelectorAll('.chapter-fold-toggle');
function toggleSection(ev) {
ev.currentTarget.parentElement.classList.toggle('expanded');
ev.currentTarget.parentElement.parentElement.classList.toggle('expanded');
}
Array.from(sidebarAnchorToggles).forEach(el => {
el.addEventListener('click', toggleSection);
@@ -237,17 +232,12 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox)
// be expanded.
function updateHeaderExpanded(currentA) {
// Add expanded to all header-item li ancestors.
let current = currentA.parentElement.parentElement.parentElement;
while (current.tagName === 'LI') {
const prevSibling = current.previousElementSibling;
if (prevSibling !== null
&& prevSibling.tagName === 'LI'
&& prevSibling.classList.contains('header-item')) {
prevSibling.classList.add('expanded');
current = prevSibling.parentElement.parentElement;
} else {
break;
let current = currentA.parentElement;
while (current) {
if (current.tagName === 'LI' && current.classList.contains('header-item')) {
current.classList.add('expanded');
}
current = current.parentElement;
}
}
@@ -343,19 +333,6 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox)
if (activeSection === null) {
return;
}
const activeItem = activeSection.parentElement;
const activeList = activeItem.parentElement;
// Build a tree of headers in the sidebar.
const rootLi = document.createElement('li');
rootLi.classList.add('header-item');
rootLi.classList.add('expanded');
const rootOl = document.createElement('ol');
rootOl.classList.add('section');
rootLi.appendChild(rootOl);
const stack = [{ level: 0, ol: rootOl }];
// The level where it will start folding deeply nested headers.
const foldLevel = 3;
const main = document.getElementsByTagName('main')[0];
headers = Array.from(main.querySelectorAll('h2, h3, h4, h5, h6'))
@@ -365,57 +342,90 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox)
return;
}
// Build a tree of headers in the sidebar.
const stack = [];
const firstLevel = parseInt(headers[0].tagName.charAt(1));
for (let i = 1; i < firstLevel; i++) {
const ol = document.createElement('ol');
ol.classList.add('section');
if (stack.length > 0) {
stack[stack.length - 1].ol.appendChild(ol);
}
stack.push({level: i + 1, ol: ol});
}
// The level where it will start folding deeply nested headers.
const foldLevel = 3;
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
const level = parseInt(header.tagName.charAt(1));
const currentLevel = stack[stack.length - 1].level;
if (level > currentLevel) {
// Begin nesting to this level.
for (let nextLevel = currentLevel + 1; nextLevel <= level; nextLevel++) {
const ol = document.createElement('ol');
ol.classList.add('section');
const last = stack[stack.length - 1];
const lastChild = last.ol.lastChild;
// Handle the case where jumping more than one nesting
// level, which doesn't have a list item to place this new
// list inside of.
if (lastChild) {
lastChild.appendChild(ol);
} else {
last.ol.appendChild(ol);
}
stack.push({level: nextLevel, ol: ol});
}
} else if (level < currentLevel) {
while (stack.length > 1 && stack[stack.length - 1].level >= level) {
stack.pop();
}
}
const li = document.createElement('li');
li.classList.add('header-item');
li.classList.add('expanded');
if (level < foldLevel) {
li.classList.add('expanded');
}
const span = document.createElement('span');
span.classList.add('chapter-link-wrapper');
const a = document.createElement('a');
span.appendChild(a);
a.href = '#' + header.id;
a.classList.add('header-in-summary');
a.innerHTML = header.children[0].innerHTML;
a.addEventListener('click', headerThresholdClick);
li.appendChild(a);
const nextHeader = headers[i + 1];
if (nextHeader !== undefined) {
const nextLevel = parseInt(nextHeader.tagName.charAt(1));
if (nextLevel > level && level >= foldLevel) {
const div = document.createElement('div');
div.textContent = '❱';
const toggle = document.createElement('a');
toggle.classList.add('toggle');
toggle.classList.add('chapter-fold-toggle');
toggle.classList.add('header-toggle');
toggle.appendChild(div);
toggle.addEventListener('click', () => {
li.classList.toggle('expanded');
});
li.appendChild(toggle);
const toggleDiv = document.createElement('div');
toggleDiv.textContent = '❱';
toggle.appendChild(toggleDiv);
span.appendChild(toggle);
headerToggles.push(li);
}
}
// Find the appropriate parent level.
while (stack.length > 1 && stack[stack.length - 1].level >= level) {
stack.pop();
}
li.appendChild(span);
const currentParent = stack[stack.length - 1];
currentParent.ol.appendChild(li);
// Create new nested ol for potential children.
const nestedOl = document.createElement('ol');
nestedOl.classList.add('section');
const nestedLi = document.createElement('li');
nestedLi.appendChild(nestedOl);
currentParent.ol.appendChild(nestedLi);
stack.push({ level: level, ol: nestedOl });
}
activeList.insertBefore(rootLi, activeItem.nextSibling);
const activeItemSpan = activeSection.parentElement;
activeItemSpan.after(stack[0].ol);
});
document.addEventListener('DOMContentLoaded', reloadCurrentHeader);

View File

@@ -57,13 +57,13 @@ impl HelperDef for RenderToc {
out.write("<ol class=\"chapter\">")?;
let mut current_level = 1;
let mut first = true;
for item in chapters {
let (_section, level) = if let Some(s) = item.get("section") {
(s.as_str(), s.matches('.').count())
} else {
("", 1)
};
let level = item
.get("section")
.map(|s| s.matches('.').count())
.unwrap_or(1);
// Expand if folding is disabled, or if levels that are larger than this would not
// be folded.
@@ -71,25 +71,31 @@ impl HelperDef for RenderToc {
match level.cmp(&current_level) {
Ordering::Greater => {
while level > current_level {
out.write("<li>")?;
out.write("<ol class=\"section\">")?;
current_level += 1;
}
write_li_open_tag(out, is_expanded, false)?;
// There is an assumption that when descending, it can
// only go one level down at a time. This should be
// enforced by the nature of markdown lists and the
// summary parser.
assert_eq!(level, current_level + 1);
current_level += 1;
out.write("<ol class=\"section\">")?;
write_li_open_tag(out, is_expanded)?;
}
Ordering::Less => {
while level < current_level {
out.write("</ol>")?;
out.write("</li>")?;
out.write("</ol>")?;
current_level -= 1;
}
write_li_open_tag(out, is_expanded, false)?;
write_li_open_tag(out, is_expanded)?;
}
Ordering::Equal => {
write_li_open_tag(out, is_expanded, !item.contains_key("section"))?;
if !first {
out.write("</li>")?;
}
write_li_open_tag(out, is_expanded)?;
}
}
first = false;
// Spacer
if item.contains_key("spacer") {
@@ -105,6 +111,8 @@ impl HelperDef for RenderToc {
continue;
}
out.write("<span class=\"chapter-link-wrapper\">")?;
// Link
let path_exists = match item.get("path") {
Some(path) if !path.is_empty() => {
@@ -121,7 +129,7 @@ impl HelperDef for RenderToc {
true
}
_ => {
out.write("<div>")?;
out.write("<span>")?;
false
}
};
@@ -142,41 +150,35 @@ impl HelperDef for RenderToc {
if path_exists {
out.write("</a>")?;
} else {
out.write("</div>")?;
out.write("</span>")?;
}
// Render expand/collapse toggle
if let Some(flag) = item.get("has_sub_items") {
let has_sub_items = flag.parse::<bool>().unwrap_or_default();
if fold_enable && has_sub_items {
out.write("<a class=\"toggle\"><div>❱</div></a>")?;
// The <div> here is to manage rotating the element when
// the chapter title is long and word-wraps.
out.write("<a class=\"chapter-fold-toggle\"><div>❱</div></a>")?;
}
}
out.write("</li>")?;
out.write("</span>")?;
}
while current_level > 1 {
out.write("</ol>")?;
while current_level > 0 {
out.write("</li>")?;
out.write("</ol>")?;
current_level -= 1;
}
out.write("</ol>")?;
Ok(())
}
}
fn write_li_open_tag(
out: &mut dyn Output,
is_expanded: bool,
is_affix: bool,
) -> Result<(), std::io::Error> {
fn write_li_open_tag(out: &mut dyn Output, is_expanded: bool) -> Result<(), std::io::Error> {
let mut li = String::from("<li class=\"chapter-item ");
if is_expanded {
li.push_str("expanded ");
}
if is_affix {
li.push_str("affix ");
}
li.push_str("\">");
out.write(&li)
}

View File

@@ -0,0 +1,6 @@
[book]
title = "heading-nav-folded"
[output.html.fold]
enable = true
level = 0

View File

@@ -0,0 +1,7 @@
# Summary
- [Introduction](./intro.md)
- [Sub chapter](./sub/index.md)
- [Sub inner](./sub/inner/index.md)
- [Sub second chapter](./sub/second.md)
- [Next main chapter](./next-main.md)

View File

@@ -0,0 +1,9 @@
# Introduction
## Heading A
### Heading A2
### Heading A3
## Heading B

View File

@@ -0,0 +1 @@
# Next main chapter

View File

@@ -0,0 +1,3 @@
# Sub chapter
## Sub-chapter heading

View File

@@ -0,0 +1,3 @@
# Sub inner
## Inner chapter heading

View File

@@ -0,0 +1,3 @@
# Sub second chapter
## Second chapter heading

View File

@@ -6,3 +6,4 @@
- [Collapsed headings](collapsed.md)
- [Headings with markup](markup.md)
- [Current scrolls to bottom](current-to-bottom.md)
- [Unusual heading levels](unusual-heading-levels.md)

View File

@@ -0,0 +1,9 @@
# Unusual heading levels
### Heading 3
## Heading 2
#### Heading 5
#### Heading 5.1

View File

@@ -3,49 +3,54 @@
set-window-size: (1400, 800)
go-to: |DOC_PATH| + "heading-nav/collapsed.html"
assert-count: (".header-item", 12)
assert-count: (".header-item", 11)
assert-count: (".current-header", 1)
assert-text: (".current-header", "Heading 1")
// Collapsed elements do not have "expanded" class.
assert-attribute: ("li:has(> a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-21'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-21'])", {"class": "header-item"})
// Click 1.2, doesn't change expanded.
// Click 1.2, expands it.
click: "a.header-in-summary[href='#heading-12']"
assert-attribute: ("li:has(> a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-21'])", {"class": "header-item"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-12']]]/ol", {"display": "none"})
// Click expand chevron.
// 1.2.1 and 1.2.2 should be visible
assert-attribute: ("li:has(> span > a[href='#heading-12'])", {"class": "header-item expanded"})
assert-attribute: ("li:has(> span > a[href='#heading-21'])", {"class": "header-item"})
assert-css: ("//a[@href='#heading-12']/../following-sibling::ol", {"display": "block"})
// Click 1.1, should collapse it.
click: "a.header-in-summary[href='#heading-11']"
assert-attribute: ("li:has(> span > a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-21'])", {"class": "header-item"})
assert-css: ("//a[@href='#heading-12']/../following-sibling::ol", {"display": "none"})
// Click the chevron, should expand it.
click: "a.header-in-summary[href='#heading-12'] ~ a.header-toggle"
assert-attribute: ("li:has(> a[href='#heading-12'])", {"class": "header-item expanded"})
assert-attribute: ("li:has(> a[href='#heading-21'])", {"class": "header-item"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-12']]]/ol", {"display": "block"})
assert-attribute: ("li:has(> span > a[href='#heading-12'])", {"class": "header-item expanded"})
assert-attribute: ("li:has(> span > a[href='#heading-21'])", {"class": "header-item"})
assert-css: ("//a[@href='#heading-12']/../following-sibling::ol", {"display": "block"})
// Click 1.3
click: "a.header-in-summary[href='#heading-13']"
// Everything should be collapsed
assert-attribute: ("li:has(> a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-21'])", {"class": "header-item"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-12']]]/ol", {"display": "none"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-21']]]/ol", {"display": "none"})
assert-attribute: ("li:has(> span > a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-21'])", {"class": "header-item"})
assert-css: ("//a[@href='#heading-12']/../following-sibling::ol", {"display": "none"})
assert-css: ("//a[@href='#heading-21']/../following-sibling::ol", {"display": "none"})
assert-attribute: ("li:has(> span > a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-21'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-211'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-2111'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-21'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-211'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-2111'])", {"class": "header-item"})
// Scroll to bottom of page
press-key: 'PageDown'
press-key: 'PageDown'
press-key: 'PageDown'
press-key: 'PageDown'
// 2.1.1.1.1 should be visible, and all the chevrons should be open, and expanded should be on each one
assert-attribute: ("li:has(> a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> a[href='#heading-21'])", {"class": "header-item expanded"})
assert-attribute: ("li:has(> a[href='#heading-211'])", {"class": "header-item expanded"})
assert-attribute: ("li:has(> a[href='#heading-2111'])", {"class": "header-item expanded"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-12']]]/ol", {"display": "none"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-21']]]/ol", {"display": "block"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-211']]]/ol", {"display": "block"})
assert-css: ("//li[preceding-sibling::li[1][a[@href='#heading-2111']]]/ol", {"display": "block"})
assert-attribute: ("li:has(> span > a[href='#heading-12'])", {"class": "header-item"})
assert-attribute: ("li:has(> span > a[href='#heading-21'])", {"class": "header-item expanded"})
assert-attribute: ("li:has(> span > a[href='#heading-211'])", {"class": "header-item expanded"})
assert-attribute: ("li:has(> span > a[href='#heading-2111'])", {"class": "header-item expanded"})
assert-css: ("//a[@href='#heading-12']/../following-sibling::ol", {"display": "none"})
assert-css: ("//a[@href='#heading-21']/../following-sibling::ol", {"display": "block"})
assert-css: ("//a[@href='#heading-211']/../following-sibling::ol", {"display": "block"})
assert-css: ("//a[@href='#heading-2111']/../following-sibling::ol", {"display": "block"})

View File

@@ -0,0 +1,3 @@
// Tests when chapter folding is enabled.
go-to: |DOC_PATH| + "heading-nav-folded/index.html"

View File

@@ -3,7 +3,7 @@
set-window-size: (1400, 800)
go-to: |DOC_PATH| + "heading-nav/large-intro.html"
assert-count: (".header-item", 2)
assert-count: (".header-item", 1)
assert-count: (".current-header", 0)
scroll-to: "#first-header"

View File

@@ -3,7 +3,7 @@
set-window-size: (1400, 800)
go-to: |DOC_PATH| + "heading-nav/markup.html"
assert-count: (".header-item", 5)
assert-count: (".header-item", 4)
assert-count: (".current-header", 1)
assert-text: (".current-header", "Heading with code or italic or bold or strike")
assert-property: (".current-header", {"innerHTML": "Heading with <code>code</code> or <em>italic</em> or <strong>bold</strong> or <del>strike</del>"})

View File

@@ -3,7 +3,7 @@
set-window-size: (1400, 800)
go-to: |DOC_PATH| + "heading-nav/normal-intro.html"
assert-count: (".header-item", 4)
assert-count: (".header-item", 3)
assert-count: (".current-header", 1)
assert-text: (".current-header", "The first heading")

View File

@@ -0,0 +1,7 @@
// Tests for unusual heading levels
set-window-size: (1400, 800)
go-to: |DOC_PATH| + "heading-nav/unusual-heading-levels.html"
assert-property: ("//a[@href='unusual-heading-levels.html']/../following-sibling::ol", {"innerHTML": '<ol class="section"><li class="header-item expanded"><span class="chapter-link-wrapper"><a href="#heading-3" class="header-in-summary current-header">Heading 3</a></span></li></ol><li class="header-item expanded"><span class="chapter-link-wrapper"><a href="#heading-2" class="header-in-summary">Heading 2</a></span><ol class="section"><ol class="section"><li class="header-item expanded"><span class="chapter-link-wrapper"><a href="#heading-5" class="header-in-summary">Heading 5</a></span></li><li class="header-item expanded"><span class="chapter-link-wrapper"><a href="#heading-51" class="header-in-summary">Heading 5.1</a></span></li></ol></ol></li>'})

View File

@@ -20,16 +20,22 @@ fn readme_to_index() {
)
.check_toc_js(str![[r#"
<ol class="chapter">
<li class="chapter-item expanded affix ">
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="index.html">Intro</a>
</span>
</li>
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="first/index.html">
<strong aria-hidden="true">1.</strong> First</a>
</span>
</li>
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="second/index.html">
<strong aria-hidden="true">2.</strong> Second</a>
</span>
</li>
</ol>
"#]]);

View File

@@ -148,16 +148,22 @@ fn summary_with_markdown_formatting() {
.check_toc_js(str![[r#"
<ol class="chapter">
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="formatted-summary.html">
<strong aria-hidden="true">1.</strong> Italic code *escape* `escape2`</a>
</span>
</li>
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="soft.html">
<strong aria-hidden="true">2.</strong> Soft line break</a>
</span>
</li>
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="escaped-tag.html">
<strong aria-hidden="true">3.</strong> &lt;escaped tag&gt;</a>
</span>
</li>
</ol>
"#]])