| --- | |
| 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> | |
| <details class="table-of-contents-mobile toc-loading"> | |
| <summary>Table of Contents</summary> | |
| <div id="article-toc-mobile-placeholder"></div> | |
| </details> | |
| <script is:inline> | |
| // Build TOC from article headings (h2/h3/h4) and render into the sticky aside | |
| const buildTOC = () => { | |
| const holder = document.getElementById("article-toc-placeholder"); | |
| const holderMobile = document.getElementById( | |
| "article-toc-mobile-placeholder", | |
| ); | |
| // Always rebuild TOC to avoid stale entries | |
| 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; | |
| // Inclure tous les titres H2/H3/H4 sans filtrer "Table of contents" | |
| const headingsArr = Array.from(headings); | |
| if (!headingsArr.length) return; | |
| // Ensure unique ids for headings (deduplicate duplicates) | |
| 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); | |
| // adjust depth | |
| 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); | |
| // Ajouter un index unique à chaque heading pour le tracking | |
| 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); | |
| // active link on scroll | |
| const links = [ | |
| ...(holder ? holder.querySelectorAll("a") : []), | |
| ...(holderMobile ? holderMobile.querySelectorAll("a") : []), | |
| ]; | |
| // Read breakpoint from CSS var and set autoCollapse only on desktop (disabled on mobile) | |
| 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; | |
| // Inject styles for collapsible & animation (tous les niveaux) | |
| 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, | |
| details.table-of-contents-mobile nav.table-of-contents-collapsible li > ul { overflow: hidden; transition: height 200ms ease; } | |
| .table-of-contents nav.table-of-contents-collapsible li.collapsed > ul, | |
| details.table-of-contents-mobile 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; | |
| // Temporarily set height to auto to measure scrollHeight reliably | |
| const prev = el.style.height; | |
| el.style.height = "auto"; | |
| // Force un reflow pour que le navigateur calcule les wraps de texte | |
| void el.offsetHeight; | |
| // Maintenant scrollHeight inclut la vraie hauteur avec tous les line wraps | |
| const h = el.scrollHeight; | |
| el.style.height = prev || ""; | |
| return h; | |
| }; | |
| // Tracker les animations en cours pour pouvoir les annuler | |
| const activeAnimations = new Map(); | |
| const cancelAnimation = (el) => { | |
| if (!el) return; | |
| const animData = activeAnimations.get(el); | |
| if (animData) { | |
| // Nettoyer le listener de l'animation précédente | |
| el.removeEventListener("transitionend", animData.onEnd); | |
| activeAnimations.delete(el); | |
| } | |
| }; | |
| const animateTo = (el, target) => { | |
| if (!el) return; | |
| // Annuler toute animation en cours sur cet élément | |
| cancelAnimation(el); | |
| // Obtenir la hauteur ACTUELLE (même si une animation est en cours) | |
| const current = parseFloat(getComputedStyle(el).height) || 0; | |
| // Si on est déjà proche de la cible, pas besoin d'animer | |
| if (Math.abs(current - target) < 1) { | |
| el.style.height = target ? "auto" : "0px"; | |
| return; | |
| } | |
| // Démarrer depuis la hauteur actuelle | |
| el.style.height = current + "px"; | |
| // Force reflow | |
| void el.offsetHeight; | |
| // Aller vers la cible | |
| el.style.height = target + "px"; | |
| // Créer le listener de fin | |
| const onEnd = (e) => { | |
| if (e.propertyName !== "height") return; | |
| el.removeEventListener("transitionend", onEnd); | |
| activeAnimations.delete(el); | |
| if (target > 0) el.style.height = "auto"; | |
| }; | |
| // Sauvegarder le listener pour pouvoir l'annuler plus tard | |
| 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(); | |
| // Trouver l'élément <li> correspondant au heading actif et tous ses ancêtres | |
| const getActiveAndAncestors = (items, targetIdx) => { | |
| const toExpand = new Set(); | |
| // Trouver le <li> qui correspond au targetIdx | |
| 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; | |
| // Collecter l'élément actif lui-même | |
| const activeIdx = Number( | |
| activeLi.getAttribute("data-heading-idx") || "-1", | |
| ); | |
| toExpand.add(activeIdx); | |
| // Remonter et collecter TOUS les ancêtres, sans condition | |
| // La structure de la TOC détermine automatiquement qui doit être ouvert | |
| 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); | |
| // Étape 0 : Annuler TOUTES les animations en cours avant de commencer | |
| // Cela évite les conflits si l'utilisateur scroll rapidement | |
| items.forEach((li) => { | |
| const sub = li.querySelector(":scope > ul"); | |
| if (sub) cancelAnimation(sub); | |
| }); | |
| // Étape 1 : Identifier TOUS les éléments qui vont changer d'état | |
| const allChanges = []; | |
| items.forEach((li) => { | |
| const sub = li.querySelector(":scope > ul"); | |
| if (!sub) return; | |
| const idx = Number(li.getAttribute("data-heading-idx") || "-1"); | |
| // Un élément doit être expanded SI il contient (directement ou indirectement) le heading actif | |
| // Donc soit il est dans newActiveAncestors, soit un de ses descendants l'est | |
| let shouldBeExpanded = false; | |
| // Vérifier si cet élément ou un de ses descendants est dans le chemin actif | |
| const allDescendants = li.querySelectorAll("li[data-heading-idx]"); | |
| const allRelatedIndices = [ | |
| idx, | |
| ...Array.from(allDescendants).map((d) => | |
| Number(d.getAttribute("data-heading-idx") || "-1"), | |
| ), | |
| ]; | |
| // Si au moins un de ces indices est dans newActiveAncestors, garder ouvert | |
| 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 }); | |
| } | |
| }); | |
| // Étape 2 : Parmi tous les changements, trouver ceux qui sont des "top-level" | |
| // (= n'ont PAS d'ancêtre qui change aussi) | |
| const topLevelChanges = []; | |
| const descendantChanges = []; | |
| allChanges.forEach((change) => { | |
| let hasAncestorChanging = false; | |
| // Remonter l'arbre pour voir si un ancêtre change aussi | |
| 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", | |
| ); | |
| // Vérifier si ce parent est dans la liste des changements | |
| const parentIsChanging = allChanges.some( | |
| (c) => c.idx === parentIdx, | |
| ); | |
| if (parentIsChanging) { | |
| hasAncestorChanging = true; | |
| break; | |
| } | |
| currentLi = parentLi; | |
| } | |
| if (hasAncestorChanging) { | |
| descendantChanges.push(change); | |
| } else { | |
| topLevelChanges.push(change); | |
| } | |
| }); | |
| // Étape 3 : Appliquer TOUS les descendants instantanément (sans animation) | |
| // Ceci doit être fait AVANT toute animation pour que les hauteurs soient correctes | |
| 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"; | |
| } | |
| // Forcer un reflow immédiat pour cet élément | |
| void sub.offsetHeight; | |
| sub.style.transition = oldTransition || ""; | |
| }); | |
| // Forcer un reflow global pour que TOUS les changements soient appliqués | |
| void document.body.offsetHeight; | |
| // IMPORTANT : Attendre un frame pour que le navigateur ait fini tous les calculs | |
| // avant de mesurer les hauteurs des parents | |
| } | |
| // Étape 4 : Animer SEULEMENT les top-level avec requestAnimationFrame | |
| // Les descendants sont déjà dans leur état final, donc la hauteur du parent sera correcte | |
| if (topLevelChanges.length > 0) { | |
| // Double requestAnimationFrame pour être sûr que le DOM est stabilisé | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| topLevelChanges.forEach(({ li, sub, shouldBeExpanded }) => { | |
| if (shouldBeExpanded) { | |
| li.classList.remove("collapsed"); | |
| // CRITIQUE : Avant de mesurer, mettre ABSOLUMENT TOUS les sous-éléments | |
| // dans leur état final (expanded OU collapsed) de manière synchrone | |
| const allInnerItems = sub.querySelectorAll( | |
| "li[data-heading-idx]", | |
| ); | |
| // D'abord, désactiver toutes les transitions | |
| allInnerItems.forEach((innerLi) => { | |
| const innerSub = innerLi.querySelector(":scope > ul"); | |
| if (innerSub) { | |
| innerSub.style.transition = "none"; | |
| } | |
| }); | |
| // Ensuite, mettre chaque élément dans son état final | |
| allInnerItems.forEach((innerLi) => { | |
| const innerIdx = Number( | |
| innerLi.getAttribute("data-heading-idx") || "-1", | |
| ); | |
| const innerSub = innerLi.querySelector(":scope > ul"); | |
| if (innerSub) { | |
| if (newActiveAncestors.has(innerIdx)) { | |
| // Cet élément devrait être expanded | |
| innerLi.classList.remove("collapsed"); | |
| innerSub.style.height = "auto"; | |
| } else { | |
| // Cet élément devrait être collapsed | |
| innerLi.classList.add("collapsed"); | |
| innerSub.style.height = "0px"; | |
| } | |
| } | |
| }); | |
| // Forcer un reflow global pour que TOUT soit calculé | |
| void sub.offsetHeight; | |
| // Réactiver les transitions | |
| allInnerItems.forEach((innerLi) => { | |
| const innerSub = innerLi.querySelector(":scope > ul"); | |
| if (innerSub) { | |
| innerSub.style.transition = ""; | |
| } | |
| }); | |
| // Maintenant on peut mesurer avec confiance : tous les éléments | |
| // sont dans leur état final définitif | |
| const target = measure(sub); | |
| animateTo(sub, target); | |
| } else { | |
| li.classList.add("collapsed"); | |
| animateTo(sub, 0); | |
| } | |
| }); | |
| }); | |
| }); | |
| } | |
| prevActiveElements = newActiveAncestors; | |
| }; | |
| update(sideItems); | |
| update(mobileItems); | |
| setNavCollapsible(); | |
| prevActiveIdx = activeIdx; | |
| }; | |
| // When switching between desktop/mobile, refresh autoCollapse and expand all on mobile | |
| 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); | |
| // Constantes de configuration | |
| const SCROLL_OFFSET_PX = 60; // Offset pour détecter quand un heading est "actif" (position sticky) | |
| const SCROLL_THROTTLE_MS = 50; // Throttle le scroll à max 20 fois par seconde | |
| const URL_DEBOUNCE_MS = 300; // Debounce la mise à jour d'URL à 300ms | |
| const COLLAPSE_DEBOUNCE_MS = 100; // Debounce pour le collapse state | |
| const ANIMATION_DURATION_MS = 250; // Durée des animations de collapse | |
| // Debounce pour traiter la dernière mise à jour après que le scroll se stabilise | |
| let scrollDebounceTimer = null; | |
| let lastRequestedIdx = -1; | |
| let isProcessing = false; | |
| let hasUserScrolled = false; // Flag pour savoir si l'utilisateur a vraiment scrollé | |
| let urlUpdateTimer = null; // Timer pour debounce la mise à jour de l'URL | |
| let lastScrollTime = 0; // Pour le throttling | |
| // Fonction pour mettre à jour l'URL avec l'ancre actuelle (avec debounce) | |
| const updateURL = (headingId, force = false) => { | |
| if (!headingId) return; | |
| // Ne pas mettre à jour l'URL si l'utilisateur n'a pas scrollé et que ce n'est pas forcé | |
| if (!force && !hasUserScrolled) return; | |
| // Debounce la mise à jour d'URL pour éviter trop d'appels à history.pushState | |
| clearTimeout(urlUpdateTimer); | |
| urlUpdateTimer = setTimeout( | |
| () => { | |
| const newUrl = `${window.location.pathname}${window.location.search}#${headingId}`; | |
| // Mettre à jour l'URL sans recharger la page | |
| if (window.location.href !== newUrl) { | |
| history.pushState(null, null, newUrl); | |
| // Communiquer avec la fenêtre parente (format officiel Hugging Face) | |
| if (window.parent !== window) { | |
| try { | |
| window.parent.postMessage( | |
| { | |
| queryString: "", | |
| hash: headingId, | |
| }, | |
| "https://huggingface.co", | |
| ); | |
| } catch (e) { | |
| // Ignorer les erreurs silencieusement | |
| } | |
| } | |
| } | |
| }, | |
| force ? 0 : URL_DEBOUNCE_MS, | |
| ); // Pas de debounce si forcé (navigation initiale) | |
| }; | |
| // Fonction utilitaire pour trouver le heading actif selon la position du scroll | |
| 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 = () => { | |
| // Throttling : ne traiter le scroll que toutes les SCROLL_THROTTLE_MS ms | |
| const now = performance.now(); | |
| if (now - lastScrollTime < SCROLL_THROTTLE_MS) { | |
| return; | |
| } | |
| lastScrollTime = now; | |
| // Marquer que l'utilisateur a scrollé | |
| hasUserScrolled = true; | |
| // Optimisation : utiliser requestAnimationFrame pour batch les getBoundingClientRect | |
| // et éviter les reflows multiples | |
| requestAnimationFrame(() => { | |
| const { activeIdx, activeHeadingId } = findActiveHeading(); | |
| // Mettre à jour l'URL si la section active a changé (avec debounce intégré) | |
| if (activeHeadingId && activeHeadingId !== prevActiveHeadingId) { | |
| updateURL(activeHeadingId); | |
| prevActiveHeadingId = activeHeadingId; | |
| } | |
| if (activeIdx === prevActiveIdx) return; | |
| // Sauvegarder la dernière demande | |
| lastRequestedIdx = activeIdx; | |
| // Si on est en train de traiter, ne rien faire (on traitera la dernière demande après) | |
| if (isProcessing) return; | |
| // Debounce : attendre un peu que le scroll se stabilise | |
| clearTimeout(scrollDebounceTimer); | |
| scrollDebounceTimer = setTimeout(() => { | |
| // Traiter la dernière demande | |
| if (lastRequestedIdx !== prevActiveIdx) { | |
| isProcessing = true; | |
| setCollapsedState(lastRequestedIdx); | |
| // Le processing flag sera réinitialisé après les animations | |
| setTimeout(() => { | |
| isProcessing = false; | |
| // Si une nouvelle demande est arrivée pendant qu'on traitait, la traiter maintenant | |
| if (lastRequestedIdx !== prevActiveIdx) { | |
| onScroll(); | |
| } | |
| }, ANIMATION_DURATION_MS); // Attendre que les animations soient lancées | |
| } | |
| }, COLLAPSE_DEBOUNCE_MS); | |
| }); | |
| }; | |
| // Version d'initialisation synchrone de setCollapsedState (sans animations) | |
| 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); // activeIdx du scope parent | |
| // Désactiver toutes les transitions temporairement | |
| items.forEach((li) => { | |
| const sub = li.querySelector(":scope > ul"); | |
| if (sub) { | |
| sub.style.transition = "none"; | |
| } | |
| }); | |
| // Appliquer l'état sans animation | |
| 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"; | |
| } | |
| }); | |
| // Forcer un reflow | |
| void document.body.offsetHeight; | |
| // Réactiver les transitions après un court délai | |
| requestAnimationFrame(() => { | |
| items.forEach((li) => { | |
| const sub = li.querySelector(":scope > ul"); | |
| if (sub) { | |
| sub.style.transition = ""; | |
| } | |
| }); | |
| }); | |
| }; | |
| applyStateSync(sideItems); | |
| applyStateSync(mobileItems); | |
| setNavCollapsible(); | |
| prevActiveIdx = activeIdx; | |
| }; | |
| // Fonction pour déclencher le fade-in une fois que tout est prêt | |
| const showTOC = () => { | |
| // Utiliser plusieurs RAF pour s'assurer que le DOM est complètement rendu | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| // Petit délai supplémentaire pour être sûr que tout est stabilisé | |
| setTimeout(() => { | |
| const tocElement = document.querySelector(".table-of-contents"); | |
| const tocMobileElement = document.querySelector( | |
| ".table-of-contents-mobile", | |
| ); | |
| 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); // Petit délai pour s'assurer que tout est rendu | |
| }); | |
| }); | |
| }; | |
| // If auto-collapse, appliquer l'état de manière synchrone (sans animations) | |
| if (autoCollapse) { | |
| setCollapsedStateSync(0); | |
| // Attendre que tout soit rendu avant d'afficher | |
| showTOC(); | |
| } else { | |
| // Si pas d'auto-collapse, afficher immédiatement | |
| showTOC(); | |
| } | |
| window.addEventListener("scroll", onScroll); | |
| // Gérer la navigation par ancres au chargement de la page | |
| const handleInitialNavigation = () => { | |
| const hash = window.location.hash; | |
| if (hash) { | |
| const targetElement = document.querySelector(hash); | |
| if (targetElement) { | |
| // Attendre que le DOM soit prêt puis faire défiler vers l'élément | |
| setTimeout(() => { | |
| targetElement.scrollIntoView({ block: "start" }); | |
| // Mettre à jour l'URL après le scroll (forcé car c'est une navigation initiale avec ancre) | |
| setTimeout(() => { | |
| updateURL(hash.substring(1), true); // Enlever le # du hash, forcer la mise à jour | |
| }, 100); | |
| }, 100); | |
| } | |
| } | |
| // Ne plus mettre à jour l'URL automatiquement si pas d'ancre | |
| }; | |
| // Initialize state (sans mettre à jour l'URL) | |
| // Ne pas marquer hasUserScrolled ici pour éviter les mises à jour d'URL au chargement | |
| const { activeIdx: initialActiveIdx, activeHeadingId: initialHeadingId } = | |
| findActiveHeading(); | |
| if (initialHeadingId) { | |
| prevActiveHeadingId = initialHeadingId; | |
| } | |
| prevActiveIdx = initialActiveIdx; | |
| // Gérer la navigation initiale | |
| handleInitialNavigation(); | |
| // Gérer les événements de navigation du navigateur (boutons précédent/suivant) | |
| window.addEventListener("popstate", (event) => { | |
| const hash = window.location.hash; | |
| if (hash) { | |
| const targetElement = document.querySelector(hash); | |
| if (targetElement) { | |
| targetElement.scrollIntoView({ block: "start" }); | |
| } | |
| } else { | |
| // Si pas d'ancre, aller au début de la page | |
| window.scrollTo({ top: 0 }); | |
| } | |
| }); | |
| // Marquer qu'un scroll a eu lieu quand l'utilisateur clique sur un lien du TOC | |
| links.forEach((link) => { | |
| link.addEventListener("click", () => { | |
| hasUserScrolled = true; | |
| }); | |
| }); | |
| // Close mobile accordion when a link inside it is clicked | |
| if (holderMobile) { | |
| const details = holderMobile.closest("details"); | |
| holderMobile.addEventListener("click", (ev) => { | |
| const target = ev.target; | |
| const anchor = | |
| target && "closest" in target ? target.closest("a") : null; | |
| if (anchor instanceof HTMLAnchorElement && details && details.open) { | |
| details.open = false; | |
| } | |
| }); | |
| } | |
| }; | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", buildTOC, { once: true }); | |
| } else { | |
| buildTOC(); | |
| } | |
| </script> | |
| <style is:global> | |
| /* Fade-in animation pour le chargement du TOC */ | |
| .table-of-contents.toc-loading, | |
| .table-of-contents-mobile.toc-loading { | |
| opacity: 0; | |
| transition: opacity 0.3s ease-in-out; | |
| } | |
| .table-of-contents.toc-loaded, | |
| .table-of-contents-mobile.toc-loaded { | |
| opacity: 1; | |
| } | |
| /* Sticky aside */ | |
| .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; | |
| } | |
| /* Look & feel */ | |
| .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; | |
| } | |
| /* Mobile accordion */ | |
| .table-of-contents-mobile { | |
| display: none; | |
| margin: 8px 0 16px; | |
| } | |
| .table-of-contents-mobile > summary { | |
| cursor: pointer; | |
| list-style: none; | |
| padding: var(--spacing-3) var(--spacing-4); | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| color: var(--text-color); | |
| font-weight: 600; | |
| position: relative; | |
| } | |
| .table-of-contents-mobile[open] > summary { | |
| border-bottom-left-radius: 0; | |
| border-bottom-right-radius: 0; | |
| } | |
| /* Disclosure arrow for mobile summary */ | |
| .table-of-contents-mobile > summary::after { | |
| content: ""; | |
| position: absolute; | |
| right: var(--spacing-4); | |
| top: 50%; | |
| width: 8px; | |
| height: 8px; | |
| border-right: 2px solid currentColor; | |
| border-bottom: 2px solid currentColor; | |
| transform: translateY(-70%) rotate(45deg); | |
| transition: transform 150ms ease; | |
| opacity: 0.7; | |
| } | |
| .table-of-contents-mobile[open] > summary::after { | |
| transform: translateY(-30%) rotate(-135deg); | |
| } | |
| .table-of-contents-mobile nav { | |
| border-left: none; | |
| padding: 10px 12px; | |
| font-size: 14px; | |
| border: 1px solid var(--border-color); | |
| border-top: none; | |
| border-bottom-left-radius: 8px; | |
| border-bottom-right-radius: 8px; | |
| } | |
| .table-of-contents-mobile nav ul { | |
| margin: 0 0 6px; | |
| padding-left: 1em; | |
| } | |
| .table-of-contents-mobile nav li { | |
| list-style: none; | |
| margin: 0.25em 0; | |
| } | |
| .table-of-contents-mobile nav a, | |
| .table-of-contents-mobile nav a:link, | |
| .table-of-contents-mobile nav a:visited { | |
| color: var(--text-color); | |
| text-decoration: none; | |
| border-bottom: none; | |
| } | |
| .table-of-contents-mobile nav > ul > li > a { | |
| font-weight: 700; | |
| } | |
| .table-of-contents-mobile nav a:hover { | |
| text-decoration: underline solid var(--muted-color); | |
| } | |
| .table-of-contents-mobile nav a.active { | |
| text-decoration: underline; | |
| } | |
| </style> | |