// 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') { fillReference(); land(); } }); // -- reference picker --------------------------------------------------- // The "GEN 1:1" in the navbar. Built from the book on the page rather than // from a table shipped beside it: the chapters are the sections and a // chapter's verses are the anchors inside it, so an option can never name a // target that is not there to scroll to, and there is no second file to keep // in step, cache, or version. // // It is not kept in step with the scroll position, on purpose. It says where // you are going, not where you are, and it starts at 1:1 in every book. function byId(id) { return document.getElementById(id); } function fillOptions(select, items) { select.textContent = ''; for (var i = 0; i < items.length; i++) { var option = document.createElement('option'); // The value is the id of the element to land on, so nothing downstream // has to know how a verse anchor is spelled. option.value = items[i].value; option.textContent = items[i].label; select.appendChild(option); } } function fillVerses(chapterId) { var section = byId(chapterId); var verses = section ? section.querySelectorAll('.v') : []; var items = []; for (var i = 0; i < verses.length; i++) { items.push({ value: verses[i].id, label: (verses[i].textContent || '').trim() }); } fillOptions(byId('reference-verse'), items); } function fillReference() { var picker = byId('reference'); if (!picker) return; var book = document.querySelector('#content article.book'); picker.classList.toggle('d-none', !book); picker.classList.toggle('d-flex', !!book); if (!book) return; byId('reference-book').textContent = book.getAttribute('data-book') || ''; var chapters = book.querySelectorAll('.chapter[data-chapter]'); var items = []; for (var i = 0; i < chapters.length; i++) { items.push({ value: chapters[i].id, label: chapters[i].getAttribute('data-chapter') }); } fillOptions(byId('reference-chapter'), items); if (items.length) fillVerses(items[0].value); } // The URL is the whole mechanism: name the anchor, then land on it. Nothing // is stored anywhere else, so a reload or a shared link ends up in the same // place. // // replaceState rather than assigning location.hash: spinning through a // select should not stack one history entry per option, and assigning the // hash hands the scroll to the browser, which Bootstrap has set to smooth. function goTo(id) { var target = byId(id); if (!target) return; history.replaceState(null, '', '#' + id); jumpTo(target); } // Delegated at the document, so it survives every swap and binds once. // Changing the chapter rebuilds the verses and lands on the chapter heading, // which is what 1 is directly below; changing the verse lands on the verse. document.addEventListener('change', function (event) { var target = event.target; if (!target || !target.id) return; if (target.id === 'reference-chapter') { fillVerses(target.value); goTo(target.value); } else if (target.id === 'reference-verse') { goTo(target.value); } }); // 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(); fillReference(); 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(); fillReference(); 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')); }); }); })();