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