229 lines
7.6 KiB
JavaScript
229 lines
7.6 KiB
JavaScript
/**
|
|
* Collapsible Table of Contents for mkdocs-material with toc.integrate.
|
|
*
|
|
* Behaviour:
|
|
* - ALL nested TOC items start collapsed on page load.
|
|
* - Clicking a parent item toggles it and marks it as "user-pinned" so it
|
|
* stays in whatever state the user chose.
|
|
* - As the reader scrolls, the ancestors of the currently-active TOC link
|
|
* are automatically expanded so the highlighted item is always visible.
|
|
* - When the reader scrolls past a section, any items that were only
|
|
* auto-expanded (not user-pinned) collapse back down.
|
|
* - Works with mkdocs-material instant-loading / SPA navigation.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
/**
|
|
* Set of TOC <li> elements the user has manually expanded.
|
|
* These survive auto-collapse and are only cleared on page navigation.
|
|
*/
|
|
var userPinned = new Set();
|
|
|
|
/**
|
|
* Set of TOC <li> elements that were auto-expanded by the scroll tracker.
|
|
* Cleared and recalculated each time the active link changes.
|
|
*/
|
|
var autoExpanded = new Set();
|
|
|
|
/** Reference to the sidebar element (cached after first lookup). */
|
|
var sidebar = null;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return all ancestor <li.md-nav__item--nested> elements between `el`
|
|
* and the TOC root (the <ul data-md-component="toc"> list).
|
|
*/
|
|
function ancestorItems(el) {
|
|
var ancestors = [];
|
|
var node = el ? el.parentElement : null;
|
|
while (node && node !== sidebar) {
|
|
if (
|
|
node.tagName === "LI" &&
|
|
node.classList.contains("md-nav__item--nested")
|
|
) {
|
|
ancestors.push(node);
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
/**
|
|
* Collapse a nested item (add the CSS class that hides children).
|
|
*/
|
|
function collapse(li) {
|
|
li.classList.add("toc-collapsed");
|
|
}
|
|
|
|
/**
|
|
* Expand a nested item (remove the CSS class so children are visible).
|
|
*/
|
|
function expand(li) {
|
|
li.classList.remove("toc-collapsed");
|
|
}
|
|
|
|
// ── Initialisation ──────────────────────────────────────────────────
|
|
|
|
function initCollapsibleToc() {
|
|
sidebar = document.querySelector(".md-sidebar--primary");
|
|
if (!sidebar) return;
|
|
|
|
// Reset pinned / auto-expanded tracking on (re-)init
|
|
userPinned.clear();
|
|
autoExpanded.clear();
|
|
|
|
// Mark all nav roots so CSS can scope styles
|
|
sidebar.querySelectorAll("nav.md-nav").forEach(function (nav) {
|
|
nav.classList.add("md-nav--integrated");
|
|
});
|
|
|
|
// Find every <li> inside the actual TOC (md-nav--secondary) that has
|
|
// nested children. We scope to the secondary nav to avoid touching the
|
|
// page-level nav item that wraps the TOC — that item contains a <label>
|
|
// for the mobile TOC toggle and would show up as a bogus duplicate entry.
|
|
var tocNav = sidebar.querySelector("nav.md-nav--secondary");
|
|
if (!tocNav) return;
|
|
|
|
tocNav.querySelectorAll("li.md-nav__item").forEach(function (li) {
|
|
var nestedNav = li.querySelector(":scope > nav.md-nav, :scope > .md-nav");
|
|
if (!nestedNav) return;
|
|
|
|
li.classList.add("md-nav__item--nested");
|
|
|
|
// Collapse everything on load
|
|
collapse(li);
|
|
|
|
// Inject a dedicated toggle button (chevron) into the link row.
|
|
// Clicking the chevron only toggles expand/collapse.
|
|
// Clicking the link text still navigates to the heading.
|
|
var link = li.querySelector(":scope > a.md-nav__link");
|
|
if (link && !link.querySelector(".toc-toggle")) {
|
|
var toggle = document.createElement("span");
|
|
toggle.className = "toc-toggle";
|
|
toggle.setAttribute("role", "button");
|
|
toggle.setAttribute("aria-label", "Toggle section");
|
|
link.appendChild(toggle);
|
|
|
|
toggle.addEventListener("click", function (e) {
|
|
// Stop the click from propagating to the <a> and navigating
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
var isCollapsed = li.classList.contains("toc-collapsed");
|
|
if (isCollapsed) {
|
|
expand(li);
|
|
userPinned.add(li);
|
|
} else {
|
|
collapse(li);
|
|
userPinned.delete(li);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Kick off the active-link watcher
|
|
watchActiveLink();
|
|
}
|
|
|
|
// ── Active-link watcher ─────────────────────────────────────────────
|
|
//
|
|
// mkdocs-material dynamically toggles `.md-nav__link--active` on the
|
|
// TOC link that corresponds to the heading currently in view. We use
|
|
// a MutationObserver on the TOC container to detect when that class
|
|
// changes and react by expanding / collapsing as needed.
|
|
|
|
var observer = null;
|
|
|
|
function watchActiveLink() {
|
|
// Disconnect any previous observer (e.g. after SPA navigation)
|
|
if (observer) {
|
|
observer.disconnect();
|
|
observer = null;
|
|
}
|
|
|
|
var tocRoot = sidebar
|
|
? sidebar.querySelector("[data-md-component='toc']")
|
|
: null;
|
|
if (!tocRoot) return;
|
|
|
|
// Respond to the current active link immediately
|
|
handleActiveChange(tocRoot);
|
|
|
|
// Observe subtree attribute changes — the theme adds / removes
|
|
// the --active class on <a> elements as the user scrolls.
|
|
observer = new MutationObserver(function () {
|
|
handleActiveChange(tocRoot);
|
|
});
|
|
|
|
observer.observe(tocRoot, {
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ["class"],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called whenever the active TOC link may have changed.
|
|
*
|
|
* 1. Find the currently-active <a>.
|
|
* 2. Determine which ancestor <li>s need to be expanded.
|
|
* 3. Auto-expand those ancestors (unless already open).
|
|
* 4. Auto-collapse any previously auto-expanded items that are no longer
|
|
* ancestors of the active link (but leave user-pinned items alone).
|
|
*/
|
|
function handleActiveChange(tocRoot) {
|
|
var activeLink = tocRoot.querySelector("a.md-nav__link--active");
|
|
|
|
// Determine which nested <li>s must be open for the active link
|
|
var requiredOpen = activeLink ? ancestorItems(activeLink) : [];
|
|
|
|
// Also include the active link's own <li> if it is a nested item
|
|
if (activeLink) {
|
|
var parentLi = activeLink.closest("li.md-nav__item--nested");
|
|
if (parentLi && requiredOpen.indexOf(parentLi) === -1) {
|
|
requiredOpen.push(parentLi);
|
|
}
|
|
}
|
|
|
|
var requiredSet = new Set(requiredOpen);
|
|
|
|
// Collapse items that were auto-expanded last round but are no longer
|
|
// needed — skip anything the user has pinned open.
|
|
autoExpanded.forEach(function (li) {
|
|
if (!requiredSet.has(li) && !userPinned.has(li)) {
|
|
collapse(li);
|
|
}
|
|
});
|
|
|
|
// Build the new auto-expanded set
|
|
autoExpanded = new Set();
|
|
|
|
requiredOpen.forEach(function (li) {
|
|
if (li.classList.contains("toc-collapsed")) {
|
|
expand(li);
|
|
}
|
|
// Track as auto-expanded only if not user-pinned
|
|
if (!userPinned.has(li)) {
|
|
autoExpanded.add(li);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Bootstrap ───────────────────────────────────────────────────────
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initCollapsibleToc);
|
|
} else {
|
|
initCollapsibleToc();
|
|
}
|
|
|
|
// Re-init after instant navigation (mkdocs-material SPA mode)
|
|
if (typeof document$ !== "undefined") {
|
|
document$.subscribe(function () {
|
|
initCollapsibleToc();
|
|
});
|
|
}
|
|
})();
|