lerobot/robot-learning-tutorial
508
1---2export interface Props { tableOfContentAutoCollapse?: boolean }3const { tableOfContentAutoCollapse = false } = Astro.props as Props;4---5<nav class="table-of-contents" aria-label="Table of Contents" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>6 <div class="title">Table of Contents</div>7 <div id="article-toc-placeholder"></div>8</nav>9<details class="table-of-contents-mobile">10 <summary>Table of Contents</summary>11 <div id="article-toc-mobile-placeholder"></div>12</details>13 14<script is:inline>15 // Build TOC from article headings (h2/h3/h4) and render into the sticky aside16 const buildTOC = () => {17 const holder = document.getElementById('article-toc-placeholder');18 const holderMobile = document.getElementById('article-toc-mobile-placeholder');19 // Always rebuild TOC to avoid stale entries20 if (holder) holder.innerHTML = '';21 if (holderMobile) holderMobile.innerHTML = '';22 const articleRoot = document.querySelector('section.content-grid main');23 if (!articleRoot) return;24 const headings = articleRoot.querySelectorAll('h2, h3, h4');25 if (!headings.length) return;26 27 // Inclure tous les titres H2/H3/H4 sans filtrer "Table of contents"28 const headingsArr = Array.from(headings);29 if (!headingsArr.length) return;30 31 // Ensure unique ids for headings (deduplicate duplicates)32 const usedIds = new Set();33 const slugify = (s) => String(s || '')34 .toLowerCase()35 .trim()36 .replace(/\s+/g, '_')37 .replace(/[^a-z0-9_\-]/g, '');38 headingsArr.forEach((h) => {39 let id = (h.id || '').trim();40 if (!id) {41 const base = slugify(h.textContent || '');42 id = base || 'section';43 }44 let candidate = id;45 let n = 2;46 while (usedIds.has(candidate)) {47 candidate = `${id}-${n++}`;48 }49 if (h.id !== candidate) h.id = candidate;50 usedIds.add(candidate);51 });52 53 const nav = document.createElement('nav');54 let ulStack = [document.createElement('ul')];55 nav.appendChild(ulStack[0]);56 57 const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;58 let prev = 2;59 let h2Count = -1;60 const h2List = headingsArr.filter(h => h.tagName === 'H2');61 headingsArr.forEach((h) => {62 const lvl = levelOf(h.tagName);63 // adjust depth64 while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }65 while (lvl < prev) { ulStack.pop(); prev--; }66 const li = document.createElement('li');67 const a = document.createElement('a');68 a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self';69 li.appendChild(a);70 if (lvl === 2) {71 h2Count += 1;72 li.setAttribute('data-h2-idx', String(h2Count));73 }74 ulStack[ulStack.length-1].appendChild(li);75 });76 77 if (holder) holder.appendChild(nav);78 const navClone = nav.cloneNode(true);79 if (holderMobile) holderMobile.appendChild(navClone);80 81 // active link on scroll82 const links = [83 ...(holder ? holder.querySelectorAll('a') : []),84 ...(holderMobile ? holderMobile.querySelectorAll('a') : [])85 ];86 // Read breakpoint from CSS var and set autoCollapse only on desktop (disabled on mobile)87 const getCollapsePx = () => {88 const root = document.documentElement;89 const raw = getComputedStyle(root).getPropertyValue('--bp-content-collapse').trim();90 return raw || '1100px';91 };92 const mq = window.matchMedia(`(max-width: ${getCollapsePx()})`);93 const attrEnabled = (document.querySelector('.table-of-contents')?.getAttribute('data-auto-collapse') === '1');94 let autoCollapse = attrEnabled && !mq.matches;95 96 // Inject styles for collapsible & animation97 const ensureStyles = () => {98 if (document.getElementById('toc-collapse-style')) return;99 const style = document.createElement('style');100 style.id = 'toc-collapse-style';101 style.textContent = `102 .table-of-contents nav.table-of-contents-collapsible > ul > li > ul,103 details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }104 .table-of-contents nav.table-of-contents-collapsible > ul > li.collapsed > ul,105 details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li.collapsed > ul { display: block; }106 `;107 document.head.appendChild(style);108 };109 ensureStyles();110 111 const getTopLevelItems = () => {112 const sideNav = holder ? holder.querySelector('nav') : null;113 const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;114 const q = (navEl) => navEl ? Array.from(navEl.querySelectorAll(':scope > ul > li[data-h2-idx]')) : [];115 return { sideNav, mobileNav, sideTop: q(sideNav), mobileTop: q(mobileNav) };116 };117 118 const setNavCollapsible = () => {119 const sideNav = holder ? holder.querySelector('nav') : null;120 const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;121 if (sideNav) sideNav.classList.add('table-of-contents-collapsible');122 if (mobileNav) mobileNav.classList.add('table-of-contents-collapsible');123 };124 125 const measure = (el) => {126 if (!el) return 0;127 // Temporarily set height to auto to measure scrollHeight reliably128 const prev = el.style.height;129 el.style.height = 'auto';130 const h = el.scrollHeight;131 el.style.height = prev || '';132 return h;133 };134 135 const animateTo = (el, target) => {136 if (!el) return;137 const current = parseFloat(getComputedStyle(el).height) || 0;138 if (Math.abs(current - target) < 1) {139 el.style.height = target ? 'auto' : '0px';140 return;141 }142 el.style.height = current + 'px';143 // Force reflow144 void el.offsetHeight;145 el.style.height = target + 'px';146 const onEnd = (e) => {147 if (e.propertyName !== 'height') return;148 el.removeEventListener('transitionend', onEnd);149 if (target > 0) el.style.height = 'auto';150 };151 el.addEventListener('transitionend', onEnd);152 };153 154 let prevActiveIdx = -1;155 const setCollapsedState = (activeIdx) => {156 if (!autoCollapse) return;157 if (activeIdx == null || activeIdx < 0) activeIdx = 0;158 const { sideTop, mobileTop } = getTopLevelItems();159 const update = (items) => items.forEach((li) => {160 const idx = Number(li.getAttribute('data-h2-idx') || '-1');161 const sub = li.querySelector(':scope > ul');162 if (!sub) return;163 if (idx === activeIdx) {164 li.classList.remove('collapsed');165 const target = measure(sub);166 animateTo(sub, target);167 } else {168 li.classList.add('collapsed');169 animateTo(sub, 0);170 }171 });172 update(sideTop);173 update(mobileTop);174 setNavCollapsible();175 prevActiveIdx = activeIdx;176 };177 178 // When switching between desktop/mobile, refresh autoCollapse and expand all on mobile179 const expandAll = () => {180 const { sideTop, mobileTop } = getTopLevelItems();181 const expand = (items) => items.forEach((li) => {182 li.classList.remove('collapsed');183 const sub = li.querySelector(':scope > ul');184 if (sub) sub.style.height = 'auto';185 });186 expand(sideTop);187 expand(mobileTop);188 };189 190 const onMqChange = () => {191 autoCollapse = attrEnabled && !mq.matches;192 if (!autoCollapse) {193 expandAll();194 } else {195 setCollapsedState(prevActiveIdx);196 }197 };198 if (mq.addEventListener) mq.addEventListener('change', onMqChange);199 else if (mq.addListener) mq.addListener(onMqChange);200 201 const onScroll = () => {202 // active link highlight203 let activeIdx = -1;204 for (let i = headingsArr.length - 1; i >= 0; i--) {205 const top = headingsArr[i].getBoundingClientRect().top;206 if (top - 60 <= 0) {207 links.forEach(l => l.classList.remove('active'));208 const id = '#' + headingsArr[i].id;209 const actives = Array.from(links).filter(l => l.getAttribute('href') === id);210 actives.forEach(a => a.classList.add('active'));211 if (headingsArr[i].tagName === 'H2') {212 activeIdx = h2List.indexOf(headingsArr[i]);213 } else {214 for (let j = i; j >= 0; j--) {215 if (headingsArr[j].tagName === 'H2') { activeIdx = h2List.indexOf(headingsArr[j]); break; }216 }217 }218 break;219 }220 }221 if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);222 };223 224 // If auto-collapse, collapse immediately (expand first section) before any scroll225 if (autoCollapse) setCollapsedState(0);226 227 window.addEventListener('scroll', onScroll);228 // Initialize state229 onScroll();230 231 // Close mobile accordion when a link inside it is clicked232 if (holderMobile) {233 const details = holderMobile.closest('details');234 holderMobile.addEventListener('click', (ev) => {235 const target = ev.target;236 const anchor = target && 'closest' in target ? target.closest('a') : null;237 if (anchor instanceof HTMLAnchorElement && details && details.open) {238 details.open = false;239 }240 });241 }242 };243 244 if (document.readyState === 'loading') {245 document.addEventListener('DOMContentLoaded', buildTOC, { once: true });246 } else { buildTOC(); }247</script>248 249<style is:global>250 /* Sticky aside */251 .table-of-contents {252 position: sticky;253 top: 32px;254 margin-top: 12px;255 }256 257 .table-of-contents nav {258 border-left: 1px solid var(--border-color);259 padding-left: 16px;260 font-size: 13px;261 }262 263 .table-of-contents .title {264 font-weight: 600;265 font-size: 14px;266 margin-bottom: 8px;267 }268 269 /* Look & feel */270 .table-of-contents nav ul {271 margin: 0 0 6px;272 padding-left: 1em;273 }274 275 .table-of-contents nav li {276 list-style: none;277 margin: .25em 0;278 }279 280 .table-of-contents nav a,281 .table-of-contents nav a:link,282 .table-of-contents nav a:visited {283 color: var(--text-color);284 text-decoration: none;285 border-bottom: none;286 }287 288 .table-of-contents nav > ul > li > a {289 font-weight: 700;290 }291 292 .table-of-contents nav a:hover {293 text-decoration: underline solid var(--muted-color);294 }295 296 .table-of-contents nav a.active {297 text-decoration: underline;298 }299 300 /* Mobile accordion */301 .table-of-contents-mobile {302 display: none;303 margin: 8px 0 16px;304 }305 306 .table-of-contents-mobile > summary {307 cursor: pointer;308 list-style: none;309 padding: var(--spacing-3) var(--spacing-4);310 border: 1px solid var(--border-color);311 border-radius: 8px;312 color: var(--text-color);313 font-weight: 600;314 position: relative;315 }316 317 .table-of-contents-mobile[open] > summary {318 border-bottom-left-radius: 0;319 border-bottom-right-radius: 0;320 }321 322 /* Disclosure arrow for mobile summary */323 .table-of-contents-mobile > summary::after {324 content: '';325 position: absolute;326 right: var(--spacing-4);327 top: 50%;328 width: 8px;329 height: 8px;330 border-right: 2px solid currentColor;331 border-bottom: 2px solid currentColor;332 transform: translateY(-70%) rotate(45deg);333 transition: transform 150ms ease;334 opacity: .7;335 }336 337 .table-of-contents-mobile[open] > summary::after {338 transform: translateY(-30%) rotate(-135deg);339 }340 341 .table-of-contents-mobile nav {342 border-left: none;343 padding: 10px 12px;344 font-size: 14px;345 border: 1px solid var(--border-color);346 border-top: none;347 border-bottom-left-radius: 8px;348 border-bottom-right-radius: 8px;349 }350 351 .table-of-contents-mobile nav ul {352 margin: 0 0 6px;353 padding-left: 1em;354 }355 356 .table-of-contents-mobile nav li {357 list-style: none;358 margin: .25em 0;359 }360 361 .table-of-contents-mobile nav a,362 .table-of-contents-mobile nav a:link,363 .table-of-contents-mobile nav a:visited {364 color: var(--text-color);365 text-decoration: none;366 border-bottom: none;367 }368 369 .table-of-contents-mobile nav > ul > li > a {370 font-weight: 700;371 }372 373 .table-of-contents-mobile nav a:hover {374 text-decoration: underline solid var(--muted-color);375 }376 377 .table-of-contents-mobile nav a.active {378 text-decoration: underline;379 }380 381 382 383</style>384 