levineuwirth.org/static/js/sidenotes.js

124 lines
4.7 KiB
JavaScript

/* sidenotes.js — Collision avoidance and hover linking for sidenotes.
*
* HTML structure produced by Filters/Sidenotes.hs:
* <sup class="sidenote-ref" id="snref-N"><a href="#sn-N">N</a></sup>
* <span class="sidenote" id="sn-N">…</span>
*
* #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);
}());