2026-10-21 20:25:15 -04:00
|
|
|
/*
|
|
|
|
|
* browser-guest-preload.cjs — runs inside the web page that the Browser tab shows (the Electron
|
|
|
|
|
* <webview> guest). main.cjs forces it onto every webview from will-attach-webview, sandboxed and
|
|
|
|
|
* context-isolated: the page can neither see nor call anything here, and this file can only talk to the
|
|
|
|
|
* Bible app through ipcRenderer.sendToHost (to the <webview> element owned by browser-tab.js) and
|
|
|
|
|
* ipcRenderer.on (from it).
|
|
|
|
|
*
|
|
|
|
|
* Why each piece exists — every one of these is something the app cannot see from outside the page,
|
|
|
|
|
* because input inside a webview never reaches the app's own document:
|
|
|
|
|
*
|
|
|
|
|
* keyboard a tapped text field tells the app to bring up its own on-screen keyboard;
|
|
|
|
|
* scripture a tap on "Isaiah 41:10" is found with the app's OWN scanner and handed over to open in a
|
|
|
|
|
* new Bible tab;
|
|
|
|
|
* scroll reported so the phone remote and the booth follower can track the page, and carried out
|
|
|
|
|
* on the app's behalf (phone remote, header nav, OSC);
|
|
|
|
|
* follower a script-free copy of the DOM, published only while a booth screen is linked;
|
|
|
|
|
* activity touches inside the page count as activity, or the app's idle splash would drop over the
|
|
|
|
|
* page while pastor is using it — and the five-finger "blank the screen" gesture still works.
|
|
|
|
|
*/
|
|
|
|
|
'use strict';
|
|
|
|
|
const { ipcRenderer, webFrame } = require('electron');
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* No dialog boxes on the stage. alert / confirm / prompt from a page would open a native window over the
|
|
|
|
|
* TV. They are replaced in the PAGE's world (the only one its scripts can see) with silent stand-ins:
|
|
|
|
|
* confirm answers "Cancel", so nothing a page asks permission for can happen by itself.
|
|
|
|
|
*/
|
|
|
|
|
try {
|
|
|
|
|
webFrame.executeJavaScript('(function(){try{window.alert=function(){};window.confirm=function(){return false;};window.prompt=function(){return null;};}catch(e){}})();');
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
const OVERLAY_ATTR = 'data-bibleapp-overlay'; // our own tap flash; never published, never observed
|
|
|
|
|
const send = (ch, data) => { try { ipcRenderer.sendToHost(ch, data); } catch (_) {} };
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ settings pushed by the app
|
|
|
|
|
/*
|
|
|
|
|
* The scanner arrives as SOURCE over IPC instead of being copied into this file: a sandboxed preload
|
|
|
|
|
* cannot require the app's files, and a second copy would drift from the one ProPresenter Sync uses.
|
|
|
|
|
* It is evaluated here, in this isolated world, so the page never sees it.
|
|
|
|
|
*/
|
|
|
|
|
let Scan = null;
|
|
|
|
|
let tapsOn = true;
|
|
|
|
|
let wantDom = false;
|
|
|
|
|
|
|
|
|
|
ipcRenderer.on('bibleapp:init', (_e, cfg) => {
|
|
|
|
|
cfg = cfg || {};
|
|
|
|
|
if (typeof cfg.taps === 'boolean') tapsOn = cfg.taps;
|
|
|
|
|
if (cfg.scanner && !Scan) {
|
|
|
|
|
try {
|
|
|
|
|
const root = {};
|
|
|
|
|
new Function('globalThis', cfg.scanner)(root);
|
|
|
|
|
if (root.ScriptureScan && root.ScriptureScan.scanAll && root.BibleBooks) Scan = root;
|
|
|
|
|
} catch (_) { Scan = null; }
|
|
|
|
|
}
|
|
|
|
|
if (typeof cfg.wantDom === 'boolean') setWantDom(cfg.wantDom);
|
|
|
|
|
reportScroll(true);
|
|
|
|
|
});
|
|
|
|
|
ipcRenderer.on('bibleapp:taps', (_e, on) => { tapsOn = !!on; });
|
|
|
|
|
ipcRenderer.on('bibleapp:want-dom', (_e, on) => setWantDom(!!on));
|
|
|
|
|
ipcRenderer.on('bibleapp:dom-now', () => { if (wantDom) publishDom(true); });
|
|
|
|
|
ipcRenderer.on('bibleapp:scroll', (_e, cmd) => doScroll(cmd || {}));
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ scroll
|
|
|
|
|
function scroller() { return document.scrollingElement || document.documentElement; }
|
|
|
|
|
function maxScroll() { const s = scroller(); return s ? Math.max(0, s.scrollHeight - s.clientHeight) : 0; }
|
|
|
|
|
|
|
|
|
|
function doScroll(cmd) {
|
|
|
|
|
const s = scroller();
|
|
|
|
|
if (!s) return;
|
|
|
|
|
let y = null;
|
|
|
|
|
if (cmd.edge === 'top') y = 0;
|
|
|
|
|
else if (cmd.edge === 'bottom') y = maxScroll();
|
|
|
|
|
else if (typeof cmd.to === 'number') y = cmd.to;
|
|
|
|
|
else if (typeof cmd.by === 'number') y = s.scrollTop + cmd.by;
|
|
|
|
|
else if (typeof cmd.pages === 'number') y = s.scrollTop + cmd.pages * window.innerHeight * 0.85;
|
|
|
|
|
if (y === null || !isFinite(y)) return;
|
|
|
|
|
// 'instant' for a finger dragging on the phone (it restates the position every few ms); smooth for a
|
|
|
|
|
// button press. Explicit either way, so a page's own `scroll-behavior` cannot turn a drag into mush.
|
|
|
|
|
window.scrollTo({ top: Math.max(0, Math.min(maxScroll(), y)), behavior: cmd.smooth ? 'smooth' : 'instant' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let lastY = -1, lastMax = -1, scrollTimer = 0;
|
|
|
|
|
function reportScroll(force) {
|
|
|
|
|
const s = scroller();
|
|
|
|
|
if (!s) return;
|
|
|
|
|
const y = Math.round(s.scrollTop), max = Math.round(maxScroll());
|
|
|
|
|
if (!force && y === lastY && max === lastMax) return;
|
|
|
|
|
lastY = y; lastMax = max;
|
|
|
|
|
send('bibleapp:scroll', { y: y, max: max, vh: window.innerHeight });
|
|
|
|
|
}
|
|
|
|
|
// A timer, not rAF: rAF stops whenever the guest is not painting, and the booth would stop following.
|
|
|
|
|
function reportScrollSoon() { if (!scrollTimer) scrollTimer = setTimeout(() => { scrollTimer = 0; reportScroll(false); }, 16); }
|
|
|
|
|
window.addEventListener('scroll', reportScrollSoon, { capture: true, passive: true });
|
|
|
|
|
window.addEventListener('resize', reportScrollSoon);
|
|
|
|
|
window.addEventListener('load', () => reportScroll(true));
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ text fields -> the app's keyboard
|
|
|
|
|
function isEditable(el) {
|
|
|
|
|
if (!el || el.nodeType !== 1) return false;
|
|
|
|
|
if (el.isContentEditable) return true;
|
|
|
|
|
if (el.disabled || el.readOnly) return false;
|
|
|
|
|
if (el.tagName === 'TEXTAREA') return true;
|
|
|
|
|
if (el.tagName !== 'INPUT') return false;
|
|
|
|
|
return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].indexOf(String(el.type || 'text').toLowerCase()) !== -1;
|
|
|
|
|
}
|
|
|
|
|
function editableFrom(target) {
|
|
|
|
|
if (isEditable(target)) return target;
|
|
|
|
|
const el = target && target.closest ? target.closest('input, textarea, [contenteditable]') : null;
|
|
|
|
|
return isEditable(el) ? el : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* The app's keyboard is the only one that should appear. A finger on a field would otherwise also raise
|
|
|
|
|
* the Windows touch keyboard over the stage; inputmode="none" is the browser's own switch for that. It is
|
|
|
|
|
* set just before the field takes focus and put back as it leaves, so the page is never left changed.
|
|
|
|
|
*/
|
|
|
|
|
const savedInputMode = new WeakMap();
|
|
|
|
|
function quietOsKeyboard(el) {
|
|
|
|
|
if (!el || savedInputMode.has(el)) return;
|
|
|
|
|
savedInputMode.set(el, el.getAttribute('inputmode'));
|
|
|
|
|
el.setAttribute('inputmode', 'none');
|
|
|
|
|
}
|
|
|
|
|
function restoreOsKeyboard(el) {
|
|
|
|
|
if (!el || !savedInputMode.has(el)) return;
|
|
|
|
|
const v = savedInputMode.get(el);
|
|
|
|
|
savedInputMode.delete(el);
|
|
|
|
|
if (v === null) el.removeAttribute('inputmode'); else el.setAttribute('inputmode', v);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 21:17:16 -04:00
|
|
|
/*
|
|
|
|
|
* Where the field is, so the app can open its keyboard right under it: the stage TV is portrait, and a
|
|
|
|
|
* keyboard parked at the bottom of a 75" screen is a reach to the floor. `field` tells one field from
|
|
|
|
|
* another, so focus moving to a different one (Tab, the next memorize blank) can move the keyboard too.
|
|
|
|
|
*/
|
|
|
|
|
let fieldSeq = 0;
|
|
|
|
|
const fieldIds = new WeakMap();
|
|
|
|
|
function fieldInfo(el) {
|
|
|
|
|
if (!fieldIds.has(el)) fieldIds.set(el, ++fieldSeq);
|
|
|
|
|
const r = el.getBoundingClientRect();
|
|
|
|
|
return { field: fieldIds.get(el), rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 20:25:15 -04:00
|
|
|
/*
|
|
|
|
|
* Only a TAP raises the keyboard. A page that focuses its own search box as it loads must not throw a
|
|
|
|
|
* keyboard over the stage while nobody is touching it; focus moving between fields (Tab, the next
|
|
|
|
|
* memorize blank) keeps an open keyboard open; focus moving to anything that is not a field — the site
|
|
|
|
|
* focuses its results heading after a search — puts it away, so the results are not covered.
|
|
|
|
|
*/
|
|
|
|
|
window.addEventListener('pointerdown', (e) => {
|
|
|
|
|
const el = editableFrom(e.target);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
quietOsKeyboard(el);
|
2026-10-21 21:17:16 -04:00
|
|
|
send('bibleapp:editable', Object.assign({ on: true, tap: true }, fieldInfo(el)));
|
2026-10-21 20:25:15 -04:00
|
|
|
}, true);
|
|
|
|
|
document.addEventListener('focusin', (e) => {
|
2026-10-21 21:17:16 -04:00
|
|
|
const el = e.target;
|
|
|
|
|
if (!isEditable(el)) { send('bibleapp:editable', { on: false, tap: false }); return; }
|
|
|
|
|
// Measured a frame later: focusing can scroll the field into view first.
|
|
|
|
|
requestAnimationFrame(() => send('bibleapp:editable', Object.assign({ on: true, tap: false }, fieldInfo(el))));
|
2026-10-21 20:25:15 -04:00
|
|
|
}, true);
|
|
|
|
|
document.addEventListener('focusout', (e) => {
|
|
|
|
|
restoreOsKeyboard(e.target);
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
// The app itself took focus (pastor tapped the tab bar): that is the app's business, not the page's.
|
|
|
|
|
if (!document.hasFocus()) return;
|
|
|
|
|
if (!isEditable(document.activeElement)) send('bibleapp:editable', { on: false, tap: false });
|
|
|
|
|
}, 0);
|
|
|
|
|
}, true);
|
|
|
|
|
|
2026-10-22 09:28:59 -04:00
|
|
|
// ------------------------------------------------------------------ no focus highlight
|
|
|
|
|
/*
|
|
|
|
|
* Nothing on the page shows that it has focus. The app's keyboard is outside the page, so every key press
|
|
|
|
|
* takes focus from the page for a moment and gives it straight back: a site's focus style would flash
|
|
|
|
|
* with each letter — and on stage it is clutter anyway. Outline rings (the browser's own and any site's)
|
|
|
|
|
* go with one rule in browser-tab.js's CLEAN_CSS; this takes the rest — a border that brightens, a glow —
|
|
|
|
|
* out of the site's own :focus / :focus-within / :focus-visible rules, and leaves everything else those
|
|
|
|
|
* rules do: a menu that opens on focus still opens. Sites add rules as they go (CSS-in-JS, late
|
|
|
|
|
* stylesheets), so this runs again just before anything can take focus. Another origin's stylesheets
|
|
|
|
|
* cannot be read and keep theirs; the outline rule still covers their rings.
|
|
|
|
|
*/
|
|
|
|
|
const FOCUS_LOOK = /^(?:outline|box-shadow|border)(?:-|$)/;
|
|
|
|
|
const seenRules = new WeakSet();
|
|
|
|
|
|
|
|
|
|
// Split at commas that are not inside brackets or quotes: "a:hover, b:is(c, d)" -> 2; "x:1; y:f(a;b)" -> 2.
|
|
|
|
|
function splitTop(text, sep) {
|
|
|
|
|
const out = [];
|
|
|
|
|
let depth = 0, quote = '', start = 0;
|
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
|
const c = text[i];
|
|
|
|
|
if (quote) { if (c === '\\') i++; else if (c === quote) quote = ''; continue; }
|
|
|
|
|
if (c === '"' || c === "'") quote = c;
|
|
|
|
|
else if (c === '(' || c === '[') depth++;
|
|
|
|
|
else if (c === ')' || c === ']') depth--;
|
|
|
|
|
else if (c === sep && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
|
|
|
|
|
}
|
|
|
|
|
out.push(text.slice(start).trim());
|
|
|
|
|
return out.filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
// Styled BECAUSE it has focus. ":not(:focus)" styles a field at rest, so it does not count.
|
|
|
|
|
function focusDriven(sel) {
|
|
|
|
|
return /:focus(?:-within|-visible)?(?![\w-])/.test(sel.replace(/:not\((?:[^()]|\([^()]*\))*\)/g, ''));
|
|
|
|
|
}
|
|
|
|
|
function isLook(decl) { return FOCUS_LOOK.test(decl.slice(0, decl.indexOf(':')).trim().toLowerCase()); }
|
|
|
|
|
|
|
|
|
|
function stripFocusLook(list, owner) {
|
|
|
|
|
for (let i = 0; i < list.length; i++) {
|
|
|
|
|
const r = list[i];
|
|
|
|
|
if (r.cssRules && r.cssRules.length) stripFocusLook(r.cssRules, r); // @media, @supports, @layer, nesting
|
|
|
|
|
if (!r.selectorText || !r.style || seenRules.has(r)) continue;
|
|
|
|
|
seenRules.add(r);
|
|
|
|
|
const sels = splitTop(r.selectorText, ',');
|
|
|
|
|
const focus = sels.filter(focusDriven);
|
|
|
|
|
if (!focus.length) continue;
|
|
|
|
|
// Declarations as written (cssText), so a shorthand built on var() survives intact.
|
|
|
|
|
const decls = splitTop(r.style.cssText, ';');
|
|
|
|
|
const keep = decls.filter((d) => !isLook(d));
|
|
|
|
|
if (keep.length === decls.length) continue;
|
|
|
|
|
if (focus.length === sels.length) { r.style.cssText = keep.join('; '); continue; }
|
|
|
|
|
// Shared with selectors that are not about focus ("a:hover, a:focus"): those keep the whole rule, and
|
|
|
|
|
// the focus ones move to a copy without the look.
|
|
|
|
|
r.selectorText = sels.filter((s) => !focusDriven(s)).join(', ');
|
|
|
|
|
if (!keep.length) continue;
|
|
|
|
|
try { owner.insertRule(focus.join(', ') + ' { ' + keep.join('; ') + ' }', i + 1); i++; seenRules.add(list[i]); } catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function hideFocusLook() {
|
|
|
|
|
const sheets = [...document.styleSheets, ...(document.adoptedStyleSheets || [])];
|
|
|
|
|
for (const sh of sheets) { try { stripFocusLook(sh.cssRules, sh); } catch (_) {} } // another origin's: unreadable
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('DOMContentLoaded', hideFocusLook);
|
|
|
|
|
window.addEventListener('load', hideFocusLook);
|
|
|
|
|
window.addEventListener('pointerdown', hideFocusLook, true);
|
|
|
|
|
document.addEventListener('focusin', hideFocusLook, true);
|
|
|
|
|
|
2026-10-21 20:25:15 -04:00
|
|
|
// ------------------------------------------------------------------ activity + five fingers
|
|
|
|
|
let lastActivity = 0;
|
|
|
|
|
function activity() {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - lastActivity < 1000) return;
|
|
|
|
|
lastActivity = now;
|
|
|
|
|
send('bibleapp:activity');
|
|
|
|
|
}
|
|
|
|
|
['pointerdown', 'wheel', 'keydown', 'touchstart', 'scroll'].forEach((ev) => {
|
|
|
|
|
window.addEventListener(ev, activity, { capture: true, passive: true });
|
|
|
|
|
});
|
|
|
|
|
const touchIds = new Set();
|
|
|
|
|
window.addEventListener('pointerdown', (e) => {
|
|
|
|
|
if (e.pointerType !== 'touch') return;
|
|
|
|
|
touchIds.add(e.pointerId);
|
|
|
|
|
if (touchIds.size >= 5) send('bibleapp:five-finger');
|
|
|
|
|
}, true);
|
|
|
|
|
['pointerup', 'pointercancel'].forEach((ev) => window.addEventListener(ev, (e) => { if (e.pointerType === 'touch') touchIds.delete(e.pointerId); }, true));
|
|
|
|
|
window.addEventListener('blur', () => touchIds.clear());
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ scripture under a tap
|
|
|
|
|
const BLOCK = /^(block|flex|inline-flex|grid|inline-grid|list-item|table|table-cell|table-caption|flow-root)$/;
|
|
|
|
|
// The nearest block around the tapped text: a reference split across spans ("<b>John</b> 3:16") is still
|
|
|
|
|
// one run of text there, where the single text node would only hold half of it.
|
|
|
|
|
function blockAround(node) {
|
|
|
|
|
let el = node.nodeType === 1 ? node : node.parentElement;
|
|
|
|
|
for (let i = 0; el && i < 6; i++, el = el.parentElement) {
|
|
|
|
|
if (BLOCK.test(getComputedStyle(el).display)) return el;
|
|
|
|
|
}
|
|
|
|
|
return node.parentElement || document.body;
|
|
|
|
|
}
|
|
|
|
|
function textOf(block) {
|
|
|
|
|
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, {
|
|
|
|
|
acceptNode(n) {
|
|
|
|
|
const p = n.parentElement;
|
|
|
|
|
if (!p || /^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA)$/.test(p.tagName) || p.closest('[' + OVERLAY_ATTR + ']')) return NodeFilter.FILTER_REJECT;
|
|
|
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const nodes = [];
|
|
|
|
|
let text = '';
|
|
|
|
|
for (let n = walker.nextNode(); n && text.length < 6000; n = walker.nextNode()) {
|
|
|
|
|
nodes.push({ node: n, start: text.length });
|
|
|
|
|
text += n.nodeValue;
|
|
|
|
|
}
|
|
|
|
|
return { text, nodes };
|
|
|
|
|
}
|
|
|
|
|
function rangeOf(map, start, end) {
|
|
|
|
|
const at = (pos) => {
|
|
|
|
|
for (let i = map.nodes.length - 1; i >= 0; i--) {
|
|
|
|
|
const e = map.nodes[i];
|
|
|
|
|
if (pos >= e.start) return { node: e.node, off: Math.min(pos - e.start, e.node.nodeValue.length) };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
const a = at(start), b = at(end);
|
|
|
|
|
if (!a || !b) return null;
|
|
|
|
|
const r = document.createRange();
|
|
|
|
|
r.setStart(a.node, a.off);
|
|
|
|
|
r.setEnd(b.node, b.off);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
// "Isaiah 41:10 (NIV)" or a badge beside it, as the site's verse cards have.
|
|
|
|
|
function translationNear(text, end, block) {
|
|
|
|
|
const after = Scan.ScriptureScan.translationAfter(text, end);
|
|
|
|
|
if (after) return after;
|
|
|
|
|
let el = block;
|
|
|
|
|
for (let i = 0; el && i < 2; i++, el = el.parentElement) {
|
|
|
|
|
const cands = el.querySelectorAll('span, small, sup, abbr, b, strong, em, i, div');
|
|
|
|
|
for (let k = 0; k < cands.length && k < 60; k++) {
|
|
|
|
|
const c = cands[k];
|
|
|
|
|
if (c.children.length) continue;
|
|
|
|
|
const t = (c.textContent || '').trim();
|
|
|
|
|
if (t && t.length <= 8 && Scan.ScriptureScan.isTranslation(t)) return t.toUpperCase();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
function referenceAt(x, y) {
|
|
|
|
|
const caret = document.caretRangeFromPoint(x, y);
|
|
|
|
|
if (!caret || caret.startContainer.nodeType !== 3) return null;
|
|
|
|
|
const block = blockAround(caret.startContainer);
|
|
|
|
|
const map = textOf(block);
|
|
|
|
|
const entry = map.nodes.find((e) => e.node === caret.startContainer);
|
|
|
|
|
if (!entry) return null;
|
|
|
|
|
const off = entry.start + caret.startOffset;
|
|
|
|
|
const hits = Scan.ScriptureScan.scanAll(map.text);
|
|
|
|
|
for (const h of hits) {
|
|
|
|
|
if (off < h.start || off > h.end) continue;
|
|
|
|
|
const range = rangeOf(map, h.start, h.end);
|
|
|
|
|
if (!range) continue;
|
|
|
|
|
// caretRangeFromPoint snaps to the NEAREST character, even from empty space beside a line. Only a
|
|
|
|
|
// finger actually on the reference counts.
|
|
|
|
|
const pad = 8;
|
|
|
|
|
const onIt = Array.from(range.getClientRects()).some((r) => x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad);
|
|
|
|
|
if (onIt) return { ref: h.ref, range: range, translation: translationNear(map.text, h.end, block) };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
function flash(range) {
|
|
|
|
|
try {
|
|
|
|
|
const layer = document.createElement('div');
|
|
|
|
|
layer.setAttribute(OVERLAY_ATTR, '');
|
|
|
|
|
layer.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;';
|
|
|
|
|
Array.from(range.getClientRects()).forEach((r) => {
|
|
|
|
|
const d = document.createElement('div');
|
|
|
|
|
d.style.cssText = 'position:fixed;pointer-events:none;border-radius:6px;background:rgba(255,214,10,.32);' +
|
|
|
|
|
'box-shadow:0 0 0 2px rgba(255,214,10,.85);transition:opacity .45s ease .3s;' +
|
|
|
|
|
'left:' + (r.left - 4) + 'px;top:' + (r.top - 3) + 'px;width:' + (r.width + 8) + 'px;height:' + (r.height + 6) + 'px;';
|
|
|
|
|
layer.appendChild(d);
|
|
|
|
|
});
|
|
|
|
|
(document.body || document.documentElement).appendChild(layer);
|
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(() => { layer.querySelectorAll('div').forEach((d) => { d.style.opacity = '0'; }); }));
|
|
|
|
|
setTimeout(() => { try { layer.remove(); } catch (_) {} }, 1000);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
// Capture on window, so it runs before the page's own handlers — a reference inside a link opens in the
|
|
|
|
|
// app instead of navigating the page away. A tap that is not on a reference is left completely alone.
|
|
|
|
|
window.addEventListener('click', (e) => {
|
|
|
|
|
if (!tapsOn || !Scan || e.button !== 0) return;
|
|
|
|
|
if (editableFrom(e.target) || (e.target.closest && e.target.closest('select, button[type="submit"]'))) return;
|
|
|
|
|
let found = null;
|
|
|
|
|
try { found = referenceAt(e.clientX, e.clientY); } catch (_) { found = null; }
|
|
|
|
|
if (!found) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopImmediatePropagation();
|
|
|
|
|
flash(found.range);
|
|
|
|
|
const r = found.ref;
|
|
|
|
|
send('bibleapp:scripture', { book: r.book, chapter: r.chapter, verse: r.verse, verseEnd: r.verseEnd, canonical: r.canonical, translation: found.translation });
|
|
|
|
|
}, true);
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ the booth follower's copy
|
|
|
|
|
/*
|
|
|
|
|
* The booth screen re-renders the stage rather than receiving video (follower-link.js), so the page goes
|
|
|
|
|
* there as DOM. Only while a booth is linked — the app turns this on and off. Scripts are stripped (the
|
|
|
|
|
* booth must show THIS page, not re-run its search and get different results), and everything a script
|
|
|
|
|
* would have put in place is baked in: form values, which live in properties rather than attributes, and
|
|
|
|
|
* CSS-in-JS rules, which live in the CSSOM rather than in the <style> text.
|
|
|
|
|
*/
|
|
|
|
|
let domVer = 0, domTimer = 0, domDirty = true, observer = null;
|
|
|
|
|
|
|
|
|
|
function setWantDom(on) {
|
|
|
|
|
if (on === wantDom) return;
|
|
|
|
|
wantDom = on;
|
|
|
|
|
if (on) { domDirty = true; publishDom(true); }
|
|
|
|
|
}
|
|
|
|
|
function ours(n) {
|
|
|
|
|
const el = n && (n.nodeType === 1 ? n : n.parentElement);
|
|
|
|
|
return !!(el && el.closest && el.closest('[' + OVERLAY_ATTR + ']'));
|
|
|
|
|
}
|
|
|
|
|
function startObserver() {
|
|
|
|
|
if (observer || !document.documentElement) return;
|
|
|
|
|
observer = new MutationObserver((muts) => {
|
|
|
|
|
for (const m of muts) {
|
|
|
|
|
if (m.type === 'attributes' && m.attributeName === 'inputmode') continue; // our own, see quietOsKeyboard
|
|
|
|
|
if (ours(m.target)) continue;
|
|
|
|
|
if (m.type === 'childList' && [...m.addedNodes, ...m.removedNodes].length && [...m.addedNodes, ...m.removedNodes].every(ours)) continue;
|
|
|
|
|
domDirty = true;
|
|
|
|
|
reportScrollSoon(); // new content moves the bottom of the page
|
|
|
|
|
if (wantDom && !domTimer) domTimer = setTimeout(() => { domTimer = 0; publishDom(false); }, 350);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fields(root) { return root.querySelectorAll('input, textarea, select'); }
|
|
|
|
|
function fieldValue(el) {
|
|
|
|
|
if (el.tagName === 'SELECT') return el.selectedIndex;
|
|
|
|
|
if (el.type === 'checkbox' || el.type === 'radio') return !!el.checked;
|
|
|
|
|
if (el.type === 'password' || el.type === 'file') return ''; // never put a password on the program feed
|
|
|
|
|
return el.value;
|
|
|
|
|
}
|
|
|
|
|
function serialize() {
|
|
|
|
|
const live = document.documentElement;
|
|
|
|
|
const clone = live.cloneNode(true);
|
|
|
|
|
const ls = live.querySelectorAll('style'), cs = clone.querySelectorAll('style');
|
|
|
|
|
for (let i = 0; i < ls.length && i < cs.length; i++) {
|
|
|
|
|
try {
|
|
|
|
|
const rules = ls[i].sheet && ls[i].sheet.cssRules;
|
|
|
|
|
if (rules && rules.length) { let t = ''; for (const r of rules) t += r.cssText + '\n'; cs[i].textContent = t; }
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const adopted = document.adoptedStyleSheets || [];
|
|
|
|
|
if (adopted.length) {
|
|
|
|
|
let t = '';
|
|
|
|
|
adopted.forEach((sh) => { for (const r of sh.cssRules) t += r.cssText + '\n'; });
|
|
|
|
|
const st = document.createElement('style');
|
|
|
|
|
st.textContent = t;
|
|
|
|
|
(clone.querySelector('head') || clone).appendChild(st);
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
const lf = fields(live), cf = fields(clone);
|
|
|
|
|
for (let i = 0; i < lf.length && i < cf.length; i++) {
|
|
|
|
|
const a = lf[i], b = cf[i], v = fieldValue(a);
|
|
|
|
|
try {
|
|
|
|
|
if (a.tagName === 'TEXTAREA') b.textContent = v;
|
|
|
|
|
else if (a.tagName === 'SELECT') Array.from(b.options).forEach((o, k) => { if (k === v) o.setAttribute('selected', ''); else o.removeAttribute('selected'); });
|
|
|
|
|
else if (a.type === 'checkbox' || a.type === 'radio') { if (v) b.setAttribute('checked', ''); else b.removeAttribute('checked'); }
|
|
|
|
|
else b.setAttribute('value', v);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
clone.querySelectorAll('script, noscript, base, meta[http-equiv], [' + OVERLAY_ATTR + ']').forEach((n) => n.remove());
|
|
|
|
|
let head = clone.querySelector('head');
|
|
|
|
|
if (!head) { head = document.createElement('head'); clone.insertBefore(head, clone.firstChild); }
|
|
|
|
|
const base = document.createElement('base');
|
|
|
|
|
base.setAttribute('href', location.href);
|
|
|
|
|
head.insertBefore(base, head.firstChild);
|
|
|
|
|
return '<!DOCTYPE html>' + clone.outerHTML;
|
|
|
|
|
}
|
|
|
|
|
function sendInputs() {
|
|
|
|
|
try { send('bibleapp:inputs', { values: Array.from(fields(document), fieldValue) }); } catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
function publishDom(force) {
|
|
|
|
|
if (!wantDom || (!domDirty && !force) || !document.documentElement) return;
|
|
|
|
|
domDirty = false;
|
|
|
|
|
let html;
|
|
|
|
|
try { html = serialize(); } catch (_) { return; }
|
|
|
|
|
domVer++;
|
|
|
|
|
send('bibleapp:dom', { ver: domVer, html: html });
|
|
|
|
|
sendInputs();
|
|
|
|
|
}
|
|
|
|
|
// Typing changes a property, not the DOM, so it never wakes the observer: field values travel on their own,
|
|
|
|
|
// a few bytes per keystroke instead of the whole page.
|
|
|
|
|
let inputsTimer = 0;
|
|
|
|
|
function inputsSoon() { if (wantDom && !inputsTimer) inputsTimer = setTimeout(() => { inputsTimer = 0; sendInputs(); }, 60); }
|
|
|
|
|
document.addEventListener('input', inputsSoon, true);
|
|
|
|
|
document.addEventListener('change', inputsSoon, true);
|
|
|
|
|
|
|
|
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
startObserver();
|
|
|
|
|
reportScroll(true);
|
|
|
|
|
send('bibleapp:ready', { url: location.href, title: document.title });
|
|
|
|
|
});
|