289 lines
9.3 KiB
JavaScript
Executable File
289 lines
9.3 KiB
JavaScript
Executable File
|
||
|
||
/* Event listener function for the COPY BUTTON */
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
document.querySelectorAll("pre.src").forEach(function (codeBlock) {
|
||
const button = document.createElement("button");
|
||
button.innerText = "Copy";
|
||
button.className = "copy-btn";
|
||
|
||
// Append button inside <pre>
|
||
codeBlock.appendChild(button);
|
||
|
||
button.addEventListener("click", function () {
|
||
const text = codeBlock.innerText.replace(button.innerText, ""); // exclude button text
|
||
navigator.clipboard.writeText(text.trim()).then(() => {
|
||
button.innerText = "Copied!";
|
||
setTimeout(() => (button.innerText = "Copy"), 1500);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
|
||
/* Event listener for footnotes and sidenotes*/
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
document.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => {
|
||
const sup = ref.closest("sup") || ref;
|
||
// idempotent: don't insert twice
|
||
if (sup.nextElementSibling && sup.nextElementSibling.classList?.contains("footnote-sidenote")) return;
|
||
|
||
const targetId = ref.getAttribute("href").replace(/^#/, ""); // works for fn.2 or fn2
|
||
|
||
const anchor = document.getElementById(targetId);
|
||
if (!anchor) return;
|
||
|
||
const footdef = anchor.closest(".footdef") || anchor.parentElement;
|
||
if (!footdef) return;
|
||
|
||
// 1) Prefer leaf paragraphs to avoid div+p duplication
|
||
let paras = footdef.querySelectorAll("p.footpara");
|
||
if (!paras.length) {
|
||
// fallback: any .footpara elements that don't contain another .footpara
|
||
paras = footdef.querySelectorAll(".footpara:not(:has(.footpara))");
|
||
}
|
||
|
||
// 2) Build HTML, de-duplicating by text content
|
||
let parts = [];
|
||
if (paras.length) {
|
||
const seen = new Set();
|
||
parts = Array.from(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);
|
||
}
|
||
|
||
// 3) Fallback: clean full block if no paras found
|
||
if (!parts.length) {
|
||
const clone = footdef.cloneNode(true);
|
||
clone.querySelectorAll("sup.footnum, a[role='doc-backlink']").forEach(n => n.remove());
|
||
parts = [clone.innerHTML.trim()];
|
||
}
|
||
|
||
// 4) Insert the sidenote
|
||
const sn = document.createElement("span");
|
||
sn.className = "sidenote footnote-sidenote";
|
||
sn.setAttribute("data-fn", (ref.textContent || "").trim());
|
||
sn.innerHTML = parts.join(" ");
|
||
|
||
sup.insertAdjacentElement("afterend", sn);
|
||
});
|
||
});
|
||
|
||
|
||
/* Function for setting the theme */
|
||
(function(){
|
||
const root = document.documentElement;
|
||
const storageKey = "theme";
|
||
const saved = localStorage.getItem(storageKey);
|
||
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";
|
||
// If no current (auto), assume we’re toggling to dark first
|
||
const target = current ? next : "dark";
|
||
root.setAttribute("data-theme", target);
|
||
localStorage.setItem(storageKey, target);
|
||
});
|
||
})();
|
||
|
||
|
||
// Simple, timezone-safe if you pass UTC (…Z) in the datetime
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
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); // Prefer ISO like 2025-12-31T00:00:00Z
|
||
if (isNaN(target)) { el.textContent = '—'; return; }
|
||
|
||
const now = new Date();
|
||
let diff = target - now;
|
||
|
||
if (diff <= 0) {
|
||
el.textContent = `${label ? label + ' ' : ''}today`;
|
||
el.classList.add('expired');
|
||
return;
|
||
}
|
||
|
||
const d = Math.floor(diff / 86400000); diff -= d * 86400000;
|
||
const h = Math.floor(diff / 3600000); diff -= h * 3600000;
|
||
const m = Math.floor(diff / 60000); diff -= m * 60000;
|
||
const s = Math.floor(diff / 1000);
|
||
|
||
const pieces = [];
|
||
if (d) pieces.push(plural(d, 'day'));
|
||
pieces.push(`${h}h ${m}m ${s}s`);
|
||
|
||
el.textContent = `${label ? label + ' in : ' : ''}${pieces.join(' ')}`;
|
||
};
|
||
|
||
const tick = () => els.forEach(render);
|
||
tick();
|
||
setInterval(tick, 1000); // update every second
|
||
});
|
||
|
||
|
||
|
||
/* Event listener for scrolling and changing the active label on the TOC */
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const toc = document.querySelector("#text-table-of-contents");
|
||
if (!toc) { console.warn("No #text-table-of-contents found"); return; }
|
||
|
||
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
|
||
// <a href="#introduction">Intro</a> matches
|
||
if (!links.length) { console.warn("No ToC links found"); return; }
|
||
|
||
// Map: id -> link
|
||
const linkById = new Map();
|
||
links.forEach(a => {
|
||
const id = decodeURIComponent(a.getAttribute("href").slice(1));
|
||
const el = document.getElementById(id);
|
||
if (el) linkById.set(id, a);
|
||
});
|
||
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
|
||
|
||
// Headings to observe (h2–h4 usually)
|
||
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
|
||
.filter(h => linkById.has(h.id));
|
||
|
||
// Helper to mark active
|
||
const setActive = (id) => {
|
||
links.forEach(a => {
|
||
const active = a.getAttribute("href") === `#${id}`;
|
||
a.classList.toggle("is-active", active);
|
||
if (active) a.setAttribute("aria-current", "true");
|
||
else a.removeAttribute("aria-current");
|
||
});
|
||
};
|
||
|
||
// Calculate sticky header offset in px
|
||
const headerOffsetPx = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||
|
||
// Track visible headings (id -> distance from top)
|
||
const visible = new Map();
|
||
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
const id = entry.target.id;
|
||
if (entry.isIntersecting) {
|
||
// How far from the top (after header offset)
|
||
const dist = entry.target.getBoundingClientRect().top - headerOffsetPx;
|
||
visible.set(id, dist);
|
||
} else {
|
||
visible.delete(id);
|
||
}
|
||
});
|
||
|
||
if (visible.size) {
|
||
// Choose the heading closest to the top (>= -headerOffset)
|
||
const topMost = [...visible.entries()]
|
||
.sort((a,b) => Math.abs(a[1]) - Math.abs(b[1]))[0][0];
|
||
setActive(topMost);
|
||
// console.log("Active:", topMost, visible);
|
||
}
|
||
}, {
|
||
root: null, // track relative to viewport
|
||
rootMargin: `-${headerOffsetPx}px 0px -70% 0px`,
|
||
threshold: [0, 0.01, 0.1] // fire as soon as it enters
|
||
});
|
||
|
||
headings.forEach(h => observer.observe(h));
|
||
|
||
// Initial highlight (in case load mid‑page)
|
||
let bestId = null, bestDist = Infinity;
|
||
headings.forEach(h => {
|
||
const top = h.getBoundingClientRect().top - headerOffsetPx;
|
||
const dist = top < 0 ? Math.abs(top) : top + 1e6;
|
||
if (dist < bestDist) { bestDist = dist; bestId = h.id; }
|
||
});
|
||
if (bestId) setActive(bestId);
|
||
|
||
// smooth-scroll ToC clicks
|
||
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}`);
|
||
});
|
||
});
|
||
|
||
|
||
// Make Mermaid diagrams zoomable
|
||
window.addEventListener('load', function() {
|
||
// Initialize Mermaid (if not already)
|
||
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
|
||
|
||
// Find all Mermaid divs
|
||
document.querySelectorAll('.mermaid').forEach(el => {
|
||
// Decode HTML entities
|
||
el.innerHTML = el.innerHTML
|
||
.replace(/>/g, '>')
|
||
.replace(/</g, '<')
|
||
.replace(/&/g, '&');
|
||
|
||
// Wrap in container if not already wrapped
|
||
if (!el.parentElement.classList.contains('mermaid-container')) {
|
||
const container = document.createElement('div');
|
||
container.classList.add('mermaid-container');
|
||
|
||
// Add zoom controls
|
||
const controls = document.createElement('div');
|
||
controls.classList.add('mermaid-zoom-controls');
|
||
controls.innerHTML = `
|
||
<button class="zoom-in">+</button>
|
||
<button class="zoom-out">-</button>
|
||
`;
|
||
container.appendChild(controls);
|
||
|
||
// Move the mermaid diagram into container
|
||
container.appendChild(el.cloneNode(true));
|
||
el.replaceWith(container);
|
||
|
||
// Select the SVG inside
|
||
const svg = container.querySelector('.mermaid svg');
|
||
let scale = 1;
|
||
|
||
// Zoom functions
|
||
controls.querySelector('.zoom-in').addEventListener('click', () => {
|
||
scale += 0.1;
|
||
svg.style.transform = `scale(${scale})`;
|
||
});
|
||
|
||
controls.querySelector('.zoom-out').addEventListener('click', () => {
|
||
scale = Math.max(0.1, scale - 0.1);
|
||
svg.style.transform = `scale(${scale})`;
|
||
});
|
||
|
||
// Optional: scroll wheel zoom
|
||
container.addEventListener('wheel', (e) => {
|
||
if (e.ctrlKey) { // zoom only with ctrl+wheel
|
||
e.preventDefault();
|
||
if (e.deltaY < 0) scale += 0.05;
|
||
else scale = Math.max(0.1, scale - 0.05);
|
||
svg.style.transform = `scale(${scale})`;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Finally, render Mermaid diagrams
|
||
mermaid.run({ querySelector: '.mermaid' });
|
||
});
|