Files
bible-app/browser-guest-preload.cjs
T
christianandClaude Opus 5.5 9d63e00632 Browser tab keyboard: open under the field being typed into; Enter hides it
The stage TV is portrait, and the keyboard opened at the bottom of the screen — a
reach to the floor to type into a search box at the top.

- browser-guest-preload.cjs reports the tapped field's position (and which field it
  is) with the tap, and again when focus moves to another field.
- browser-tab.js opens the keyboard directly under that field, centred on it and
  kept on screen; if there is no room under it, just above it. Focus moving to a
  different field (Tab, the next memorize blank) moves the keyboard with it.
  Re-tapping the same field leaves a dragged keyboard where it was.
- The keyboard's Enter submits to the page and then hides the keyboard, so search
  results are not covered. Tapping a field brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:17:16 -04:00

405 lines
19 KiB
JavaScript

/*
* 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);
}
/*
* 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) } };
}
/*
* 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);
send('bibleapp:editable', Object.assign({ on: true, tap: true }, fieldInfo(el)));
}, true);
document.addEventListener('focusin', (e) => {
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))));
}, 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);
// ------------------------------------------------------------------ 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 });
});