thibaud frere
update
c1d1666
raw
history blame
8.57 kB
---
export interface Props { tableOfContentAutoCollapse?: boolean }
const { tableOfContentAutoCollapse = false } = Astro.props as Props;
---
<aside class="toc" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
<div class="title">Table of Contents</div>
<div id="article-toc-placeholder"></div>
</aside>
<details class="toc-mobile">
<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;
// Filter out headings that should not appear in TOC
const normalize = (s) => String(s || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
const isTocLabel = (s) => /^(table\s+of\s+contents?)$|^toc$/i.test(String(s || '').replace(/[^a-zA-Z0-9]+/g, ' ').trim());
const shouldSkip = (h) => {
const t = h.textContent || '';
const id = String(h.id || '');
const slug = normalize(t).replace(/\s+/g, '_');
if (isTocLabel(t)) return true;
if (isTocLabel(id.replace(/[_-]+/g, ' '))) return true;
if (isTocLabel(slug.replace(/[_-]+/g, ' '))) return true;
return false;
};
const headingsArr = Array.from(headings).filter(h => !shouldSkip(h));
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 h2Count = -1;
const h2List = headingsArr.filter(h => h.tagName === 'H2');
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);
if (lvl === 2) {
h2Count += 1;
li.setAttribute('data-h2-idx', String(h2Count));
}
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') : [])
];
const autoCollapse = (document.querySelector('aside.toc')?.getAttribute('data-auto-collapse') === '1');
// Inject styles for collapsible & animation
const ensureStyles = () => {
if (document.getElementById('toc-collapse-style')) return;
const style = document.createElement('style');
style.id = 'toc-collapse-style';
style.textContent = `
aside.toc nav.toc-collapsible > ul > li > ul,
details.toc-mobile nav.toc-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }
aside.toc nav.toc-collapsible > ul > li.collapsed > ul,
details.toc-mobile nav.toc-collapsible > ul > li.collapsed > ul { display: block; }
`;
document.head.appendChild(style);
};
ensureStyles();
const getTopLevelItems = () => {
const sideNav = holder ? holder.querySelector('nav') : null;
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
const q = (navEl) => navEl ? Array.from(navEl.querySelectorAll(':scope > ul > li[data-h2-idx]')) : [];
return { sideNav, mobileNav, sideTop: q(sideNav), mobileTop: q(mobileNav) };
};
const setNavCollapsible = () => {
const sideNav = holder ? holder.querySelector('nav') : null;
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
if (sideNav) sideNav.classList.add('toc-collapsible');
if (mobileNav) mobileNav.classList.add('toc-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';
const h = el.scrollHeight;
el.style.height = prev || '';
return h;
};
const animateTo = (el, target) => {
if (!el) return;
const current = parseFloat(getComputedStyle(el).height) || 0;
if (Math.abs(current - target) < 1) {
el.style.height = target ? 'auto' : '0px';
return;
}
el.style.height = current + 'px';
// Force reflow
void el.offsetHeight;
el.style.height = target + 'px';
const onEnd = (e) => {
if (e.propertyName !== 'height') return;
el.removeEventListener('transitionend', onEnd);
if (target > 0) el.style.height = 'auto';
};
el.addEventListener('transitionend', onEnd);
};
let prevActiveIdx = -1;
const setCollapsedState = (activeIdx) => {
if (!autoCollapse) return;
if (activeIdx == null || activeIdx < 0) activeIdx = 0;
const { sideTop, mobileTop } = getTopLevelItems();
const update = (items) => items.forEach((li) => {
const idx = Number(li.getAttribute('data-h2-idx') || '-1');
const sub = li.querySelector(':scope > ul');
if (!sub) return;
if (idx === activeIdx) {
li.classList.remove('collapsed');
const target = measure(sub);
animateTo(sub, target);
} else {
li.classList.add('collapsed');
animateTo(sub, 0);
}
});
update(sideTop);
update(mobileTop);
setNavCollapsible();
prevActiveIdx = activeIdx;
};
const onScroll = () => {
// active link highlight
let activeIdx = -1;
for (let i = headingsArr.length - 1; i >= 0; i--) {
const top = headingsArr[i].getBoundingClientRect().top;
if (top - 60 <= 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'));
if (headingsArr[i].tagName === 'H2') {
activeIdx = h2List.indexOf(headingsArr[i]);
} else {
for (let j = i; j >= 0; j--) {
if (headingsArr[j].tagName === 'H2') { activeIdx = h2List.indexOf(headingsArr[j]); break; }
}
}
break;
}
}
if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);
};
window.addEventListener('scroll', onScroll);
// Initialize state
onScroll();
// 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>