// 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 three things a composed document cannot do for itself: // mark up the verse numbers as controls, run the modal, and tell the bookmark // slot when a worker has appeared to answer it. // // 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); }); 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); if (target.id === 'content') land(); }); // 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. document.body.addEventListener('htmx:historyRestore', function () { markVerses(); land(); }); // -- 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. function jumpTo(el) { el.scrollIntoView({ block: 'start', behavior: 'instant' }); } // 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 window.scrollTo({ top: 0, behavior: 'instant' }); } // 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(); // 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')); }); }); })();