/* 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 + keyboard wiring */
/* ------------------------------------------------------------------ */
/* At most one sidenote is "focused" (click-sticky) at a time. */
let focusedPair = null;
function wireHover(ref, sn) {
/* Idempotent: skip pairs already wired so 'reinitSidenotes'
after a transclusion injection cannot stack listeners. */
if (ref.dataset.snBound === '1') return;
ref.dataset.snBound = '1';
sn.dataset.snBound = '1';
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(); }
function toggleFocus() {
/* Sticky focus only makes sense on wide viewports where the
sidenote is actually visible. On narrow screens there's
nothing to pin. */
if (getComputedStyle(sn).display === 'none') return;
if (focused) {
focused = false;
focusedPair = null;
} else {
if (focusedPair) focusedPair.unfocus();
focused = true;
focusedPair = { ref: ref, sn: sn, unfocus: unfocus };
}
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();
toggleFocus();
});
/* Keyboard activation: Enter follows the link by default (the
browser synthesizes a click), but Space does not. Both are
expected to toggle focus on a focus-activated element, so
we normalize: Enter/Space toggle, Escape clears if focused.
The retains its native focusability so
Tab reaches it; we only intercept the key. */
link.addEventListener('keydown', function (e) {
if (e.key === ' ') {
e.preventDefault();
toggleFocus();
} else if (e.key === 'Escape' && focused) {
e.preventDefault();
focused = false;
focusedPair = null;
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;
}
});
/* Global Escape dismisses any sticky-focused sidenote, even if focus
has moved away from the ref link. */
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && focusedPair) {
focusedPair.unfocus();
focusedPair = null;
}
});
function wireAll(root) {
root.querySelectorAll('.sidenote').forEach(function (sn) {
const refId = 'snref-' + sn.id.slice(3);
const ref = document.getElementById(refId);
if (ref) wireHover(ref, sn);
});
}
function init() {
const body = document.getElementById('markdownBody');
if (!body) return;
wireAll(body);
positionSidenotes();
}
/* Public re-init hook used by transclude.js after it injects new
content. wireAll is idempotent so calling this multiple times is
safe. */
window.reinitSidenotes = function (container) {
wireAll(container || document.getElementById('markdownBody') || document);
positionSidenotes();
};
document.addEventListener('DOMContentLoaded', init);
window.addEventListener('resize', positionSidenotes);
}());