|
| 1 | +/** |
| 2 | + * SPA-like navigation for Sphinx/Furo docs. |
| 3 | + * |
| 4 | + * Intercepts internal link clicks and swaps only the content that changes |
| 5 | + * (article, sidebar nav tree, TOC drawer), preserving sidebar scroll |
| 6 | + * position, theme state, and avoiding full-page reloads. |
| 7 | + * |
| 8 | + * Progressive enhancement: no-op when fetch/DOMParser/pushState unavailable. |
| 9 | + */ |
| 10 | +(function () { |
| 11 | + "use strict"; |
| 12 | + |
| 13 | + if (!window.fetch || !window.DOMParser || !window.history?.pushState) return; |
| 14 | + |
| 15 | + // --- Theme toggle (replicates Furo's cycleThemeOnce) --- |
| 16 | + |
| 17 | + function cycleTheme() { |
| 18 | + var current = localStorage.getItem("theme") || "auto"; |
| 19 | + var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; |
| 20 | + var next; |
| 21 | + if (current === "auto") next = prefersDark ? "light" : "dark"; |
| 22 | + else if (current === "dark") next = prefersDark ? "auto" : "light"; |
| 23 | + else next = prefersDark ? "dark" : "auto"; |
| 24 | + document.body.dataset.theme = next; |
| 25 | + localStorage.setItem("theme", next); |
| 26 | + } |
| 27 | + |
| 28 | + // --- Copy button injection --- |
| 29 | + |
| 30 | + var copyBtnTemplate = null; |
| 31 | + |
| 32 | + function captureCopyIcon() { |
| 33 | + var btn = document.querySelector(".copybtn"); |
| 34 | + if (btn) copyBtnTemplate = btn.cloneNode(true); |
| 35 | + } |
| 36 | + |
| 37 | + function addCopyButtons() { |
| 38 | + if (!copyBtnTemplate) captureCopyIcon(); |
| 39 | + if (!copyBtnTemplate) return; |
| 40 | + var cells = document.querySelectorAll("div.highlight pre"); |
| 41 | + cells.forEach(function (cell, i) { |
| 42 | + cell.id = "codecell" + i; |
| 43 | + var next = cell.nextElementSibling; |
| 44 | + if (next && next.classList.contains("copybtn")) { |
| 45 | + next.setAttribute("data-clipboard-target", "#codecell" + i); |
| 46 | + } else { |
| 47 | + var btn = copyBtnTemplate.cloneNode(true); |
| 48 | + btn.setAttribute("data-clipboard-target", "#codecell" + i); |
| 49 | + cell.insertAdjacentElement("afterend", btn); |
| 50 | + } |
| 51 | + }); |
| 52 | + } |
| 53 | + |
| 54 | + // --- Minimal scrollspy --- |
| 55 | + |
| 56 | + var scrollCleanup = null; |
| 57 | + |
| 58 | + function initScrollSpy() { |
| 59 | + if (scrollCleanup) scrollCleanup(); |
| 60 | + scrollCleanup = null; |
| 61 | + |
| 62 | + var links = document.querySelectorAll(".toc-tree a"); |
| 63 | + if (!links.length) return; |
| 64 | + |
| 65 | + var entries = []; |
| 66 | + links.forEach(function (a) { |
| 67 | + var id = (a.getAttribute("href") || "").split("#")[1]; |
| 68 | + var el = id && document.getElementById(id); |
| 69 | + var li = a.closest("li"); |
| 70 | + if (el && li) entries.push({ el: el, li: li }); |
| 71 | + }); |
| 72 | + if (!entries.length) return; |
| 73 | + |
| 74 | + function update() { |
| 75 | + var offset = |
| 76 | + parseFloat(getComputedStyle(document.documentElement).fontSize) * 4; |
| 77 | + var active = null; |
| 78 | + for (var i = entries.length - 1; i >= 0; i--) { |
| 79 | + if (entries[i].el.getBoundingClientRect().top <= offset) { |
| 80 | + active = entries[i]; |
| 81 | + break; |
| 82 | + } |
| 83 | + } |
| 84 | + entries.forEach(function (e) { |
| 85 | + e.li.classList.remove("scroll-current"); |
| 86 | + }); |
| 87 | + if (active) active.li.classList.add("scroll-current"); |
| 88 | + } |
| 89 | + |
| 90 | + window.addEventListener("scroll", update, { passive: true }); |
| 91 | + update(); |
| 92 | + scrollCleanup = function () { |
| 93 | + window.removeEventListener("scroll", update); |
| 94 | + }; |
| 95 | + } |
| 96 | + |
| 97 | + // --- Link interception --- |
| 98 | + |
| 99 | + function shouldIntercept(link, e) { |
| 100 | + if (e.defaultPrevented || e.button !== 0) return false; |
| 101 | + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; |
| 102 | + if (link.origin !== location.origin) return false; |
| 103 | + if (link.target && link.target !== "_self") return false; |
| 104 | + if (link.hasAttribute("download")) return false; |
| 105 | + |
| 106 | + var path = link.pathname; |
| 107 | + if (!path.endsWith(".html") && !path.endsWith("/")) return false; |
| 108 | + |
| 109 | + var base = path.split("/").pop() || ""; |
| 110 | + if ( |
| 111 | + base === "search.html" || |
| 112 | + base === "genindex.html" || |
| 113 | + base === "py-modindex.html" |
| 114 | + ) |
| 115 | + return false; |
| 116 | + |
| 117 | + if (link.closest("#sidebar-projects")) return false; |
| 118 | + if (link.pathname === location.pathname && link.hash) return false; |
| 119 | + |
| 120 | + return true; |
| 121 | + } |
| 122 | + |
| 123 | + // --- DOM swap --- |
| 124 | + |
| 125 | + function swap(doc) { |
| 126 | + [".article-container", ".sidebar-tree", ".toc-drawer"].forEach( |
| 127 | + function (sel) { |
| 128 | + var fresh = doc.querySelector(sel); |
| 129 | + var stale = document.querySelector(sel); |
| 130 | + if (fresh && stale) stale.replaceWith(fresh); |
| 131 | + }, |
| 132 | + ); |
| 133 | + var title = doc.querySelector("title"); |
| 134 | + if (title) document.title = title.textContent || ""; |
| 135 | + } |
| 136 | + |
| 137 | + function reinit() { |
| 138 | + addCopyButtons(); |
| 139 | + initScrollSpy(); |
| 140 | + var btn = document.querySelector(".content-icon-container .theme-toggle"); |
| 141 | + if (btn) btn.addEventListener("click", cycleTheme); |
| 142 | + } |
| 143 | + |
| 144 | + // --- Navigation --- |
| 145 | + |
| 146 | + var currentCtrl = null; |
| 147 | + |
| 148 | + async function navigate(url, isPop) { |
| 149 | + if (currentCtrl) currentCtrl.abort(); |
| 150 | + var ctrl = new AbortController(); |
| 151 | + currentCtrl = ctrl; |
| 152 | + |
| 153 | + try { |
| 154 | + var resp = await fetch(url, { signal: ctrl.signal }); |
| 155 | + if (!resp.ok) throw new Error(resp.status); |
| 156 | + |
| 157 | + var html = await resp.text(); |
| 158 | + var doc = new DOMParser().parseFromString(html, "text/html"); |
| 159 | + |
| 160 | + if (!doc.querySelector(".article-container")) |
| 161 | + throw new Error("no article"); |
| 162 | + |
| 163 | + swap(doc); |
| 164 | + |
| 165 | + if (!isPop) history.pushState({ spa: true }, "", url); |
| 166 | + |
| 167 | + if (!isPop) { |
| 168 | + var hash = new URL(url, location.href).hash; |
| 169 | + if (hash) { |
| 170 | + var el = document.querySelector(hash); |
| 171 | + if (el) el.scrollIntoView(); |
| 172 | + } else { |
| 173 | + window.scrollTo(0, 0); |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + reinit(); |
| 178 | + } catch (err) { |
| 179 | + if (err.name === "AbortError") return; |
| 180 | + window.location.href = url; |
| 181 | + } finally { |
| 182 | + if (currentCtrl === ctrl) currentCtrl = null; |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + // --- Events --- |
| 187 | + |
| 188 | + document.addEventListener("click", function (e) { |
| 189 | + var link = e.target.closest("a[href]"); |
| 190 | + if (link && shouldIntercept(link, e)) { |
| 191 | + e.preventDefault(); |
| 192 | + navigate(link.href, false); |
| 193 | + } |
| 194 | + }); |
| 195 | + |
| 196 | + history.replaceState({ spa: true }, ""); |
| 197 | + |
| 198 | + window.addEventListener("popstate", function (e) { |
| 199 | + if (e.state && e.state.spa) navigate(location.href, true); |
| 200 | + }); |
| 201 | + |
| 202 | + // --- Hover prefetch --- |
| 203 | + |
| 204 | + var prefetchTimer = null; |
| 205 | + |
| 206 | + document.addEventListener("mouseover", function (e) { |
| 207 | + var link = e.target.closest("a[href]"); |
| 208 | + if (!link || link.origin !== location.origin) return; |
| 209 | + if (!link.pathname.endsWith(".html") && !link.pathname.endsWith("/")) |
| 210 | + return; |
| 211 | + |
| 212 | + clearTimeout(prefetchTimer); |
| 213 | + prefetchTimer = setTimeout(function () { |
| 214 | + fetch(link.href, { priority: "low" }).catch(function () {}); |
| 215 | + }, 65); |
| 216 | + }); |
| 217 | + |
| 218 | + document.addEventListener("mouseout", function (e) { |
| 219 | + if (e.target.closest("a[href]")) clearTimeout(prefetchTimer); |
| 220 | + }); |
| 221 | + |
| 222 | + // --- Init --- |
| 223 | + |
| 224 | + // Copy buttons are injected by copybutton.js on DOMContentLoaded. |
| 225 | + // This defer script runs before DOMContentLoaded, so our handler |
| 226 | + // fires after copybutton's handler (registration order preserved). |
| 227 | + document.addEventListener("DOMContentLoaded", captureCopyIcon); |
| 228 | +})(); |
0 commit comments