/* ========================================================= BOOTSTRAP ========================================================= */ document.addEventListener("DOMContentLoaded", () => { initCopyButtons(); initFootnoteSidenotes(); initThemeToggle(); initCountdowns(); initTOCHighlighting(); initStackedNavigation(); restoreStackFromURL(); initClearPanesButton(); initInitialPaneControls(); }); /* ========================================================= COPY BUTTONS (code blocks) ========================================================= */ function initCopyButtons() { document.querySelectorAll("pre.src").forEach(codeBlock => { if (codeBlock.querySelector(".copy-btn")) return; // idempotent const button = document.createElement("button"); button.className = "copy-btn"; button.textContent = "Copy"; codeBlock.appendChild(button); button.addEventListener("click", async () => { const text = codeBlock.innerText.replace(button.innerText, "").trim(); try { await navigator.clipboard.writeText(text); button.textContent = "Copied!"; setTimeout(() => (button.textContent = "Copy"), 1500); } catch { button.textContent = "Failed"; setTimeout(() => (button.textContent = "Copy"), 1500); } }); }); } /* ========================================================= FOOTNOTES → SIDENOTES ========================================================= */ function initFootnoteSidenotes() { document.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => { const sup = ref.closest("sup") || ref; if (sup.nextElementSibling?.classList.contains("footnote-sidenote")) return; const targetId = ref.getAttribute("href").slice(1); const anchor = document.getElementById(targetId); if (!anchor) return; const footdef = anchor.closest(".footdef") || anchor.parentElement; if (!footdef) return; let paras = footdef.querySelectorAll("p.footpara"); if (!paras.length) { paras = footdef.querySelectorAll(".footpara:not(:has(.footpara))"); } let parts = []; if (paras.length) { const seen = new Set(); parts = [...paras] .map(p => { const txt = p.textContent.trim().replace(/\s+/g, " "); if (seen.has(txt)) return ""; seen.add(txt); return p.innerHTML.trim(); }) .filter(Boolean); } if (!parts.length) { const clone = footdef.cloneNode(true); clone .querySelectorAll("sup.footnum, a[role='doc-backlink']") .forEach(n => n.remove()); parts = [clone.innerHTML.trim()]; } const sidenote = document.createElement("span"); sidenote.className = "sidenote footnote-sidenote"; sidenote.dataset.fn = ref.textContent.trim(); sidenote.innerHTML = parts.join(" "); sup.insertAdjacentElement("afterend", sidenote); }); } /* ========================================================= THEME TOGGLE ========================================================= */ function initThemeToggle() { const root = document.documentElement; const key = "theme"; const saved = localStorage.getItem(key); if (saved === "dark" || saved === "light") { root.setAttribute("data-theme", saved); } const btn = document.getElementById("theme-toggle"); if (!btn) return; btn.addEventListener("click", () => { const current = root.getAttribute("data-theme"); const next = current === "dark" ? "light" : "dark"; root.setAttribute("data-theme", next); localStorage.setItem(key, next); }); } /* ========================================================= COUNTDOWNS ========================================================= */ function initCountdowns() { const els = document.querySelectorAll("time.countdown"); if (!els.length) return; const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`; const render = el => { const raw = el.getAttribute("datetime"); const label = el.dataset.label || ""; const target = new Date(raw); if (isNaN(target)) { el.textContent = "—"; return; } let diff = target - new Date(); if (diff <= 0) { el.textContent = `${label ? label + " " : ""}today`; el.classList.add("expired"); return; } const d = Math.floor(diff / 86400000); diff %= 86400000; const h = Math.floor(diff / 3600000); diff %= 3600000; const m = Math.floor(diff / 60000); diff %= 60000; const s = Math.floor(diff / 1000); const parts = []; if (d) parts.push(plural(d, "day")); parts.push(`${h}h ${m}m ${s}s`); el.textContent = `${label ? label + " in: " : ""}${parts.join(" ")}`; }; const tick = () => els.forEach(render); tick(); setInterval(tick, 1000); } /* ========================================================= TABLE OF CONTENTS HIGHLIGHTING ========================================================= */ function initTOCHighlighting() { const toc = document.querySelector("#text-table-of-contents"); if (!toc) return; const links = [...toc.querySelectorAll('a[href^="#"]')]; if (!links.length) return; const linkById = new Map(); links.forEach(a => { const id = decodeURIComponent(a.hash.slice(1)); const el = document.getElementById(id); if (el) linkById.set(id, a); }); const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")] .filter(h => linkById.has(h.id)); const setActive = id => { links.forEach(a => { const active = a.hash === `#${id}`; a.classList.toggle("is-active", active); a.toggleAttribute("aria-current", active); }); }; const headerOffset = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize); const visible = new Map(); const observer = new IntersectionObserver(entries => { entries.forEach(entry => { const id = entry.target.id; if (entry.isIntersecting) { visible.set(id, entry.target.getBoundingClientRect().top - headerOffset); } else { visible.delete(id); } }); if (visible.size) { const [id] = [...visible.entries()] .sort((a, b) => Math.abs(a[1]) - Math.abs(b[1]))[0]; setActive(id); } }, { rootMargin: `-${headerOffset}px 0px -70% 0px`, threshold: [0, 0.01, 0.1] }); headings.forEach(h => observer.observe(h)); toc.addEventListener("click", e => { const a = e.target.closest('a[href^="#"]'); if (!a) return; const id = decodeURIComponent(a.hash.slice(1)); const el = document.getElementById(id); if (!el) return; e.preventDefault(); el.scrollIntoView({ behavior: "smooth", block: "start" }); el.setAttribute("tabindex", "-1"); el.focus({ preventScroll: true }); history.pushState(null, "", `#${id}`); }); } let fullscreenSnapshot = null; /* ========================================================= STACKED NAVIGATION (PANES) ========================================================= */ function initStackedNavigation() { document.addEventListener("click", e => { const link = e.target.closest("a"); if (!link) return; const href = link.getAttribute("href"); if (!href || href.startsWith("#")) return; const url = new URL(href, location.href); if (url.origin !== location.origin) return; if (!url.pathname.endsWith(".html")) return; e.preventDefault(); pushPane(url.pathname + url.hash); }); } async function pushPane(urlWithHash) { const track = document.querySelector(".stack-track"); if (!track) return; const existing = [...track.children].find(p => p.dataset.url === urlWithHash); if (existing) { existing.scrollIntoView({ behavior: "smooth", inline: "end" }); return; } const [url, hash] = urlWithHash.split("#"); const res = await fetch(url); const doc = new DOMParser().parseFromString(await res.text(), "text/html"); const content = doc.querySelector("#content"); if (!content) return; const pane = document.createElement("article"); pane.className = "stack-pane"; pane.dataset.url = urlWithHash; pane.appendChild(content); track.appendChild(pane); pane.scrollIntoView({ behavior: "smooth", inline: "end" }); if (hash) { requestAnimationFrame(() => { pane.querySelector(`#${CSS.escape(hash)}`) ?.scrollIntoView({ behavior: "smooth", block: "start" }); }); } // Find the title-section and attach event listeners to the controls const titleSection = pane.querySelector(".title-section"); if (titleSection) { const closeBtn = titleSection.querySelector(".pane-close"); const fullscreenBtn = titleSection.querySelector(".pane-fullscreen"); if (closeBtn) { closeBtn.addEventListener("click", () => { if (document.body.classList.contains("pane-fullscreen")) { exitFullscreen({ removePane: pane }); return; } pane.remove(); updateURL(); }); } if (fullscreenBtn) { fullscreenBtn.addEventListener("click", () => { if (pane.classList.contains("is-fullscreen")) { exitFullscreen(); } else { enterFullscreen(pane); } }); } } updateURL(); } function initInitialPaneControls() { // Initialize controls for the initial pane (pane-root) that's already in the HTML const initialPane = document.querySelector(".pane-root"); if (!initialPane) return; const titleSection = initialPane.querySelector(".title-section"); if (!titleSection) return; const closeBtn = titleSection.querySelector(".pane-close"); const fullscreenBtn = titleSection.querySelector(".pane-fullscreen"); if (closeBtn) { closeBtn.addEventListener("click", () => { if (document.body.classList.contains("pane-fullscreen")) { exitFullscreen({ removePane: initialPane }); return; } initialPane.remove(); updateURL(); }); } if (fullscreenBtn) { fullscreenBtn.addEventListener("click", () => { if (initialPane.classList.contains("is-fullscreen")) { exitFullscreen(); } else { enterFullscreen(initialPane); } }); } } document.addEventListener("keydown", e => { if (e.key === "Escape" && document.body.classList.contains("pane-fullscreen")) { exitFullscreen(); } }); function enterFullscreen(pane) { if (!fullscreenSnapshot) { fullscreenSnapshot = [...document.querySelectorAll(".stack-pane")] .map(p => p.dataset.url); } document.querySelectorAll(".stack-pane").forEach(p => { if (p !== pane) p.remove(); }); document.body.classList.add("pane-fullscreen"); pane.classList.add("is-fullscreen"); updateURL(); } async function exitFullscreen({ removePane } = {}) { if (!fullscreenSnapshot) return; const removeUrl = removePane?.dataset.url; document.body.classList.remove("pane-fullscreen"); document .querySelectorAll(".stack-pane.is-fullscreen") .forEach(p => p.remove()); // Restore stack EXCEPT the removed pane for (const url of fullscreenSnapshot) { if (url === removeUrl) continue; await pushPane(url); } fullscreenSnapshot = null; updateURL(); } function clearAllPanes() { const panes = [...document.querySelectorAll(".stack-pane")]; panes.slice(1).forEach(pane => pane.remove()); updateURL(); } function updateURL() { const urls = [...document.querySelectorAll(".stack-pane")] .map(p => p.dataset.url); const params = new URLSearchParams(location.search); params.set("stackedNotes", urls.join("|")); history.replaceState({}, "", "?" + params.toString()); } async function restoreStackFromURL() { const params = new URLSearchParams(location.search); const stack = params.get("stackedNotes"); if (!stack) return; for (const url of stack.split("|").slice(1)) { await pushPane(url); } } function initClearPanesButton() { const btn = document.getElementById("close-all"); if (!btn) return; btn.addEventListener("click", () => { clearAllPanes(); }); }