| --- |
| 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> |
|
|