Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] expandable sidebar sections (ToC collapse) #1027

Merged
merged 7 commits into from
Oct 19, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions book-example/src/format/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,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 @@ -225,6 +233,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 @@ -431,16 +431,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 @@ -450,6 +444,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 @@ -463,6 +465,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 @@ -71,6 +71,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 @@ -444,6 +448,9 @@ fn make_data(
data.insert("playpen_js".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 @@ -463,6 +470,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 @@ -481,6 +489,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 @@ -451,6 +451,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 @@ -376,7 +376,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 @@ -390,10 +396,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 @@ -235,7 +235,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 @@ -254,7 +259,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