transformers / app /src /components /TableOfContents.astro
tfrere's picture
tfrere HF Staff
Clean repository - remove missing LFS files
6afedde
raw
history blame
33.2 kB
---
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>