183 lines
4.6 KiB
Plaintext
183 lines
4.6 KiB
Plaintext
---
|
|
const { headings } = Astro.props;
|
|
|
|
// H2/H3 seulement
|
|
const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|
---
|
|
|
|
{items.length > 0 && (
|
|
<nav class="toc-local" aria-label="Dans ce chapitre">
|
|
<div class="toc-local__title">Dans ce chapitre</div>
|
|
|
|
<ol class="toc-local__list">
|
|
{items.map((h) => (
|
|
<li
|
|
class={`toc-local__item d${h.depth}`}
|
|
data-toc-item
|
|
data-depth={h.depth}
|
|
data-id={h.slug}
|
|
>
|
|
<a href={`#${h.slug}`} data-toc-link data-slug={h.slug}>
|
|
{h.text}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</nav>
|
|
)}
|
|
|
|
<script is:inline>
|
|
(() => {
|
|
function init() {
|
|
const toc = document.querySelector(".toc-local");
|
|
if (!toc) return;
|
|
|
|
const itemEls = Array.from(toc.querySelectorAll("[data-toc-item]"));
|
|
if (!itemEls.length) return;
|
|
|
|
const ordered = itemEls
|
|
.map((li) => {
|
|
const a = li.querySelector("a[data-toc-link]");
|
|
const id = li.getAttribute("data-id") || a?.dataset.slug || "";
|
|
const depth = Number(li.getAttribute("data-depth") || "0");
|
|
const el = id ? document.getElementById(id) : null; // span.details-anchor OU h3[id]
|
|
return (a && id && el) ? { id, depth, li, a, el } : null;
|
|
})
|
|
.filter(Boolean);
|
|
|
|
if (!ordered.length) return;
|
|
|
|
const clear = () => {
|
|
for (const t of ordered) {
|
|
t.a.removeAttribute("aria-current");
|
|
t.li.classList.remove("is-current");
|
|
}
|
|
};
|
|
|
|
const openDetailsIfNeeded = (el) => {
|
|
const d = el?.closest?.("details");
|
|
if (d && !d.open) d.open = true;
|
|
};
|
|
|
|
let current = "";
|
|
|
|
const setCurrent = (id) => {
|
|
if (!id || id === current) return;
|
|
const t = ordered.find((x) => x.id === id);
|
|
if (!t) return;
|
|
|
|
current = id;
|
|
|
|
clear();
|
|
openDetailsIfNeeded(t.el);
|
|
|
|
t.a.setAttribute("aria-current", "true");
|
|
t.li.classList.add("is-current");
|
|
|
|
// ✅ IMPORTANT: plus de scrollIntoView ici
|
|
// sinon ça scroll l'aside pendant le scroll du reading => TOC global “disparaît”.
|
|
};
|
|
|
|
const computeActive = () => {
|
|
const visible = ordered.filter((t) => {
|
|
const d = t.el.closest?.("details");
|
|
if (d && !d.open) {
|
|
// Si l'élément est dans <summary>, il reste visible même details fermé
|
|
const inSummary = !!t.el.closest?.("summary");
|
|
if (!inSummary) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!visible.length) return;
|
|
|
|
const y = 120;
|
|
let best = null;
|
|
|
|
for (const t of visible) {
|
|
const r = t.el.getBoundingClientRect();
|
|
if (!r || (r.width === 0 && r.height === 0)) continue;
|
|
if (r.top <= y) best = t;
|
|
else break;
|
|
}
|
|
|
|
if (!best) best = visible[0];
|
|
setCurrent(best.id);
|
|
};
|
|
|
|
let ticking = false;
|
|
const onScroll = () => {
|
|
if (ticking) return;
|
|
ticking = true;
|
|
requestAnimationFrame(() => {
|
|
ticking = false;
|
|
computeActive();
|
|
});
|
|
};
|
|
|
|
const syncFromHash = () => {
|
|
const id = (location.hash || "").slice(1);
|
|
if (!id) { computeActive(); return; }
|
|
|
|
const el = document.getElementById(id);
|
|
if (el) openDetailsIfNeeded(el);
|
|
setCurrent(id);
|
|
};
|
|
|
|
toc.addEventListener("click", (ev) => {
|
|
const a = ev.target?.closest?.("a[data-toc-link]");
|
|
if (!a) return;
|
|
const id = a.dataset.slug || "";
|
|
if (!id) return;
|
|
|
|
const el = document.getElementById(id);
|
|
if (el) openDetailsIfNeeded(el);
|
|
|
|
setCurrent(id);
|
|
});
|
|
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
window.addEventListener("resize", onScroll);
|
|
window.addEventListener("hashchange", syncFromHash);
|
|
|
|
syncFromHash();
|
|
onScroll();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
window.addEventListener("DOMContentLoaded", init, { once: true });
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<style>
|
|
.toc-local{margin-top:12px;border:1px solid rgba(127,127,127,.25);border-radius:16px;padding:12px}
|
|
.toc-local__title{font-size:13px;opacity:.85;margin-bottom:8px}
|
|
|
|
.toc-local__list{list-style:none;margin:0;padding:0}
|
|
.toc-local__item::marker{content:""}
|
|
.toc-local__item{margin:6px 0}
|
|
.toc-local__item.d3{margin-left:12px;opacity:.9}
|
|
|
|
.toc-local__item.is-current > a{
|
|
font-weight: 750;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.toc-local a{
|
|
display:inline-block;
|
|
max-width:100%;
|
|
text-decoration:none;
|
|
}
|
|
.toc-local a:hover{ text-decoration: underline; }
|
|
.toc-local__list{
|
|
max-height: 44vh;
|
|
overflow: auto;
|
|
padding-right: 8px;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
</style>
|