[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
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).
@ -177,6 +178,13 @@ The following configuration options are available:
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:
@ -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

View File

@ -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.
@ -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 {
@ -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")]

View File

@ -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");
@ -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();
@ -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",
@ -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

View File

@ -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;
@ -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 {
@ -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
@ -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\"")?;
}
@ -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 {
@ -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)
}

View File

@ -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');

View File

@ -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 {
@ -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;

View File

@ -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)
@ -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)