/* * browser-guest-preload.cjs — runs inside the web page that the Browser tab shows (the Electron * 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 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); } /* * 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', { on: true, tap: true }); }, true); document.addEventListener('focusin', (e) => { send('bibleapp:editable', { on: isEditable(e.target), tap: false }); }, 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 ("John 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