302 lines
13 KiB
JavaScript
302 lines
13 KiB
JavaScript
// Page startup.
|
|
//
|
|
// 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, put a swapped-in page where it
|
|
// belongs before it is painted, land the reference picker where the reader
|
|
// asked to go, and tell the chrome when a worker has appeared to answer it.
|
|
//
|
|
// Two things used to live here and no longer do, both for the same reason:
|
|
// they were reading the page to work out something the worker already knew.
|
|
//
|
|
// The reference picker was built by reading the chapters and verses off the
|
|
// book on screen. It is markup and htmx now: sw.js renders it from
|
|
// bible/verses.json and answers api/reference, and the address-bar update that
|
|
// a change event used to run through here is a response header on the answer.
|
|
// See templates/reference.njk.
|
|
//
|
|
// The verse dialog was built by scraping the book name out of article
|
|
// [data-name] and the chapter out of the enclosing section, then filling a
|
|
// form and showing it. sw.js answers api/verse with the whole of the dialog,
|
|
// htmx swaps it into the empty modal in templates/base.njk, and Bootstrap
|
|
// opens it -- which is htmx's own bootstrap-modal example, with the click
|
|
// delegated because the trigger is one of 38,000 generated verse numbers and
|
|
// cannot carry attributes of its own. See templates/verse.njk.
|
|
//
|
|
// What is left of the dialog here is markVerses, and only because the numbers
|
|
// have to look and behave like controls before anyone can click one.
|
|
(function () {
|
|
'use strict';
|
|
|
|
// -- verse numbers ------------------------------------------------------
|
|
|
|
// 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.
|
|
// The verse numbers and the dialog's own buttons are not here: opening the
|
|
// dialog is an hx-on handler on #content, and everything inside it is
|
|
// markup that sw.js sends. This is the reset button and nothing else.
|
|
document.addEventListener('click', function (event) {
|
|
if (!event.target.closest) return;
|
|
var reset = event.target.closest('#app-reset');
|
|
if (reset) resetApp(reset);
|
|
});
|
|
|
|
document.body.addEventListener('htmx:afterSwap', function (event) {
|
|
markVerses(event.target);
|
|
var target = event.detail.target;
|
|
if (!target) return;
|
|
// 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'));
|
|
});
|
|
});
|
|
})();
|