/* collapse.js — Collapsible h2/h3 sections in essay body. Self-guards via #markdownBody check; no-ops on non-essay pages. Persists collapsed state per heading in localStorage. Retriggered sidenote positioning after each transition via window resize. */ (function () { 'use strict'; var PREFIX = 'section-collapsed:'; document.addEventListener('DOMContentLoaded', function () { var body = document.getElementById('markdownBody'); if (!body) return; if (body.hasAttribute('data-no-collapse')) return; var headings = Array.from(body.querySelectorAll('h2[id], h3[id]')); if (!headings.length) return; headings.forEach(function (heading) { var level = parseInt(heading.tagName[1], 10); var content = []; var node = heading.nextElementSibling; // Collect sibling elements until the next same-or-higher heading. while (node) { if (/^H[1-6]$/.test(node.tagName) && parseInt(node.tagName[1], 10) <= level) break; content.push(node); node = node.nextElementSibling; } if (!content.length) return; // Wrap collected nodes in a .section-body div. var wrapper = document.createElement('div'); wrapper.className = 'section-body'; wrapper.id = 'section-body-' + heading.id; heading.parentNode.insertBefore(wrapper, content[0]); content.forEach(function (el) { wrapper.appendChild(el); }); // Inject toggle button into the heading. var btn = document.createElement('button'); btn.className = 'section-toggle'; btn.setAttribute('aria-label', 'Toggle section'); btn.setAttribute('aria-controls', wrapper.id); heading.appendChild(btn); // Restore persisted state without transition flash. var key = PREFIX + heading.id; var collapsed = localStorage.getItem(key) === '1'; function setCollapsed(c, animate) { if (!animate) wrapper.style.transition = 'none'; if (c) { wrapper.style.maxHeight = '0'; wrapper.classList.add('is-collapsed'); btn.setAttribute('aria-expanded', 'false'); } else { // Animate: transition 0 → scrollHeight, then release to 'none' // in transitionend so late-rendering content (e.g. KaTeX) is // never clipped. No animation: go straight to 'none'. wrapper.style.maxHeight = animate ? wrapper.scrollHeight + 'px' : 'none'; wrapper.classList.remove('is-collapsed'); btn.setAttribute('aria-expanded', 'true'); } if (!animate) { // Re-enable transition after layout pass. requestAnimationFrame(function () { requestAnimationFrame(function () { wrapper.style.transition = ''; }); }); } } setCollapsed(collapsed, false); btn.addEventListener('click', function (e) { e.stopPropagation(); var isCollapsed = wrapper.classList.contains('is-collapsed'); if (!isCollapsed) { // Pin height before collapsing so CSS transition has a from-value. wrapper.style.maxHeight = wrapper.scrollHeight + 'px'; void wrapper.offsetHeight; // force reflow } setCollapsed(!isCollapsed, true); localStorage.setItem(key, isCollapsed ? '0' : '1'); }); // After open animation: release the height cap so late-rendering // content (KaTeX, images) is never clipped. // After close animation: cap is already 0, nothing to do. // Also retrigger sidenote layout after each transition. wrapper.addEventListener('transitionend', function () { if (!wrapper.classList.contains('is-collapsed')) { wrapper.style.maxHeight = 'none'; } window.dispatchEvent(new Event('resize')); }); }); }); }());