docs: Add table of contents navigation (#15212)

Danilo Leal and Marshall Bowers created

To ease navigating on pages that are long and having a birds-eye view of
all the available content.

This is done client-side and done via the files initially generated by
the `mdbook-pagetoc` plugin ([crates.io link
here](https://crates.io/crates/mdbook-pagetoc)).

<img width="600" alt="Screenshot 2024-07-25 at 13 34 08"
src="https://github.com/user-attachments/assets/a78c69e5-8cc4-4414-9d9c-27a4ceb27620">

---

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

docs/README.md               |  6 ++
docs/book.toml               |  2 
docs/theme/css/chrome.css    |  2 
docs/theme/css/general.css   |  4 
docs/theme/css/variables.css |  5 +
docs/theme/index.hbs         |  8 ++
docs/theme/page-toc.css      | 79 ++++++++++++++++++++++++++++++++++++++
docs/theme/page-toc.js       | 73 +++++++++++++++++++++++++++++++++++
8 files changed, 172 insertions(+), 7 deletions(-)

Detailed changes

docs/README.md 🔗

@@ -20,3 +20,9 @@ Putting binary assets such as images in the Git repository will bloat the reposi
 
 - We have a Cloudflare router called `docs-proxy` that intercepts requests to `zed.dev/docs` and forwards them to the "docs" Cloudflare Pages project.
 - CI uploads a new version to the Pages project from `.github/workflows/deploy_docs.yml` on every push to `main`.
+
+### Table of Contents
+
+The table of contents files (`theme/page-toc.js` and `theme/page-doc.css`) were initially generated by [`mdbook-pagetoc`](https://crates.io/crates/mdbook-pagetoc).
+
+Since all these preprocessor does is generate the static assets, we don't need to keep it around once they have been generated.

docs/book.toml 🔗

@@ -9,6 +9,8 @@ site-url = "/docs/"
 [output.html]
 no-section-label = true
 preferred-dark-theme = "light"
+additional-css = ["theme/page-toc.css"]
+additional-js  = ["theme/page-toc.js"]
 
 [output.html.print]
 enable = false

docs/theme/css/chrome.css 🔗

@@ -560,7 +560,7 @@ ul#searchresults span.teaser em {
 
 .chapter li a.active {
   color: var(--sidebar-active);
-  background-color: rgba(8, 76, 207, 0.1);
+  background-color: var(--sidebar-active-bg);
 }
 
 .chapter li > a.toggle {

docs/theme/css/general.css 🔗

@@ -113,7 +113,7 @@ h6:target::before {
 */
 :target {
   /* Safari does not support logical properties */
-  scroll-margin-top: calc(var(--menu-bar-height) + 0.5em);
+  scroll-margin-top: calc(var(--menu-bar-height) + 2rem);
 }
 
 .page {
@@ -141,7 +141,7 @@ h6:target::before {
 
 .content {
   overflow-y: auto;
-  padding: 24px 4px 48px 4px;
+  padding: 48px 4px;
 }
 .content main {
   margin-inline-start: auto;

docs/theme/css/variables.css 🔗

@@ -6,7 +6,7 @@
   --sidebar-resize-indicator-space: 2px;
   --page-padding: 15px;
   --content-max-width: 750px;
-  --menu-bar-height: 50px;
+  --menu-bar-height: 64px;
   --font: "IA Writer Quattro S", sans-serif;
   --title-font: "Agrandir", "Helvetica Neue", Helvetica, Arial, sans-serif;
   --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
@@ -20,7 +20,8 @@
 
   --sidebar-fg: hsl(0, 0%, 0%);
   --sidebar-non-existant: #aaaaaa;
-  --sidebar-active: rgb(8, 76, 207);
+  --sidebar-active: hsl(219, 93%, 42%);
+  --sidebar-active-bg: hsl(219, 93%, 42%, 0.1);
   --sidebar-spacer: #f4f4f4;
 
   --scrollbar: #8f8f8f;

docs/theme/index.hbs 🔗

@@ -119,7 +119,6 @@
         </script>
 
         <div id="page-wrapper" class="page-wrapper">
-
             <div class="page">
                 {{> header}}
                 <div id="menu-bar-hover-placeholder"></div>
@@ -192,7 +191,12 @@
 
                 <div id="content" class="content">
                     <main>
-                        {{{ content }}}
+                      <div class="sidetoc">
+                          <nav class="pagetoc">
+                            <p class="toc-title">On this page</p>
+                          </nav>
+                      </div>
+                      {{{ content }}}
                     </main>
                 </div>
             </div>

docs/theme/page-toc.css 🔗

@@ -0,0 +1,79 @@
+@media only screen and (max-width: 1674px) {
+  .sidetoc {
+    display: none;
+  }
+}
+
+@media only screen and (min-width: 1675px) {
+  main {
+    position: relative;
+  }
+  .sidetoc {
+    margin-left: auto;
+    margin-right: auto;
+    left: calc(100% + (var(--content-max-width)) / 3 - 160px);
+    position: absolute;
+  }
+  .pagetoc {
+    position: fixed;
+    top: 64px;
+    width: 220px;
+    height: calc(100vh - var(--menu-bar-height) - 0.67em * 4);
+    padding-top: 80px;
+    margin-right: 16px;
+    padding-bottom: 40px;
+    overflow: auto;
+  }
+  .pagetoc > :last-child {
+    margin-bottom: 64px;
+  }
+  .pagetoc a {
+    width: fit-content;
+    font-size: 1.4rem;
+    border-left: 1px solid var(--sidebar-bg);
+    color: var(--fg) !important;
+    display: block;
+    padding: 2px;
+    margin: 8px 0 8px 12px;
+    text-align: left;
+    text-decoration: underline;
+    text-decoration-color: hsl(0, 0%, 0%, 0.1);
+  }
+  .pagetoc a:hover {
+    text-decoration-color: hsl(0, 0%, 0%, 0.5);
+  }
+  .pagetoc a.active {
+    background-color: var(--sidebar-active-bg);
+    color: var(--sidebar-active) !important;
+    text-decoration-color: hsl(219, 93%, 42%, 0.1);
+  }
+  .pagetoc a.active:hover {
+    text-decoration-color: hsl(219, 93%, 42%, 0.8);
+  }
+  .pagetoc .active {
+    background: var(--sidebar-bg);
+    color: var(--sidebar-fg);
+  }
+  .pagetoc .pagetoc-H1 {
+    display: none;
+  }
+  .pagetoc .pagetoc-H3 {
+    margin-left: 24px;
+  }
+  .pagetoc .pagetoc-H4 {
+    margin-left: 42px;
+  }
+  .pagetoc .pagetoc-H5 {
+    display: none;
+  }
+  .pagetoc .pagetoc-H6 {
+    display: none;
+  }
+  .toc-title {
+    margin: 0;
+    margin-bottom: 12px;
+    padding-left: 12px;
+    font-size: 1.4rem;
+    color: #000;
+  }
+}

docs/theme/page-toc.js 🔗

@@ -0,0 +1,73 @@
+let scrollTimeout;
+
+const listenActive = () => {
+  const elems = document.querySelector(".pagetoc").children;
+  [...elems].forEach((el) => {
+    el.addEventListener("click", (event) => {
+      clearTimeout(scrollTimeout);
+      [...elems].forEach((el) => el.classList.remove("active"));
+      el.classList.add("active");
+      // Prevent scroll updates for a short period
+      scrollTimeout = setTimeout(() => {
+        scrollTimeout = null;
+      }, 100); // Adjust timing as needed
+    });
+  });
+};
+
+const getPagetoc = () =>
+  document.querySelector(".pagetoc") || autoCreatePagetoc();
+
+const autoCreatePagetoc = () => {
+  const main = document.querySelector("#content > main");
+  const content = Object.assign(document.createElement("div"), {
+    className: "content-wrap",
+  });
+  content.append(...main.childNodes);
+  main.prepend(content);
+  main.insertAdjacentHTML(
+    "afterbegin",
+    '<div class="sidetoc"><nav class="pagetoc"></nav></div>',
+  );
+  return document.querySelector(".pagetoc");
+};
+const updateFunction = () => {
+  if (scrollTimeout) return; // Skip updates if within the cooldown period from a click
+  const headers = [...document.getElementsByClassName("header")];
+  const scrolledY = window.scrollY;
+  let lastHeader = null;
+
+  // Find the last header that is above the current scroll position
+  for (let i = headers.length - 1; i >= 0; i--) {
+    if (scrolledY >= headers[i].offsetTop) {
+      lastHeader = headers[i];
+      break;
+    }
+  }
+
+  const pagetocLinks = [...document.querySelector(".pagetoc").children];
+  pagetocLinks.forEach((link) => link.classList.remove("active"));
+
+  if (lastHeader) {
+    const activeLink = pagetocLinks.find(
+      (link) => lastHeader.href === link.href,
+    );
+    if (activeLink) activeLink.classList.add("active");
+  }
+};
+
+window.addEventListener("load", () => {
+  const pagetoc = getPagetoc();
+  const headers = [...document.getElementsByClassName("header")];
+  headers.forEach((header) => {
+    const link = Object.assign(document.createElement("a"), {
+      textContent: header.text,
+      href: header.href,
+      className: `pagetoc-${header.parentElement.tagName}`,
+    });
+    pagetoc.appendChild(link);
+  });
+  updateFunction();
+  listenActive();
+  window.addEventListener("scroll", updateFunction);
+});