| --- |
| export interface Props { |
| tableOfContentAutoCollapse?: boolean; |
| } |
| const { tableOfContentAutoCollapse = false } = Astro.props as Props; |
| --- |
|
|
| <nav |
| class="table-of-contents toc-loading" |
| aria-label="Table of Contents" |
| data-auto-collapse={tableOfContentAutoCollapse ? "1" : "0"} |
| > |
| <div class="title">Table of Contents</div> |
| <div id="article-toc-placeholder"></div> |
| </nav> |
|
|
| |
| <button class="toc-mobile-toggle" aria-label="Open table of contents" aria-expanded="false"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <line x1="3" y1="6" x2="21" y2="6" /> |
| <line x1="3" y1="12" x2="21" y2="12" /> |
| <line x1="3" y1="18" x2="21" y2="18" /> |
| </svg> |
| </button> |
| <div class="toc-mobile-backdrop" aria-hidden="true"></div> |
| <aside class="toc-mobile-sidebar toc-loading" aria-label="Table of Contents"> |
| <div class="toc-mobile-sidebar__header"> |
| <span class="toc-mobile-sidebar__title">Table of Contents</span> |
| <div class="toc-mobile-sidebar__actions"> |
| <button class="toc-mobile-sidebar__theme" aria-label="Toggle color theme"> |
| <svg class="icon light" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <circle cx="12" cy="12" r="5"></circle> |
| <line x1="12" y1="1" x2="12" y2="4"></line> |
| <line x1="12" y1="20" x2="12" y2="23"></line> |
| <line x1="1" y1="12" x2="4" y2="12"></line> |
| <line x1="20" y1="12" x2="23" y2="12"></line> |
| <line x1="4.22" y1="4.22" x2="6.34" y2="6.34"></line> |
| <line x1="17.66" y1="17.66" x2="19.78" y2="19.78"></line> |
| <line x1="4.22" y1="19.78" x2="6.34" y2="17.66"></line> |
| <line x1="17.66" y1="6.34" x2="19.78" y2="4.22"></line> |
| </svg> |
| <svg class="icon dark" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> |
| </svg> |
| </button> |
| <button class="toc-mobile-sidebar__close" aria-label="Close table of contents"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <line x1="18" y1="6" x2="6" y2="18" /> |
| <line x1="6" y1="6" x2="18" y2="18" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| <div class="toc-mobile-sidebar__body" id="article-toc-mobile-placeholder"></div> |
| </aside> |
|
|
| <script is:inline> |
| |
| const buildTOC = () => { |
| const holder = document.getElementById("article-toc-placeholder"); |
| const holderMobile = document.getElementById( |
| "article-toc-mobile-placeholder", |
| ); |
| |
| if (holder) holder.innerHTML = ""; |
| if (holderMobile) holderMobile.innerHTML = ""; |
| const articleRoot = document.querySelector("section.content-grid main"); |
| if (!articleRoot) return; |
| const headings = articleRoot.querySelectorAll("h2, h3, h4"); |
| if (!headings.length) return; |
| |
| |
| const headingsArr = Array.from(headings); |
| if (!headingsArr.length) return; |
| |
| |
| const usedIds = new Set(); |
| const slugify = (s) => |
| String(s || "") |
| .toLowerCase() |
| .trim() |
| .replace(/\s+/g, "_") |
| .replace(/[^a-z0-9_\-]/g, ""); |
| headingsArr.forEach((h) => { |
| let id = (h.id || "").trim(); |
| if (!id) { |
| const base = slugify(h.textContent || ""); |
| id = base || "section"; |
| } |
| let candidate = id; |
| let n = 2; |
| while (usedIds.has(candidate)) { |
| candidate = `${id}-${n++}`; |
| } |
| if (h.id !== candidate) h.id = candidate; |
| usedIds.add(candidate); |
| }); |
| |
| const nav = document.createElement("nav"); |
| let ulStack = [document.createElement("ul")]; |
| nav.appendChild(ulStack[0]); |
| |
| const levelOf = (tag) => (tag === "H2" ? 2 : tag === "H3" ? 3 : 4); |
| let prev = 2; |
| let headingCount = 0; |
| headingsArr.forEach((h) => { |
| const lvl = levelOf(h.tagName); |
| |
| while (lvl > prev) { |
| const ul = document.createElement("ul"); |
| ulStack[ulStack.length - 1].lastElementChild?.appendChild(ul); |
| ulStack.push(ul); |
| prev++; |
| } |
| while (lvl < prev) { |
| ulStack.pop(); |
| prev--; |
| } |
| const li = document.createElement("li"); |
| const a = document.createElement("a"); |
| a.href = "#" + h.id; |
| a.textContent = h.textContent; |
| a.target = "_self"; |
| li.appendChild(a); |
| |
| li.setAttribute("data-heading-idx", String(headingCount)); |
| headingCount++; |
| ulStack[ulStack.length - 1].appendChild(li); |
| }); |
| |
| if (holder) holder.appendChild(nav); |
| const navClone = nav.cloneNode(true); |
| if (holderMobile) holderMobile.appendChild(navClone); |
| |
| |
| const links = [ |
| ...(holder ? holder.querySelectorAll("a") : []), |
| ...(holderMobile ? holderMobile.querySelectorAll("a") : []), |
| ]; |
| |
| const getCollapsePx = () => { |
| const root = document.documentElement; |
| const raw = getComputedStyle(root) |
| .getPropertyValue("--bp-content-collapse") |
| .trim(); |
| return raw || "1100px"; |
| }; |
| const mq = window.matchMedia(`(max-width: ${getCollapsePx()})`); |
| const attrEnabled = |
| document |
| .querySelector(".table-of-contents") |
| ?.getAttribute("data-auto-collapse") === "1"; |
| let autoCollapse = attrEnabled && !mq.matches; |
| |
| |
| const ensureStyles = () => { |
| if (document.getElementById("toc-collapse-style")) return; |
| const style = document.createElement("style"); |
| style.id = "toc-collapse-style"; |
| style.textContent = ` |
| .table-of-contents nav.table-of-contents-collapsible li > ul, |
| .toc-mobile-sidebar nav.table-of-contents-collapsible li > ul { overflow: hidden; transition: height 200ms ease; } |
| .table-of-contents nav.table-of-contents-collapsible li.collapsed > ul, |
| .toc-mobile-sidebar nav.table-of-contents-collapsible li.collapsed > ul { display: block; } |
| `; |
| document.head.appendChild(style); |
| }; |
| ensureStyles(); |
| |
| const getAllItemsWithChildren = () => { |
| const sideNav = holder ? holder.querySelector("nav") : null; |
| const mobileNav = holderMobile ? holderMobile.querySelector("nav") : null; |
| const q = (navEl) => |
| navEl |
| ? Array.from(navEl.querySelectorAll("li[data-heading-idx]")).filter( |
| (li) => li.querySelector(":scope > ul"), |
| ) |
| : []; |
| return { |
| sideNav, |
| mobileNav, |
| sideItems: q(sideNav), |
| mobileItems: q(mobileNav), |
| }; |
| }; |
| |
| const setNavCollapsible = () => { |
| const sideNav = holder ? holder.querySelector("nav") : null; |
| const mobileNav = holderMobile ? holderMobile.querySelector("nav") : null; |
| if (sideNav) sideNav.classList.add("table-of-contents-collapsible"); |
| if (mobileNav) mobileNav.classList.add("table-of-contents-collapsible"); |
| }; |
| |
| const measure = (el) => { |
| if (!el) return 0; |
| |
| const prev = el.style.height; |
| el.style.height = "auto"; |
| |
| |
| void el.offsetHeight; |
| |
| |
| const h = el.scrollHeight; |
| el.style.height = prev || ""; |
| return h; |
| }; |
| |
| |
| const activeAnimations = new Map(); |
| |
| const cancelAnimation = (el) => { |
| if (!el) return; |
| const animData = activeAnimations.get(el); |
| if (animData) { |
| |
| el.removeEventListener("transitionend", animData.onEnd); |
| activeAnimations.delete(el); |
| } |
| }; |
| |
| const animateTo = (el, target) => { |
| if (!el) return; |
| |
| |
| cancelAnimation(el); |
| |
| |
| const current = parseFloat(getComputedStyle(el).height) || 0; |
| |
| |
| if (Math.abs(current - target) < 1) { |
| el.style.height = target ? "auto" : "0px"; |
| return; |
| } |
| |
| |
| el.style.height = current + "px"; |
| |
| void el.offsetHeight; |
| |
| |
| el.style.height = target + "px"; |
| |
| |
| const onEnd = (e) => { |
| if (e.propertyName !== "height") return; |
| el.removeEventListener("transitionend", onEnd); |
| activeAnimations.delete(el); |
| if (target > 0) el.style.height = "auto"; |
| }; |
| |
| |
| activeAnimations.set(el, { onEnd }); |
| el.addEventListener("transitionend", onEnd); |
| }; |
| |
| let prevActiveIdx = -1; |
| let prevActiveElements = new Set(); |
| let prevActiveHeadingId = null; |
| |
| const setCollapsedState = (activeIdx) => { |
| if (!autoCollapse) return; |
| if (activeIdx == null || activeIdx < 0) activeIdx = 0; |
| const { sideItems, mobileItems } = getAllItemsWithChildren(); |
| |
| |
| const getActiveAndAncestors = (items, targetIdx) => { |
| const toExpand = new Set(); |
| |
| |
| const findActiveLi = (li) => { |
| const idx = Number(li.getAttribute("data-heading-idx") || "-1"); |
| if (idx === targetIdx) { |
| return li; |
| } |
| |
| const childUl = li.querySelector(":scope > ul"); |
| if (!childUl) return null; |
| |
| const childLis = childUl.querySelectorAll( |
| ":scope > li[data-heading-idx]", |
| ); |
| for (const child of childLis) { |
| const found = findActiveLi(child); |
| if (found) return found; |
| } |
| return null; |
| }; |
| |
| let activeLi = null; |
| for (const li of items) { |
| activeLi = findActiveLi(li); |
| if (activeLi) break; |
| } |
| |
| if (!activeLi) return toExpand; |
| |
| |
| const activeIdx = Number( |
| activeLi.getAttribute("data-heading-idx") || "-1", |
| ); |
| toExpand.add(activeIdx); |
| |
| |
| |
| let current = activeLi; |
| while (current) { |
| const parent = current.parentElement?.closest("li[data-heading-idx]"); |
| if (parent) { |
| const parentIdx = Number( |
| parent.getAttribute("data-heading-idx") || "-1", |
| ); |
| toExpand.add(parentIdx); |
| current = parent; |
| } else { |
| break; |
| } |
| } |
| |
| return toExpand; |
| }; |
| |
| const update = (items) => { |
| const newActiveAncestors = getActiveAndAncestors(items, activeIdx); |
| |
| |
| |
| items.forEach((li) => { |
| const sub = li.querySelector(":scope > ul"); |
| if (sub) cancelAnimation(sub); |
| }); |
| |
| |
| const allChanges = []; |
| items.forEach((li) => { |
| const sub = li.querySelector(":scope > ul"); |
| if (!sub) return; |
| |
| const idx = Number(li.getAttribute("data-heading-idx") || "-1"); |
| |
| |
| |
| let shouldBeExpanded = false; |
| |
| |
| const allDescendants = li.querySelectorAll("li[data-heading-idx]"); |
| const allRelatedIndices = [ |
| idx, |
| ...Array.from(allDescendants).map((d) => |
| Number(d.getAttribute("data-heading-idx") || "-1"), |
| ), |
| ]; |
| |
| |
| shouldBeExpanded = allRelatedIndices.some((i) => |
| newActiveAncestors.has(i), |
| ); |
| |
| const isCurrentlyCollapsed = li.classList.contains("collapsed"); |
| |
| const isChanging = |
| (shouldBeExpanded && isCurrentlyCollapsed) || |
| (!shouldBeExpanded && !isCurrentlyCollapsed); |
| |
| if (isChanging) { |
| allChanges.push({ li, sub, shouldBeExpanded, idx }); |
| } |
| }); |
| |
| |
| |
| const topLevelChanges = []; |
| const descendantChanges = []; |
| |
| allChanges.forEach((change) => { |
| let hasAncestorChanging = false; |
| |
| |
| let currentLi = change.li; |
| while (currentLi) { |
| const parentLi = currentLi.parentElement?.closest( |
| "li[data-heading-idx]", |
| ); |
| if (!parentLi) break; |
| |
| const parentIdx = Number( |
| parentLi.getAttribute("data-heading-idx") || "-1", |
| ); |
| |
| |
| const parentIsChanging = allChanges.some( |
| (c) => c.idx === parentIdx, |
| ); |
| if (parentIsChanging) { |
| hasAncestorChanging = true; |
| break; |
| } |
| |
| currentLi = parentLi; |
| } |
| |
| if (hasAncestorChanging) { |
| descendantChanges.push(change); |
| } else { |
| topLevelChanges.push(change); |
| } |
| }); |
| |
| |
| |
| if (descendantChanges.length > 0) { |
| descendantChanges.forEach(({ li, sub, shouldBeExpanded }) => { |
| const oldTransition = sub.style.transition; |
| sub.style.transition = "none"; |
| |
| if (shouldBeExpanded) { |
| li.classList.remove("collapsed"); |
| sub.style.height = "auto"; |
| } else { |
| li.classList.add("collapsed"); |
| sub.style.height = "0px"; |
| } |
| |
| |
| void sub.offsetHeight; |
| sub.style.transition = oldTransition || ""; |
| }); |
| |
| |
| void document.body.offsetHeight; |
| |
| |
| |
| } |
| |
| |
| |
| if (topLevelChanges.length > 0) { |
| |
| requestAnimationFrame(() => { |
| requestAnimationFrame(() => { |
| topLevelChanges.forEach(({ li, sub, shouldBeExpanded }) => { |
| if (shouldBeExpanded) { |
| li.classList.remove("collapsed"); |
| |
| |
| |
| const allInnerItems = sub.querySelectorAll( |
| "li[data-heading-idx]", |
| ); |
| |
| |
| allInnerItems.forEach((innerLi) => { |
| const innerSub = innerLi.querySelector(":scope > ul"); |
| if (innerSub) { |
| innerSub.style.transition = "none"; |
| } |
| }); |
| |
| |
| allInnerItems.forEach((innerLi) => { |
| const innerIdx = Number( |
| innerLi.getAttribute("data-heading-idx") || "-1", |
| ); |
| const innerSub = innerLi.querySelector(":scope > ul"); |
| if (innerSub) { |
| if (newActiveAncestors.has(innerIdx)) { |
| |
| innerLi.classList.remove("collapsed"); |
| innerSub.style.height = "auto"; |
| } else { |
| |
| innerLi.classList.add("collapsed"); |
| innerSub.style.height = "0px"; |
| } |
| } |
| }); |
| |
| |
| void sub.offsetHeight; |
| |
| |
| allInnerItems.forEach((innerLi) => { |
| const innerSub = innerLi.querySelector(":scope > ul"); |
| if (innerSub) { |
| innerSub.style.transition = ""; |
| } |
| }); |
| |
| |
| |
| const target = measure(sub); |
| animateTo(sub, target); |
| } else { |
| li.classList.add("collapsed"); |
| animateTo(sub, 0); |
| } |
| }); |
| }); |
| }); |
| } |
| |
| prevActiveElements = newActiveAncestors; |
| }; |
| |
| update(sideItems); |
| update(mobileItems); |
| setNavCollapsible(); |
| prevActiveIdx = activeIdx; |
| }; |
| |
| |
| const expandAll = () => { |
| const { sideItems, mobileItems } = getAllItemsWithChildren(); |
| const expand = (items) => |
| items.forEach((li) => { |
| li.classList.remove("collapsed"); |
| const sub = li.querySelector(":scope > ul"); |
| if (sub) sub.style.height = "auto"; |
| }); |
| expand(sideItems); |
| expand(mobileItems); |
| }; |
| |
| const onMqChange = () => { |
| autoCollapse = attrEnabled && !mq.matches; |
| if (!autoCollapse) { |
| expandAll(); |
| } else { |
| setCollapsedState(prevActiveIdx); |
| } |
| }; |
| if (mq.addEventListener) mq.addEventListener("change", onMqChange); |
| else if (mq.addListener) mq.addListener(onMqChange); |
| |
| |
| const SCROLL_OFFSET_PX = 60; |
| const SCROLL_THROTTLE_MS = 50; |
| const URL_DEBOUNCE_MS = 300; |
| const COLLAPSE_DEBOUNCE_MS = 100; |
| const ANIMATION_DURATION_MS = 250; |
| |
| |
| let scrollDebounceTimer = null; |
| let lastRequestedIdx = -1; |
| let isProcessing = false; |
| let hasUserScrolled = false; |
| let urlUpdateTimer = null; |
| let lastScrollTime = 0; |
| |
| |
| const updateURL = (headingId, force = false) => { |
| if (!headingId) return; |
| |
| |
| if (!force && !hasUserScrolled) return; |
| |
| |
| clearTimeout(urlUpdateTimer); |
| urlUpdateTimer = setTimeout( |
| () => { |
| const newUrl = `${window.location.pathname}${window.location.search}#${headingId}`; |
| |
| |
| if (window.location.href !== newUrl) { |
| history.pushState(null, null, newUrl); |
| |
| |
| if (window.parent !== window) { |
| try { |
| window.parent.postMessage( |
| { |
| queryString: "", |
| hash: headingId, |
| }, |
| "https://huggingface.co", |
| ); |
| } catch (e) { |
| |
| } |
| } |
| } |
| }, |
| force ? 0 : URL_DEBOUNCE_MS, |
| ); |
| }; |
| |
| |
| const findActiveHeading = () => { |
| let activeIdx = -1; |
| let activeHeadingId = null; |
| |
| for (let i = headingsArr.length - 1; i >= 0; i--) { |
| const top = headingsArr[i].getBoundingClientRect().top; |
| if (top - SCROLL_OFFSET_PX <= 0) { |
| links.forEach((l) => l.classList.remove("active")); |
| const id = "#" + headingsArr[i].id; |
| const actives = Array.from(links).filter( |
| (l) => l.getAttribute("href") === id, |
| ); |
| actives.forEach((a) => a.classList.add("active")); |
| activeIdx = i; |
| activeHeadingId = headingsArr[i].id; |
| break; |
| } |
| } |
| |
| return { activeIdx, activeHeadingId }; |
| }; |
| |
| const onScroll = () => { |
| |
| const now = performance.now(); |
| if (now - lastScrollTime < SCROLL_THROTTLE_MS) { |
| return; |
| } |
| lastScrollTime = now; |
| |
| |
| hasUserScrolled = true; |
| |
| |
| |
| requestAnimationFrame(() => { |
| const { activeIdx, activeHeadingId } = findActiveHeading(); |
| |
| |
| if (activeHeadingId && activeHeadingId !== prevActiveHeadingId) { |
| updateURL(activeHeadingId); |
| prevActiveHeadingId = activeHeadingId; |
| } |
| |
| if (activeIdx === prevActiveIdx) return; |
| |
| |
| lastRequestedIdx = activeIdx; |
| |
| |
| if (isProcessing) return; |
| |
| |
| clearTimeout(scrollDebounceTimer); |
| scrollDebounceTimer = setTimeout(() => { |
| |
| if (lastRequestedIdx !== prevActiveIdx) { |
| isProcessing = true; |
| setCollapsedState(lastRequestedIdx); |
| |
| setTimeout(() => { |
| isProcessing = false; |
| |
| if (lastRequestedIdx !== prevActiveIdx) { |
| onScroll(); |
| } |
| }, ANIMATION_DURATION_MS); |
| } |
| }, COLLAPSE_DEBOUNCE_MS); |
| }); |
| }; |
| |
| |
| const setCollapsedStateSync = (activeIdx) => { |
| if (!autoCollapse) return; |
| if (activeIdx == null || activeIdx < 0) activeIdx = 0; |
| const { sideItems, mobileItems } = getAllItemsWithChildren(); |
| |
| const getActiveAndAncestors = (items, targetIdx) => { |
| const toExpand = new Set(); |
| const findActiveLi = (li) => { |
| const idx = Number(li.getAttribute("data-heading-idx") || "-1"); |
| if (idx === targetIdx) return li; |
| const childUl = li.querySelector(":scope > ul"); |
| if (!childUl) return null; |
| const childLis = childUl.querySelectorAll( |
| ":scope > li[data-heading-idx]", |
| ); |
| for (const child of childLis) { |
| const found = findActiveLi(child); |
| if (found) return found; |
| } |
| return null; |
| }; |
| |
| let activeLi = null; |
| for (const li of items) { |
| activeLi = findActiveLi(li); |
| if (activeLi) break; |
| } |
| if (!activeLi) return toExpand; |
| |
| const activeIdxNum = Number( |
| activeLi.getAttribute("data-heading-idx") || "-1", |
| ); |
| toExpand.add(activeIdxNum); |
| |
| let current = activeLi; |
| while (current) { |
| const parent = current.parentElement?.closest("li[data-heading-idx]"); |
| if (parent) { |
| const parentIdx = Number( |
| parent.getAttribute("data-heading-idx") || "-1", |
| ); |
| toExpand.add(parentIdx); |
| current = parent; |
| } else { |
| break; |
| } |
| } |
| return toExpand; |
| }; |
| |
| const applyStateSync = (items) => { |
| const newActiveAncestors = getActiveAndAncestors(items, activeIdx); |
| |
| |
| items.forEach((li) => { |
| const sub = li.querySelector(":scope > ul"); |
| if (sub) { |
| sub.style.transition = "none"; |
| } |
| }); |
| |
| |
| items.forEach((li) => { |
| const sub = li.querySelector(":scope > ul"); |
| if (!sub) return; |
| |
| const idx = Number(li.getAttribute("data-heading-idx") || "-1"); |
| const allDescendants = li.querySelectorAll("li[data-heading-idx]"); |
| const allRelatedIndices = [ |
| idx, |
| ...Array.from(allDescendants).map((d) => |
| Number(d.getAttribute("data-heading-idx") || "-1"), |
| ), |
| ]; |
| |
| const shouldBeExpanded = allRelatedIndices.some((i) => |
| newActiveAncestors.has(i), |
| ); |
| |
| if (shouldBeExpanded) { |
| li.classList.remove("collapsed"); |
| sub.style.height = "auto"; |
| } else { |
| li.classList.add("collapsed"); |
| sub.style.height = "0px"; |
| } |
| }); |
| |
| |
| void document.body.offsetHeight; |
| |
| |
| requestAnimationFrame(() => { |
| items.forEach((li) => { |
| const sub = li.querySelector(":scope > ul"); |
| if (sub) { |
| sub.style.transition = ""; |
| } |
| }); |
| }); |
| }; |
| |
| applyStateSync(sideItems); |
| applyStateSync(mobileItems); |
| setNavCollapsible(); |
| prevActiveIdx = activeIdx; |
| }; |
| |
| |
| const showTOC = () => { |
| |
| requestAnimationFrame(() => { |
| requestAnimationFrame(() => { |
| |
| setTimeout(() => { |
| const tocElement = document.querySelector(".table-of-contents"); |
| const tocMobileElement = document.querySelector( |
| ".toc-mobile-sidebar", |
| ); |
| if (tocElement) { |
| tocElement.classList.remove("toc-loading"); |
| tocElement.classList.add("toc-loaded"); |
| } |
| if (tocMobileElement) { |
| tocMobileElement.classList.remove("toc-loading"); |
| tocMobileElement.classList.add("toc-loaded"); |
| } |
| }, 50); |
| }); |
| }); |
| }; |
| |
| |
| if (autoCollapse) { |
| setCollapsedStateSync(0); |
| |
| showTOC(); |
| } else { |
| |
| showTOC(); |
| } |
| |
| window.addEventListener("scroll", onScroll); |
| |
| |
| const handleInitialNavigation = () => { |
| const hash = window.location.hash; |
| if (hash) { |
| const targetElement = document.querySelector(hash); |
| if (targetElement) { |
| |
| setTimeout(() => { |
| targetElement.scrollIntoView({ block: "start" }); |
| |
| setTimeout(() => { |
| updateURL(hash.substring(1), true); |
| }, 100); |
| }, 100); |
| } |
| } |
| |
| }; |
| |
| |
| |
| const { activeIdx: initialActiveIdx, activeHeadingId: initialHeadingId } = |
| findActiveHeading(); |
| if (initialHeadingId) { |
| prevActiveHeadingId = initialHeadingId; |
| } |
| prevActiveIdx = initialActiveIdx; |
| |
| |
| handleInitialNavigation(); |
| |
| |
| window.addEventListener("popstate", (event) => { |
| const hash = window.location.hash; |
| if (hash) { |
| const targetElement = document.querySelector(hash); |
| if (targetElement) { |
| targetElement.scrollIntoView({ block: "start" }); |
| } |
| } else { |
| |
| window.scrollTo({ top: 0 }); |
| } |
| }); |
| |
| |
| links.forEach((link) => { |
| link.addEventListener("click", () => { |
| hasUserScrolled = true; |
| }); |
| }); |
| |
| |
| const sidebar = document.querySelector(".toc-mobile-sidebar"); |
| const backdrop = document.querySelector(".toc-mobile-backdrop"); |
| const toggleBtn = document.querySelector(".toc-mobile-toggle"); |
| const closeBtn = document.querySelector(".toc-mobile-sidebar__close"); |
| |
| const openSidebar = () => { |
| if (!sidebar || !backdrop || !toggleBtn) return; |
| sidebar.classList.add("open"); |
| backdrop.classList.add("open"); |
| toggleBtn.setAttribute("aria-expanded", "true"); |
| document.body.style.overflow = "hidden"; |
| |
| |
| requestAnimationFrame(() => { |
| const activeLink = sidebar.querySelector("a.active"); |
| if (activeLink) { |
| const body = sidebar.querySelector(".toc-mobile-sidebar__body"); |
| if (body) { |
| const linkTop = activeLink.offsetTop - body.offsetTop; |
| body.scrollTop = Math.max(0, linkTop - body.clientHeight / 3); |
| } |
| } |
| }); |
| }; |
| const closeSidebar = () => { |
| if (!sidebar || !backdrop || !toggleBtn) return; |
| sidebar.classList.remove("open"); |
| backdrop.classList.remove("open"); |
| toggleBtn.setAttribute("aria-expanded", "false"); |
| document.body.style.overflow = ""; |
| }; |
| |
| if (toggleBtn) toggleBtn.addEventListener("click", openSidebar); |
| if (closeBtn) closeBtn.addEventListener("click", closeSidebar); |
| if (backdrop) backdrop.addEventListener("click", closeSidebar); |
| |
| |
| const sidebarThemeBtn = document.querySelector(".toc-mobile-sidebar__theme"); |
| if (sidebarThemeBtn) { |
| sidebarThemeBtn.addEventListener("click", () => { |
| const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; |
| localStorage.setItem("theme", next); |
| document.documentElement.dataset.theme = next; |
| }); |
| } |
| |
| |
| if (holderMobile) { |
| holderMobile.addEventListener("click", (ev) => { |
| const target = ev.target; |
| const anchor = |
| target && "closest" in target ? target.closest("a") : null; |
| if (anchor instanceof HTMLAnchorElement) { |
| closeSidebar(); |
| } |
| }); |
| } |
| |
| |
| document.addEventListener("keydown", (ev) => { |
| if (ev.key === "Escape" && sidebar?.classList.contains("open")) { |
| closeSidebar(); |
| } |
| }); |
| }; |
| |
| if (document.readyState === "loading") { |
| document.addEventListener("DOMContentLoaded", buildTOC, { once: true }); |
| } else { |
| buildTOC(); |
| } |
| </script> |
|
|
| <style is:global> |
| |
| .table-of-contents.toc-loading, |
| .toc-mobile-sidebar.toc-loading { |
| opacity: 0; |
| transition: opacity 0.3s ease-in-out; |
| } |
| |
| .table-of-contents.toc-loaded, |
| .toc-mobile-sidebar.toc-loaded { |
| opacity: 1; |
| } |
| |
| |
| .table-of-contents { |
| position: sticky; |
| top: 32px; |
| margin-top: 12px; |
| } |
| |
| .table-of-contents nav { |
| border-left: 1px solid var(--border-color); |
| padding-left: 16px; |
| font-size: 13px; |
| } |
| |
| .table-of-contents .title { |
| font-weight: 600; |
| font-size: 14px; |
| margin-bottom: 8px; |
| } |
| |
| |
| .table-of-contents nav ul { |
| margin: 0 0 6px; |
| padding-left: 1em; |
| } |
| |
| .table-of-contents nav li { |
| list-style: none; |
| margin: 0.25em 0; |
| } |
| |
| .table-of-contents nav a, |
| .table-of-contents nav a:link, |
| .table-of-contents nav a:visited { |
| color: var(--text-color); |
| text-decoration: none; |
| border-bottom: none; |
| } |
| |
| .table-of-contents nav > ul > li > a { |
| font-weight: 700; |
| } |
| |
| .table-of-contents nav a:hover { |
| text-decoration: underline solid var(--muted-color); |
| } |
| |
| .table-of-contents nav a.active { |
| text-decoration: underline; |
| } |
| |
| |
| |
| |
| .toc-mobile-toggle { |
| display: none; |
| position: fixed; |
| top: var(--spacing-4); |
| left: var(--spacing-4); |
| z-index: var(--z-overlay); |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| border: 1px solid var(--border-color); |
| background: var(--page-bg); |
| color: var(--text-color); |
| cursor: pointer; |
| align-items: center; |
| justify-content: center; |
| box-shadow: 0 2px 12px rgba(0,0,0,.08); |
| transition: transform 150ms ease, box-shadow 150ms ease; |
| } |
| |
| .toc-mobile-toggle:active { |
| transform: scale(0.92); |
| } |
| |
| |
| |
| |
| .toc-mobile-backdrop { |
| display: none; |
| position: fixed; |
| inset: 0; |
| z-index: calc(var(--z-overlay) + 1); |
| background: rgba(0,0,0,.4); |
| opacity: 0; |
| pointer-events: none; |
| transition: opacity 250ms ease; |
| } |
| .toc-mobile-backdrop.open { |
| opacity: 1; |
| pointer-events: auto; |
| } |
| |
| |
| |
| |
| .toc-mobile-sidebar { |
| display: none; |
| position: fixed; |
| top: 0; |
| left: 0; |
| bottom: 0; |
| z-index: calc(var(--z-overlay) + 2); |
| width: min(320px, 85vw); |
| background: var(--page-bg); |
| border-right: 1px solid var(--border-color); |
| transform: translateX(-100%); |
| transition: transform 300ms cubic-bezier(.4,0,.2,1); |
| flex-direction: column; |
| } |
| .toc-mobile-sidebar.open { |
| transform: translateX(0); |
| } |
| |
| .toc-mobile-sidebar__header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: var(--spacing-3) var(--spacing-4); |
| border-bottom: 1px solid var(--border-color); |
| flex-shrink: 0; |
| } |
| |
| .toc-mobile-sidebar__title { |
| font-weight: 700; |
| font-size: 15px; |
| color: var(--text-color); |
| } |
| |
| .toc-mobile-sidebar__actions { |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| } |
| |
| .toc-mobile-sidebar__close, |
| .toc-mobile-sidebar__theme { |
| background: none; |
| border: none; |
| color: var(--muted-color); |
| cursor: pointer; |
| padding: 6px; |
| border-radius: 6px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: color 150ms ease, background 150ms ease; |
| } |
| .toc-mobile-sidebar__close:hover, |
| .toc-mobile-sidebar__theme:hover { |
| color: var(--text-color); |
| background: var(--surface-bg); |
| } |
| |
| |
| .toc-mobile-sidebar__theme .icon.dark { |
| display: none; |
| } |
| [data-theme="dark"] .toc-mobile-sidebar__theme .icon.light { |
| display: none; |
| } |
| [data-theme="dark"] .toc-mobile-sidebar__theme .icon.dark { |
| display: inline; |
| } |
| |
| .toc-mobile-sidebar__body { |
| flex: 1; |
| overflow-y: auto; |
| -webkit-overflow-scrolling: touch; |
| padding: var(--spacing-3) var(--spacing-4) var(--spacing-6); |
| } |
| |
| |
| .toc-mobile-sidebar nav ul { |
| margin: 0 0 6px; |
| padding-left: 1em; |
| } |
| |
| .toc-mobile-sidebar nav li { |
| list-style: none; |
| margin: 0.35em 0; |
| } |
| |
| .toc-mobile-sidebar nav a, |
| .toc-mobile-sidebar nav a:link, |
| .toc-mobile-sidebar nav a:visited { |
| color: var(--text-color); |
| text-decoration: none; |
| border-bottom: none; |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| |
| .toc-mobile-sidebar nav > ul > li > a { |
| font-weight: 700; |
| } |
| |
| .toc-mobile-sidebar nav a:hover { |
| text-decoration: underline solid var(--muted-color); |
| } |
| |
| .toc-mobile-sidebar nav a.active { |
| color: var(--primary-color); |
| text-decoration: underline; |
| } |
| </style> |
|
|