Seed from NAS prod snapshot 20260130-190531
This commit is contained in:
182
src/components/LocalToc.astro
Normal file
182
src/components/LocalToc.astro
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user