Files
bible/assets/js/app.js
T
2026-09-08 08:02:21 -04:00

379 lines
16 KiB
JavaScript

// Page startup and verse modal wiring.
//
// Pages arrive whole -- composed by the service worker, or by boot.html on the
// visit before there is one -- so there is nothing here that builds a page.
// What is left is the four things a composed document cannot do for itself:
// mark up the verse numbers as controls, run the modal, put a swapped-in page
// where it belongs before it is painted, and tell the chrome when a worker has
// appeared to answer it.
//
// The reference picker used to be here as well, built by reading the chapters
// and verses off the book on the page. It is markup and htmx now: sw.js
// renders it from bible/verses.json and answers api/reference, and the scroll
// and the address-bar update that a change event used to run through here are
// response headers on the answer. See templates/reference.njk.
//
// The modal's markup is in templates/base.njk and the bookmark is in the
// worker, so all this file does for it is decide which verse was clicked, fill
// the form, and show it -- Bootstrap handles opening, dismissing and focus,
// and htmx carries the save.
(function () {
'use strict';
var currentUrl = '';
function modalEl() {
return document.getElementById('verse-dialog');
}
function status(text) {
var el = document.getElementById('verse-dialog-status');
if (el) el.textContent = text || '';
return el;
}
// navigator.clipboard only exists in a secure context, so over plain http
// this always falls through to a selectable field rather than failing.
function copyLink(url) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(url).then(function () {
status('Link copied.');
}, function () { offerManualCopy(url); });
} else {
offerManualCopy(url);
}
}
function offerManualCopy(url) {
var el = status('Copy this link:');
if (!el) return;
var field = document.createElement('input');
field.type = 'text';
field.readOnly = true;
field.value = url;
field.className = 'verse-dialog-url form-control form-control-sm mt-2';
el.appendChild(field);
field.focus();
field.select();
}
// -- verse numbers ------------------------------------------------------
function verseInfo(span) {
var article = document.querySelector('article.book');
var section = span.closest('.chapter');
var book = article
? (article.getAttribute('data-name') || article.getAttribute('data-book'))
: '';
var chapter = section ? section.getAttribute('data-chapter') : '';
var page = location.pathname.split('/').pop() || './';
return {
ref: book + ' ' + chapter + ':' + (span.textContent || '').trim(),
// Relative, so a saved bookmark survives a change of host or port.
href: page + '#' + span.id,
url: location.origin + location.pathname + '#' + span.id
};
}
function openFor(span) {
var el = modalEl();
if (!el || !window.bootstrap) return;
var info = verseInfo(span);
currentUrl = info.url;
document.getElementById('verse-dialog-ref').textContent = info.ref;
el.querySelector('input[name="ref"]').value = info.ref;
el.querySelector('input[name="href"]').value = info.href;
status('');
window.bootstrap.Modal.getOrCreateInstance(el).show();
}
function hideModal() {
var el = modalEl();
if (!el || !window.bootstrap) return;
var instance = window.bootstrap.Modal.getInstance(el);
if (instance) instance.hide();
}
// Applied here rather than baked into the HTML: without this script the
// numbers are plain text, and announcing them as buttons would lie. It also
// earns Reboot's [role=button]{cursor:pointer}. Runs again after every htmx
// swap, because the new verses arrive bare.
function markVerses(root) {
var verses = (root || document).querySelectorAll('.chapter .v');
for (var i = 0; i < verses.length; i++) {
verses[i].setAttribute('role', 'button');
verses[i].setAttribute('tabindex', '0');
verses[i].setAttribute('aria-label',
'Verse ' + (verses[i].textContent || '').trim() + ', options');
}
}
// Delegated at the document, so it survives swaps and is bound only once.
// Dismissal is not handled here: the close button carries data-bs-dismiss,
// and Bootstrap owns the backdrop and Escape.
document.addEventListener('click', function (event) {
if (!event.target.closest) return;
var verse = event.target.closest('.v');
if (verse && verse.id) { event.preventDefault(); openFor(verse); return; }
if (event.target.closest('[data-action="copy"]')) copyLink(currentUrl);
var reset = event.target.closest('#app-reset');
if (reset) resetApp(reset);
});
document.addEventListener('keydown', function (event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
var el = document.activeElement;
if (el && el.classList && el.classList.contains('v') && el.id) {
event.preventDefault();
openFor(el);
}
});
document.body.addEventListener('htmx:afterSwap', function (event) {
markVerses(event.target);
var target = event.detail.target;
if (!target) return;
// Close the modal once a bookmark write has come back and swapped, leaving
// the status line up long enough to be read.
if (target.id === 'bookmark') return setTimeout(hideModal, 900);
// Both selects answer into the verse list, so this is either of them.
if (target.id === 'reference-verse') return landOnReference();
if (target.id === 'content') { land(); showVersion(); }
});
// A history restore does not go through the request pipeline -- htmx swaps
// the element straight in and fires this instead -- so the work afterSwap
// does has to be repeated here or a page reached by the back button arrives
// with dead verse numbers. The picker listens for the same event itself,
// from the navbar, because a restore reaches nothing outside #content.
document.body.addEventListener('htmx:historyRestore', function () {
markVerses();
land();
showVersion();
});
// -- startup ------------------------------------------------------------
function hashTarget() {
if (!location.hash) return null;
return document.getElementById(decodeURIComponent(location.hash.slice(1)));
}
// Every scroll here is a correction, not a gesture: the page is meant to
// arrive already in position. So they all say instant.
//
// Saying nothing would not mean instant. The default is "auto", which means
// "whatever scroll-behavior computes to" -- and Bootstrap's Reboot sets
// :root { scroll-behavior: smooth }, so the default animates. Bootstrap
// wraps that in prefers-reduced-motion, which is the right call for the
// chapter nav's own anchors; these are not that.
//
// Asked for twice, because "instant" is a later addition to an old enum and
// a browser that does not know the value rejects the whole call with a
// TypeError rather than ignoring the word -- so on an older WebKit this did
// not scroll slowly, it did not scroll at all. "auto" is what the enum has
// always had; it animates here, which is worse than instant and far better
// than staying put.
function jumpTo(el) {
try {
el.scrollIntoView({ block: 'start', behavior: 'instant' });
} catch (err) {
el.scrollIntoView({ block: 'start', behavior: 'auto' });
}
}
// The same, for the top of the page and the same reason.
function jumpToTop() {
try {
window.scrollTo({ top: 0, behavior: 'instant' });
} catch (err) {
window.scrollTo(0, 0);
}
}
// A worker-composed page is an ordinary document and the browser scrolls to
// the anchor itself. One composed by boot.html is not: it replaced a
// document the browser had already finished with, so the fragment identifier
// has been spent. Repeating it costs nothing in the first case and is what
// makes a link to GEN#v1_1 land on the verse in the second.
function scrollToHash() {
var el = hashTarget();
if (el) jumpTo(el);
}
// Where a swapped-in page should sit before it is ever seen.
//
// htmx does this itself -- scrolling to the anchor a boosted href named, or
// to the top of the target when it named none -- but from the settle
// callback, which is a setTimeout of defaultSettleDelay, 20ms. The browser
// paints the new content at the old scroll offset first, so the jump is
// visible every time. Here it runs in the same task as the swap, before any
// paint, so the page simply appears where it belongs. htmx's own scroll
// still follows and finds nothing left to do.
//
// htmx has already pushed the new URL by this point -- history is updated
// before the swap, not after -- so location.hash is the anchor that was
// asked for.
function land() {
var el = hashTarget();
if (el) jumpTo(el);
else jumpToTop();
}
// Where the reference picker lands, which is the one scroll in the app that
// is a gesture: the reader asked for chapter 3 and is waiting to be taken
// there.
//
// It used to ride along on the answer, as show: on HX-Reswap, and be htmx's
// scroll rather than this file's. Two reasons it is here now. The smaller
// one is that htmx's own scrollIntoView says "instant" with nothing around
// it, so the enum above takes the whole scroll down with it. The larger one
// is the phone: a select on iOS is a picker drawn over the page, the change
// event arrives while it is still up, and dismissing it puts the page back
// where it stood when it opened -- undoing anything scrolled in between.
// The options were swapping correctly and the book never moved.
//
// So: once now, for a pointer, and once more after the picker has had time
// to finish, but only if the page did not stay where it was put. That test
// is also what keeps the second attempt from yanking back a reader who has
// started scrolling on their own.
//
// The anchor comes from the address bar. The worker sends HX-Replace-Url
// with the answer and htmx updates history before it swaps, so by the time
// this runs location.hash is already naming the chapter or verse chosen.
var PICKER_SETTLE_MS = 350;
function landOnReference() {
var el = hashTarget();
if (!el) return;
jumpTo(el);
var landed = window.pageYOffset;
setTimeout(function () {
var again = hashTarget();
if (again && Math.abs(window.pageYOffset - landed) > 2) jumpTo(again);
}, PICKER_SETTLE_MS);
}
// The version readout, shown under the book list and nowhere else.
//
// It is chrome: it sits outside #content, so it outlives every swap -- and
// so does the body class it was rendered with, which is exactly why that
// cannot be what decides. What is in #content can, because that is the one
// thing a swap changes: the book list is the fragment rooted at .index.
//
// Toggling the class rather than the style because Bootstrap declares
// .d-none !important, which an inline display would lose to.
//
// Only the digest line is missing on a page boot.html composed -- there was
// no worker to name a version -- so the element itself is always here.
function showVersion() {
var el = document.getElementById('app-version');
if (!el) return;
el.classList.toggle('d-none', !document.querySelector('#content .index'));
}
// -- reset --------------------------------------------------------------
// Everything this app has put on the device, cleared in the order that makes
// the next load a first visit.
//
// The worker goes first, so that nothing is left answering out of a cache
// while the caches themselves are being deleted. Unregistering does not stop
// the copy already controlling this page -- an active worker keeps its
// clients until they unload -- which is what the reload at the end is for:
// the registration is gone by then, the navigation reaches server.py, and
// boot.html registers a new worker against whatever is on disk now.
//
// Then the caches, both of them, by whatever names they carry: the point of
// this button is the case where the names are not the ones this build would
// have chosen. Then the bookmark, which is the only thing here a reader
// would miss -- hence the question first.
//
// One after another rather than all at once, so that the order above is the
// order it happens in, and so that a step cannot be reading storage another
// is halfway through deleting.
//
// It still races the worker's own background download, which writes a book
// into the cache on any request that arrives while this is running -- there
// is no way to call that off from here. The reload is what settles it: the
// worker is gone by then, and the one that installs in its place opens
// caches under its own names regardless of what survived.
//
// Each step is allowed to fail on its own. A browser with no caches API, or
// storage the user has locked down, should still get everything else
// cleared and still reload.
var RESET_QUESTION =
'Clear this app from the device? The offline copy of the books and the ' +
'bookmark go with it, and the app reloads as a first visit.';
function settle(promise) {
return promise.catch(function () {});
}
function forgetWorker() {
if (!('serviceWorker' in navigator)) return Promise.resolve();
return navigator.serviceWorker.getRegistrations().then(function (regs) {
return Promise.all(regs.map(function (reg) { return reg.unregister(); }));
});
}
function forgetCaches() {
if (!window.caches) return Promise.resolve();
return caches.keys().then(function (keys) {
return Promise.all(keys.map(function (key) { return caches.delete(key); }));
});
}
// Resolved on blocked as well as on done. Blocked means another tab still
// has the database open, and there is nothing useful to do about it from
// here: this tab is about to reload, and the delete completes when the last
// one lets go.
function forgetBookmark() {
return new Promise(function (resolve) {
if (!window.indexedDB) return resolve();
var req = indexedDB.deleteDatabase('bible');
req.onsuccess = req.onerror = req.onblocked = function () { resolve(); };
});
}
function resetApp(button) {
if (!window.confirm(RESET_QUESTION)) return;
button.disabled = true;
button.textContent = 'Clearing…';
settle(forgetWorker())
.then(function () { return settle(forgetCaches()); })
.then(function () { return settle(forgetBookmark()); })
.then(function () { location.reload(); });
}
// api/bookmark has no origin server -- it is answered by the worker -- so
// there is nothing to ask until the worker is controlling this page.
// controller, not ready: ready can resolve a tick before clients.claim()
// takes effect, and a request sent in that gap reaches the network and 404s.
function whenControlled(fn) {
if (!('serviceWorker' in navigator)) return;
if (navigator.serviceWorker.controller) return fn();
navigator.serviceWorker.addEventListener('controllerchange', fn, { once: true });
}
// Deferred scripts run before DOMContentLoaded fires, so this always lands,
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () {
markVerses();
scrollToHash();
showVersion();
// Registered from the page it is going to serve, not from a page of its
// own: there is nothing to wait for and nothing to reload. This load is
// already the real app -- the worker installs alongside it, and takes over
// composing, the bookmark and the offline download from the next
// navigation on.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {});
}
whenControlled(function () {
document.body.dispatchEvent(new CustomEvent('worker-ready'));
});
});
})();