// Page startup. // // Pages arrive whole -- the service worker composes every one of them -- so // there is nothing here that builds a page. // What is left is the three things a composed document cannot do for itself: // 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. // // Three things used to live here and no longer do, all for the same reason: // they were reading the page to work out something that was already known // somewhere better. // // 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. // // The third was marking those numbers as controls: role, tabindex and an // aria-label, set on every verse on load, after every swap and after every // history restore -- a walk over a whole book, three times over, to arrive at // the same three attributes every time. They are in the markup now, written // by compile_bible.py, because nothing about them depends on anything only the // browser knows. It costs the corpus a couple of megabytes and saves the pass. (function () { 'use strict'; // -- events ------------------------------------------------------------- document.body.addEventListener('htmx:afterSwap', function (event) { 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 // scrolled to wherever the last one was. The picker listens for the same // event itself, from the navbar, because a restore reaches nothing outside // #content. document.body.addEventListener('htmx:historyRestore', function () { 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); } } // The browser scrolls to the anchor itself on a document it navigated to, // so this is for the one it does not: a reload that landed on boot.html and // was reloaded again into the real page. Costs nothing when the browser has // already been there. 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. function showVersion() { var el = document.getElementById('app-version'); if (!el) return; el.classList.toggle('d-none', !document.querySelector('#content .index')); } // Deferred scripts run before DOMContentLoaded fires, so this always lands, // and it lands after htmx has processed the shell. document.addEventListener('DOMContentLoaded', function () { scrollToHash(); showVersion(); // Registered from the page it is going to serve, not from a page of its // own: this load is already the real app, composed by the worker that is // controlling it. What this catches is the copy on disk having changed // since -- the browser refetches sw.js, sees different bytes, and installs // the new one alongside, which takes over from the next navigation on. if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').catch(function () {}); } }); })();