Skip to content

Commit 6af6219

Browse files
weihangloDylan-DPC
authored andcommitted
[Feature] expandable sidebar sections (ToC collapse) (#1027)
* render(toc): render expandable toc toggle * ui(toc): js/css logic to toggle toc * test: update rendered output css selector * config: add `html.fold.[enable|level]` * renderer: fold according to configs * doc: add `output.html.fold` * refactor: tidy fold config - Derive default for `Fold`. - Use `is_empty` instead of checking the length of chapters.
1 parent e5f74b6 commit 6af6219

File tree

7 files changed

+167
-25
lines changed

7 files changed

+167
-25
lines changed

book-example/src/format/config.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,21 @@ The following configuration options are available:
170170
- **no-section-label:** mdBook by defaults adds section label in table of
171171
contents column. For example, "1.", "2.1". Set this option to true to disable
172172
those labels. Defaults to `false`.
173+
- **fold:** A subtable for configuring sidebar section-folding behavior.
173174
- **playpen:** A subtable for configuring various playpen settings.
174175
- **search:** A subtable for configuring the in-browser search functionality.
175176
mdBook must be compiled with the `search` feature enabled (on by default).
176177
- **git-repository-url:** A url to the git repository for the book. If provided
177178
an icon link will be output in the menu bar of the book.
178179
- **git-repository-icon:** The FontAwesome icon class to use for the git
179180
repository link. Defaults to `fa-github`.
181+
182+
Available configuration options for the `[output.html.fold]` table:
183+
184+
- **enable:** Enable section-folding. When off, all folds are open.
185+
Defaults to `false`.
186+
- **level:** The higher the more folded regions are open. When level is 0, all
187+
folds are closed. Defaults to `0`.
180188

181189
Available configuration options for the `[output.html.playpen]` table:
182190

@@ -232,6 +240,10 @@ no-section-label = false
232240
git-repository-url = "https://github.com/rust-lang-nursery/mdBook"
233241
git-repository-icon = "fa-github"
234242

243+
[output.html.fold]
244+
enable = false
245+
level = 0
246+
235247
[output.html.playpen]
236248
editable = false
237249
copy-js = true

src/config.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -454,16 +454,10 @@ pub struct HtmlConfig {
454454
/// Additional JS scripts to include at the bottom of the rendered page's
455455
/// `<body>`.
456456
pub additional_js: Vec<PathBuf>,
457+
/// Fold settings.
458+
pub fold: Fold,
457459
/// Playpen settings.
458460
pub playpen: Playpen,
459-
/// This is used as a bit of a workaround for the `mdbook serve` command.
460-
/// Basically, because you set the websocket port from the command line, the
461-
/// `mdbook serve` command needs a way to let the HTML renderer know where
462-
/// to point livereloading at, if it has been enabled.
463-
///
464-
/// This config item *should not be edited* by the end user.
465-
#[doc(hidden)]
466-
pub livereload_url: Option<String>,
467461
/// Don't render section labels.
468462
pub no_section_label: bool,
469463
/// Search settings. If `None`, the default will be used.
@@ -473,6 +467,14 @@ pub struct HtmlConfig {
473467
/// FontAwesome icon class to use for the Git repository link.
474468
/// Defaults to `fa-github` if `None`.
475469
pub git_repository_icon: Option<String>,
470+
/// This is used as a bit of a workaround for the `mdbook serve` command.
471+
/// Basically, because you set the websocket port from the command line, the
472+
/// `mdbook serve` command needs a way to let the HTML renderer know where
473+
/// to point livereloading at, if it has been enabled.
474+
///
475+
/// This config item *should not be edited* by the end user.
476+
#[doc(hidden)]
477+
pub livereload_url: Option<String>,
476478
}
477479

478480
impl HtmlConfig {
@@ -486,6 +488,18 @@ impl HtmlConfig {
486488
}
487489
}
488490

491+
/// Configuration for how to fold chapters of sidebar.
492+
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
493+
#[serde(default, rename_all = "kebab-case")]
494+
pub struct Fold {
495+
/// When off, all folds are open. Default: `false`.
496+
pub enable: bool,
497+
/// The higher the more folded regions are open. When level is 0, all folds
498+
/// are closed.
499+
/// Default: `0`.
500+
pub level: u8,
501+
}
502+
489503
/// Configuration for tweaking how the the HTML renderer handles the playpen.
490504
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
491505
#[serde(default, rename_all = "kebab-case")]

src/renderer/html_handlebars/hbs_renderer.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ impl HtmlHandlebars {
7272
"path_to_root".to_owned(),
7373
json!(utils::fs::path_to_root(&ch.path)),
7474
);
75+
if let Some(ref section) = ch.number {
76+
ctx.data
77+
.insert("section".to_owned(), json!(section.to_string()));
78+
}
7579

7680
// Render the handlebars template with the data
7781
debug!("Render template");
@@ -460,6 +464,9 @@ fn make_data(
460464
data.insert("playpen_copyable".to_owned(), json!(true));
461465
}
462466

467+
data.insert("fold_enable".to_owned(), json!((html_config.fold.enable)));
468+
data.insert("fold_level".to_owned(), json!((html_config.fold.level)));
469+
463470
let search = html_config.search.clone();
464471
if cfg!(feature = "search") {
465472
let search = search.unwrap_or_default();
@@ -479,6 +486,7 @@ fn make_data(
479486
if let Some(ref git_repository_url) = html_config.git_repository_url {
480487
data.insert("git_repository_url".to_owned(), json!(git_repository_url));
481488
}
489+
482490
let git_repository_icon = match html_config.git_repository_icon {
483491
Some(ref git_repository_icon) => git_repository_icon,
484492
None => "fa-github",
@@ -497,6 +505,11 @@ fn make_data(
497505
chapter.insert("section".to_owned(), json!(section.to_string()));
498506
}
499507

508+
chapter.insert(
509+
"has_sub_items".to_owned(),
510+
json!((!ch.sub_items.is_empty()).to_string()),
511+
);
512+
500513
chapter.insert("name".to_owned(), json!(ch.name));
501514
let path = ch
502515
.path

src/renderer/html_handlebars/helpers/toc.rs

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,36 @@ impl HelperDef for RenderToc {
2828
serde_json::value::from_value::<Vec<BTreeMap<String, String>>>(c.as_json().clone())
2929
.map_err(|_| RenderError::new("Could not decode the JSON data"))
3030
})?;
31-
let current = rc
31+
let current_path = rc
3232
.evaluate(ctx, "@root/path")?
3333
.as_json()
3434
.as_str()
35-
.ok_or_else(|| RenderError::new("Type error for `path`, string expected"))?
35+
.ok_or(RenderError::new("Type error for `path`, string expected"))?
3636
.replace("\"", "");
3737

38+
let current_section = rc
39+
.evaluate(ctx, "@root/section")?
40+
.as_json()
41+
.as_str()
42+
.map(str::to_owned)
43+
.unwrap_or_default();
44+
45+
let fold_enable = rc
46+
.evaluate(ctx, "@root/fold_enable")?
47+
.as_json()
48+
.as_bool()
49+
.ok_or(RenderError::new(
50+
"Type error for `fold_enable`, bool expected",
51+
))?;
52+
53+
let fold_level = rc
54+
.evaluate(ctx, "@root/fold_level")?
55+
.as_json()
56+
.as_u64()
57+
.ok_or(RenderError::new(
58+
"Type error for `fold_level`, u64 expected",
59+
))?;
60+
3861
out.write("<ol class=\"chapter\">")?;
3962

4063
let mut current_level = 1;
@@ -46,10 +69,23 @@ impl HelperDef for RenderToc {
4669
continue;
4770
}
4871

49-
let level = if let Some(s) = item.get("section") {
50-
s.matches('.').count()
72+
let (section, level) = if let Some(s) = item.get("section") {
73+
(s.as_str(), s.matches('.').count())
5174
} else {
52-
1
75+
("", 1)
76+
};
77+
78+
let is_expanded = {
79+
if !fold_enable {
80+
// Disable fold. Expand all chapters.
81+
true
82+
} else if !section.is_empty() && current_section.starts_with(section) {
83+
// The section is ancestor or the current section itself.
84+
true
85+
} else {
86+
// Levels that are larger than this would be folded.
87+
level - 1 < fold_level as usize
88+
}
5389
};
5490

5591
if level > current_level {
@@ -58,20 +94,16 @@ impl HelperDef for RenderToc {
5894
out.write("<ol class=\"section\">")?;
5995
current_level += 1;
6096
}
61-
out.write("<li>")?;
97+
write_li_open_tag(out, is_expanded, false)?;
6298
} else if level < current_level {
6399
while level < current_level {
64100
out.write("</ol>")?;
65101
out.write("</li>")?;
66102
current_level -= 1;
67103
}
68-
out.write("<li>")?;
104+
write_li_open_tag(out, is_expanded, false)?;
69105
} else {
70-
out.write("<li")?;
71-
if item.get("section").is_none() {
72-
out.write(" class=\"affix\"")?;
73-
}
74-
out.write(">")?;
106+
write_li_open_tag(out, is_expanded, item.get("section").is_none())?;
75107
}
76108

77109
// Link
@@ -87,11 +119,11 @@ impl HelperDef for RenderToc {
87119
.replace("\\", "/");
88120

89121
// Add link
90-
out.write(&utils::fs::path_to_root(&current))?;
122+
out.write(&utils::fs::path_to_root(&current_path))?;
91123
out.write(&tmp)?;
92124
out.write("\"")?;
93125

94-
if path == &current {
126+
if path == &current_path {
95127
out.write(" class=\"active\"")?;
96128
}
97129

@@ -134,6 +166,13 @@ impl HelperDef for RenderToc {
134166
out.write("</a>")?;
135167
}
136168

169+
// Render expand/collapse toggle
170+
if let Some(flag) = item.get("has_sub_items") {
171+
let has_sub_items = flag.parse::<bool>().unwrap_or_default();
172+
if fold_enable && has_sub_items {
173+
out.write("<a class=\"toggle\"><div>❱</div></a>")?;
174+
}
175+
}
137176
out.write("</li>")?;
138177
}
139178
while current_level > 1 {
@@ -146,3 +185,19 @@ impl HelperDef for RenderToc {
146185
Ok(())
147186
}
148187
}
188+
189+
fn write_li_open_tag(
190+
out: &mut dyn Output,
191+
is_expanded: bool,
192+
is_affix: bool,
193+
) -> Result<(), std::io::Error> {
194+
let mut li = String::from("<li class=\"");
195+
if is_expanded {
196+
li.push_str("expanded ");
197+
}
198+
if is_affix {
199+
li.push_str("affix ");
200+
}
201+
li.push_str("\">");
202+
out.write(&li)
203+
}

src/theme/book.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,17 @@ function playpen_text(playpen) {
427427
try { localStorage.setItem('mdbook-sidebar', 'visible'); } catch (e) { }
428428
}
429429

430+
431+
var sidebarAnchorToggles = document.querySelectorAll('#sidebar a.toggle');
432+
433+
function toggleSection(ev) {
434+
ev.currentTarget.parentElement.classList.toggle('expanded');
435+
}
436+
437+
Array.from(sidebarAnchorToggles).forEach(function (el) {
438+
el.addEventListener('click', toggleSection);
439+
});
440+
430441
function hideSidebar() {
431442
html.classList.remove('sidebar-visible')
432443
html.classList.add('sidebar-hidden');

src/theme/css/chrome.css

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,13 @@ ul#searchresults span.teaser em {
375375
padding-left: 0;
376376
line-height: 2.2em;
377377
}
378+
379+
.chapter ol {
380+
width: 100%;
381+
}
382+
378383
.chapter li {
384+
display: flex;
379385
color: var(--sidebar-non-existant);
380386
}
381387
.chapter li a {
@@ -389,10 +395,32 @@ ul#searchresults span.teaser em {
389395
color: var(--sidebar-active);
390396
}
391397

392-
.chapter li .active {
398+
.chapter li a.active {
393399
color: var(--sidebar-active);
394400
}
395401

402+
.chapter li > a.toggle {
403+
cursor: pointer;
404+
display: block;
405+
margin-left: auto;
406+
padding: 0 10px;
407+
user-select: none;
408+
opacity: 0.68;
409+
}
410+
411+
.chapter li > a.toggle div {
412+
transition: transform 0.5s;
413+
}
414+
415+
/* collapse the section */
416+
.chapter li:not(.expanded) + li > ol {
417+
display: none;
418+
}
419+
420+
.chapter li.expanded > a.toggle div {
421+
transform: rotate(90deg);
422+
}
423+
396424
.spacer {
397425
width: 100%;
398426
height: 3px;

tests/rendered_output.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,12 @@ fn check_second_toc_level() {
264264
let mut should_be = Vec::from(TOC_SECOND_LEVEL);
265265
should_be.sort();
266266

267-
let pred = descendants!(Class("chapter"), Name("li"), Name("li"), Name("a"));
267+
let pred = descendants!(
268+
Class("chapter"),
269+
Name("li"),
270+
Name("li"),
271+
Name("a").and(Class("toggle").not())
272+
);
268273

269274
let mut children_of_children: Vec<_> = doc
270275
.find(pred)
@@ -283,7 +288,11 @@ fn check_first_toc_level() {
283288
should_be.extend(TOC_SECOND_LEVEL);
284289
should_be.sort();
285290

286-
let pred = descendants!(Class("chapter"), Name("li"), Name("a"));
291+
let pred = descendants!(
292+
Class("chapter"),
293+
Name("li"),
294+
Name("a").and(Class("toggle").not())
295+
);
287296

288297
let mut children: Vec<_> = doc
289298
.find(pred)

0 commit comments

Comments
 (0)