/* sidenotes.js — Collision avoidance and hover linking for sidenotes. * * HTML structure produced by Filters/Sidenotes.hs: * N * * * #markdownBody must be position: relative (layout.css guarantees this). * The .sidenote spans are position: absolute; left: calc(100% + 2.5rem). * Without an explicit top they sit at their "hypothetical static position", * which is fine for isolated notes but causes overlaps when notes are close. * * This script: * 1. Anchors each sidenote's top to its reference's offsetTop so the * alignment is explicit and stable across all browsers. * 2. Walks notes top-to-bottom and pushes each one below the previous * if they would overlap (with a small gap between them). * 3. Wires bidirectional hover highlights between each ref↔sidenote pair. */ (function () { 'use strict'; const GAP = 12; /* minimum px gap between successive sidenotes */ function positionSidenotes() { const body = document.getElementById('markdownBody'); if (!body) return; const notes = Array.from(body.querySelectorAll('.sidenote')); if (notes.length === 0) return; /* Only run on wide viewports where sidenotes are visible. */ if (getComputedStyle(notes[0]).display === 'none') return; let prevBottom = 0; notes.forEach(function (sn) { /* Reset any prior JS-applied top so offsetTop reads correctly. */ sn.style.top = ''; const id = sn.id; /* "sn-N" */ const refId = 'snref-' + id.slice(3); /* "snref-N" */ const ref = document.getElementById(refId); /* Preferred top: align with the reference superscript. */ const preferred = ref ? ref.offsetTop : sn.offsetTop; const top = Math.max(preferred, prevBottom + GAP); sn.style.top = top + 'px'; prevBottom = top + sn.offsetHeight; }); } /* ------------------------------------------------------------------ */ /* Hover + click wiring */ /* ------------------------------------------------------------------ */ /* At most one sidenote is "focused" (click-sticky) at a time. */ let focusedPair = null; function wireHover(ref, sn) { let hovering = false; let focused = false; function update() { const active = focused || hovering; ref.classList.toggle('is-active', active); sn.classList.toggle('is-active', active); } function unfocus() { focused = false; update(); } ref.addEventListener('mouseenter', function () { hovering = true; update(); }); ref.addEventListener('mouseleave', function () { hovering = false; update(); }); sn.addEventListener('mouseenter', function () { hovering = true; update(); }); sn.addEventListener('mouseleave', function () { hovering = false; update(); }); /* Click on the superscript link: sticky focus on wide viewports, normal anchor scroll on narrow viewports (sidenote hidden). */ const link = ref.querySelector('a'); if (link) { link.addEventListener('click', function (e) { e.preventDefault(); if (getComputedStyle(sn).display === 'none') return; /* narrow: no sidenote to focus */ if (focused) { focused = false; focusedPair = null; } else { if (focusedPair) focusedPair.unfocus(); focused = true; focusedPair = { ref: ref, sn: sn, unfocus: unfocus }; } update(); }); } } /* Click anywhere outside a focused pair dismisses it. */ document.addEventListener('click', function (e) { if (focusedPair && !focusedPair.ref.contains(e.target) && !focusedPair.sn.contains(e.target)) { focusedPair.unfocus(); focusedPair = null; } }); function init() { const body = document.getElementById('markdownBody'); if (!body) return; body.querySelectorAll('.sidenote').forEach(function (sn) { const refId = 'snref-' + sn.id.slice(3); const ref = document.getElementById(refId); if (ref) wireHover(ref, sn); }); positionSidenotes(); } document.addEventListener('DOMContentLoaded', init); window.addEventListener('resize', positionSidenotes); }());