[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:
parent
e5f74b6c86
commit
6af6219e5b
|
@ -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
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(¤t))?;
|
out.write(&utils::fs::path_to_root(¤t_path))?;
|
||||||
out.write(&tmp)?;
|
out.write(&tmp)?;
|
||||||
out.write("\"")?;
|
out.write("\"")?;
|
||||||
|
|
||||||
if path == ¤t {
|
if path == ¤t_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)
|
||||||
|
}
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue