[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.
This commit is contained in:
Weihang Lo 2019-10-19 15:56:08 +08:00 committed by Dylan DPC
parent e5f74b6c86
commit 6af6219e5b
7 changed files with 167 additions and 25 deletions

View File

@ -170,6 +170,7 @@ The following configuration options are available:
- **no-section-label:** mdBook by defaults adds section label in table of - **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 contents column. For example, "1.", "2.1". Set this option to true to disable
those labels. Defaults to `false`. those labels. Defaults to `false`.
- **fold:** A subtable for configuring sidebar section-folding behavior.
- **playpen:** A subtable for configuring various playpen settings. - **playpen:** A subtable for configuring various playpen settings.
- **search:** A subtable for configuring the in-browser search functionality. - **search:** A subtable for configuring the in-browser search functionality.
mdBook must be compiled with the `search` feature enabled (on by default). mdBook must be compiled with the `search` feature enabled (on by default).
@ -178,6 +179,13 @@ The following configuration options are available:
- **git-repository-icon:** The FontAwesome icon class to use for the git - **git-repository-icon:** The FontAwesome icon class to use for the git
repository link. Defaults to `fa-github`. 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: Available configuration options for the `[output.html.playpen]` table:
- **editable:** Allow editing the source code. Defaults to `false`. - **editable:** Allow editing the source code. Defaults to `false`.
@ -232,6 +240,10 @@ no-section-label = false
git-repository-url = "https://github.com/rust-lang-nursery/mdBook" git-repository-url = "https://github.com/rust-lang-nursery/mdBook"
git-repository-icon = "fa-github" git-repository-icon = "fa-github"
[output.html.fold]
enable = false
level = 0
[output.html.playpen] [output.html.playpen]
editable = false editable = false
copy-js = true copy-js = true

View File

@ -454,16 +454,10 @@ pub struct HtmlConfig {
/// Additional JS scripts to include at the bottom of the rendered page's /// Additional JS scripts to include at the bottom of the rendered page's
/// `<body>`. /// `<body>`.
pub additional_js: Vec<PathBuf>, pub additional_js: Vec<PathBuf>,
/// Fold settings.
pub fold: Fold,
/// Playpen settings. /// Playpen settings.
pub playpen: Playpen, 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. /// Don't render section labels.
pub no_section_label: bool, pub no_section_label: bool,
/// Search settings. If `None`, the default will be used. /// Search settings. If `None`, the default will be used.
@ -473,6 +467,14 @@ pub struct HtmlConfig {
/// FontAwesome icon class to use for the Git repository link. /// FontAwesome icon class to use for the Git repository link.
/// Defaults to `fa-github` if `None`. /// Defaults to `fa-github` if `None`.
pub git_repository_icon: Option<String>, 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 { impl HtmlConfig {
@ -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. /// Configuration for tweaking how the the HTML renderer handles the playpen.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case")]

View File

@ -72,6 +72,10 @@ impl HtmlHandlebars {
"path_to_root".to_owned(), "path_to_root".to_owned(),
json!(utils::fs::path_to_root(&ch.path)), 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 // Render the handlebars template with the data
debug!("Render template"); debug!("Render template");
@ -460,6 +464,9 @@ fn make_data(
data.insert("playpen_copyable".to_owned(), json!(true)); 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(); let search = html_config.search.clone();
if cfg!(feature = "search") { if cfg!(feature = "search") {
let search = search.unwrap_or_default(); let search = search.unwrap_or_default();
@ -479,6 +486,7 @@ fn make_data(
if let Some(ref git_repository_url) = html_config.git_repository_url { if let Some(ref git_repository_url) = html_config.git_repository_url {
data.insert("git_repository_url".to_owned(), json!(git_repository_url)); data.insert("git_repository_url".to_owned(), json!(git_repository_url));
} }
let git_repository_icon = match html_config.git_repository_icon { let git_repository_icon = match html_config.git_repository_icon {
Some(ref git_repository_icon) => git_repository_icon, Some(ref git_repository_icon) => git_repository_icon,
None => "fa-github", None => "fa-github",
@ -497,6 +505,11 @@ fn make_data(
chapter.insert("section".to_owned(), json!(section.to_string())); 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)); chapter.insert("name".to_owned(), json!(ch.name));
let path = ch let path = ch
.path .path

View File

@ -28,13 +28,36 @@ impl HelperDef for RenderToc {
serde_json::value::from_value::<Vec<BTreeMap<String, String>>>(c.as_json().clone()) serde_json::value::from_value::<Vec<BTreeMap<String, String>>>(c.as_json().clone())
.map_err(|_| RenderError::new("Could not decode the JSON data")) .map_err(|_| RenderError::new("Could not decode the JSON data"))
})?; })?;
let current = rc let current_path = rc
.evaluate(ctx, "@root/path")? .evaluate(ctx, "@root/path")?
.as_json() .as_json()
.as_str() .as_str()
.ok_or_else(|| RenderError::new("Type error for `path`, string expected"))? .ok_or(RenderError::new("Type error for `path`, string expected"))?
.replace("\"", ""); .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\">")?; out.write("<ol class=\"chapter\">")?;
let mut current_level = 1; let mut current_level = 1;
@ -46,10 +69,23 @@ impl HelperDef for RenderToc {
continue; continue;
} }
let level = if let Some(s) = item.get("section") { let (section, level) = if let Some(s) = item.get("section") {
s.matches('.').count() (s.as_str(), s.matches('.').count())
} else { } 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 { if level > current_level {
@ -58,20 +94,16 @@ impl HelperDef for RenderToc {
out.write("<ol class=\"section\">")?; out.write("<ol class=\"section\">")?;
current_level += 1; current_level += 1;
} }
out.write("<li>")?; write_li_open_tag(out, is_expanded, false)?;
} else if level < current_level { } else if level < current_level {
while level < current_level { while level < current_level {
out.write("</ol>")?; out.write("</ol>")?;
out.write("</li>")?; out.write("</li>")?;
current_level -= 1; current_level -= 1;
} }
out.write("<li>")?; write_li_open_tag(out, is_expanded, false)?;
} else { } else {
out.write("<li")?; write_li_open_tag(out, is_expanded, item.get("section").is_none())?;
if item.get("section").is_none() {
out.write(" class=\"affix\"")?;
}
out.write(">")?;
} }
// Link // Link
@ -87,11 +119,11 @@ impl HelperDef for RenderToc {
.replace("\\", "/"); .replace("\\", "/");
// Add link // Add link
out.write(&utils::fs::path_to_root(&current))?; out.write(&utils::fs::path_to_root(&current_path))?;
out.write(&tmp)?; out.write(&tmp)?;
out.write("\"")?; out.write("\"")?;
if path == &current { if path == &current_path {
out.write(" class=\"active\"")?; out.write(" class=\"active\"")?;
} }
@ -134,6 +166,13 @@ impl HelperDef for RenderToc {
out.write("</a>")?; 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>")?; out.write("</li>")?;
} }
while current_level > 1 { while current_level > 1 {
@ -146,3 +185,19 @@ impl HelperDef for RenderToc {
Ok(()) 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)
}

View File

@ -427,6 +427,17 @@ function playpen_text(playpen) {
try { localStorage.setItem('mdbook-sidebar', 'visible'); } catch (e) { } 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() { function hideSidebar() {
html.classList.remove('sidebar-visible') html.classList.remove('sidebar-visible')
html.classList.add('sidebar-hidden'); html.classList.add('sidebar-hidden');

View File

@ -375,7 +375,13 @@ ul#searchresults span.teaser em {
padding-left: 0; padding-left: 0;
line-height: 2.2em; line-height: 2.2em;
} }
.chapter ol {
width: 100%;
}
.chapter li { .chapter li {
display: flex;
color: var(--sidebar-non-existant); color: var(--sidebar-non-existant);
} }
.chapter li a { .chapter li a {
@ -389,10 +395,32 @@ ul#searchresults span.teaser em {
color: var(--sidebar-active); color: var(--sidebar-active);
} }
.chapter li .active { .chapter li a.active {
color: var(--sidebar-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 { .spacer {
width: 100%; width: 100%;
height: 3px; height: 3px;

View File

@ -264,7 +264,12 @@ fn check_second_toc_level() {
let mut should_be = Vec::from(TOC_SECOND_LEVEL); let mut should_be = Vec::from(TOC_SECOND_LEVEL);
should_be.sort(); 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 let mut children_of_children: Vec<_> = doc
.find(pred) .find(pred)
@ -283,7 +288,11 @@ fn check_first_toc_level() {
should_be.extend(TOC_SECOND_LEVEL); should_be.extend(TOC_SECOND_LEVEL);
should_be.sort(); 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 let mut children: Vec<_> = doc
.find(pred) .find(pred)