Skip to content

Commit

Permalink
[Feature] expandable sidebar sections (ToC collapse) (rust-lang#1027)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
weihanglo authored and Dylan-DPC committed Oct 19, 2019
1 parent 57cd281 commit 2375117
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 25 deletions.
12 changes: 12 additions & 0 deletions book-example/src/format/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,21 @@ The following configuration options are available:
- **no-section-label:** mdBook by defaults adds section label in table of
contents column. For example, "1.", "2.1". Set this option to true to disable
those labels. Defaults to `false`.
- **fold:** A subtable for configuring sidebar section-folding behavior.
- **playpen:** A subtable for configuring various playpen settings.
- **search:** A subtable for configuring the in-browser search functionality.
mdBook must be compiled with the `search` feature enabled (on by default).
- **git-repository-url:** A url to the git repository for the book. If provided
an icon link will be output in the menu bar of the book.
- **git-repository-icon:** The FontAwesome icon class to use for the git
repository link. Defaults to `fa-github`.

Available configuration options for the `[output.html.fold]` table:

- **enable:** Enable section-folding. When off, all folds are open.
Defaults to `false`.
- **level:** The higher the more folded regions are open. When level is 0, all
folds are closed. Defaults to `0`.

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

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

[output.html.fold]
enable = false
level = 0

[output.html.playpen]
editable = false
copy-js = true
Expand Down
30 changes: 22 additions & 8 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,16 +454,10 @@ pub struct HtmlConfig {
/// Additional JS scripts to include at the bottom of the rendered page's
/// `<body>`.
pub additional_js: Vec<PathBuf>,
/// Fold settings.
pub fold: Fold,
/// Playpen settings.
pub playpen: Playpen,
/// This is used as a bit of a workaround for the `mdbook serve` command.
/// Basically, because you set the websocket port from the command line, the
/// `mdbook serve` command needs a way to let the HTML renderer know where
/// to point livereloading at, if it has been enabled.
///
/// This config item *should not be edited* by the end user.
#[doc(hidden)]
pub livereload_url: Option<String>,
/// Don't render section labels.
pub no_section_label: bool,
/// Search settings. If `None`, the default will be used.
Expand All @@ -473,6 +467,14 @@ pub struct HtmlConfig {
/// FontAwesome icon class to use for the Git repository link.
/// Defaults to `fa-github` if `None`.
pub git_repository_icon: Option<String>,
/// This is used as a bit of a workaround for the `mdbook serve` command.
/// Basically, because you set the websocket port from the command line, the
/// `mdbook serve` command needs a way to let the HTML renderer know where
/// to point livereloading at, if it has been enabled.
///
/// This config item *should not be edited* by the end user.
#[doc(hidden)]
pub livereload_url: Option<String>,
}

impl HtmlConfig {
Expand All @@ -486,6 +488,18 @@ impl HtmlConfig {
}
}

/// Configuration for how to fold chapters of sidebar.
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Fold {
/// When off, all folds are open. Default: `false`.
pub enable: bool,
/// The higher the more folded regions are open. When level is 0, all folds
/// are closed.
/// Default: `0`.
pub level: u8,
}

/// Configuration for tweaking how the the HTML renderer handles the playpen.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ impl HtmlHandlebars {
"path_to_root".to_owned(),
json!(utils::fs::path_to_root(&ch.path)),
);
if let Some(ref section) = ch.number {
ctx.data
.insert("section".to_owned(), json!(section.to_string()));
}

// Render the handlebars template with the data
debug!("Render template");
Expand Down Expand Up @@ -460,6 +464,9 @@ fn make_data(
data.insert("playpen_copyable".to_owned(), json!(true));
}

data.insert("fold_enable".to_owned(), json!((html_config.fold.enable)));
data.insert("fold_level".to_owned(), json!((html_config.fold.level)));

let search = html_config.search.clone();
if cfg!(feature = "search") {
let search = search.unwrap_or_default();
Expand All @@ -479,6 +486,7 @@ fn make_data(
if let Some(ref git_repository_url) = html_config.git_repository_url {
data.insert("git_repository_url".to_owned(), json!(git_repository_url));
}

let git_repository_icon = match html_config.git_repository_icon {
Some(ref git_repository_icon) => git_repository_icon,
None => "fa-github",
Expand All @@ -497,6 +505,11 @@ fn make_data(
chapter.insert("section".to_owned(), json!(section.to_string()));
}

chapter.insert(
"has_sub_items".to_owned(),
json!((!ch.sub_items.is_empty()).to_string()),
);

chapter.insert("name".to_owned(), json!(ch.name));
let path = ch
.path
Expand Down
83 changes: 69 additions & 14 deletions src/renderer/html_handlebars/helpers/toc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,36 @@ impl HelperDef for RenderToc {
serde_json::value::from_value::<Vec<BTreeMap<String, String>>>(c.as_json().clone())
.map_err(|_| RenderError::new("Could not decode the JSON data"))
})?;
let current = rc
let current_path = rc
.evaluate(ctx, "@root/path")?
.as_json()
.as_str()
.ok_or_else(|| RenderError::new("Type error for `path`, string expected"))?
.ok_or(RenderError::new("Type error for `path`, string expected"))?
.replace("\"", "");

let current_section = rc
.evaluate(ctx, "@root/section")?
.as_json()
.as_str()
.map(str::to_owned)
.unwrap_or_default();

let fold_enable = rc
.evaluate(ctx, "@root/fold_enable")?
.as_json()
.as_bool()
.ok_or(RenderError::new(
"Type error for `fold_enable`, bool expected",
))?;

let fold_level = rc
.evaluate(ctx, "@root/fold_level")?
.as_json()
.as_u64()
.ok_or(RenderError::new(
"Type error for `fold_level`, u64 expected",
))?;

out.write("<ol class=\"chapter\">")?;

let mut current_level = 1;
Expand All @@ -46,10 +69,23 @@ impl HelperDef for RenderToc {
continue;
}

let level = if let Some(s) = item.get("section") {
s.matches('.').count()
let (section, level) = if let Some(s) = item.get("section") {
(s.as_str(), s.matches('.').count())
} else {
1
("", 1)
};

let is_expanded = {
if !fold_enable {
// Disable fold. Expand all chapters.
true
} else if !section.is_empty() && current_section.starts_with(section) {
// The section is ancestor or the current section itself.
true
} else {
// Levels that are larger than this would be folded.
level - 1 < fold_level as usize
}
};

if level > current_level {
Expand All @@ -58,20 +94,16 @@ impl HelperDef for RenderToc {
out.write("<ol class=\"section\">")?;
current_level += 1;
}
out.write("<li>")?;
write_li_open_tag(out, is_expanded, false)?;
} else if level < current_level {
while level < current_level {
out.write("</ol>")?;
out.write("</li>")?;
current_level -= 1;
}
out.write("<li>")?;
write_li_open_tag(out, is_expanded, false)?;
} else {
out.write("<li")?;
if item.get("section").is_none() {
out.write(" class=\"affix\"")?;
}
out.write(">")?;
write_li_open_tag(out, is_expanded, item.get("section").is_none())?;
}

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

// Add link
out.write(&utils::fs::path_to_root(&current))?;
out.write(&utils::fs::path_to_root(&current_path))?;
out.write(&tmp)?;
out.write("\"")?;

if path == &current {
if path == &current_path {
out.write(" class=\"active\"")?;
}

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

// 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>")?;
}
}
out.write("</li>")?;
}
while current_level > 1 {
Expand All @@ -146,3 +185,19 @@ impl HelperDef for RenderToc {
Ok(())
}
}

fn write_li_open_tag(
out: &mut dyn Output,
is_expanded: bool,
is_affix: bool,
) -> Result<(), std::io::Error> {
let mut li = String::from("<li class=\"");
if is_expanded {
li.push_str("expanded ");
}
if is_affix {
li.push_str("affix ");
}
li.push_str("\">");
out.write(&li)
}
11 changes: 11 additions & 0 deletions src/theme/book.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,17 @@ function playpen_text(playpen) {
try { localStorage.setItem('mdbook-sidebar', 'visible'); } catch (e) { }
}


var sidebarAnchorToggles = document.querySelectorAll('#sidebar a.toggle');

function toggleSection(ev) {
ev.currentTarget.parentElement.classList.toggle('expanded');
}

Array.from(sidebarAnchorToggles).forEach(function (el) {
el.addEventListener('click', toggleSection);
});

function hideSidebar() {
html.classList.remove('sidebar-visible')
html.classList.add('sidebar-hidden');
Expand Down
30 changes: 29 additions & 1 deletion src/theme/css/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,13 @@ ul#searchresults span.teaser em {
padding-left: 0;
line-height: 2.2em;
}

.chapter ol {
width: 100%;
}

.chapter li {
display: flex;
color: var(--sidebar-non-existant);
}
.chapter li a {
Expand All @@ -389,10 +395,32 @@ ul#searchresults span.teaser em {
color: var(--sidebar-active);
}

.chapter li .active {
.chapter li a.active {
color: var(--sidebar-active);
}

.chapter li > a.toggle {
cursor: pointer;
display: block;
margin-left: auto;
padding: 0 10px;
user-select: none;
opacity: 0.68;
}

.chapter li > a.toggle div {
transition: transform 0.5s;
}

/* collapse the section */
.chapter li:not(.expanded) + li > ol {
display: none;
}

.chapter li.expanded > a.toggle div {
transform: rotate(90deg);
}

.spacer {
width: 100%;
height: 3px;
Expand Down
13 changes: 11 additions & 2 deletions tests/rendered_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,12 @@ fn check_second_toc_level() {
let mut should_be = Vec::from(TOC_SECOND_LEVEL);
should_be.sort();

let pred = descendants!(Class("chapter"), Name("li"), Name("li"), Name("a"));
let pred = descendants!(
Class("chapter"),
Name("li"),
Name("li"),
Name("a").and(Class("toggle").not())
);

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

let pred = descendants!(Class("chapter"), Name("li"), Name("a"));
let pred = descendants!(
Class("chapter"),
Name("li"),
Name("a").and(Class("toggle").not())
);

let mut children: Vec<_> = doc
.find(pred)
Expand Down

0 comments on commit 2375117

Please sign in to comment.