// 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')); }); }); })();