/* * presentation-tools.js — Laser pointer + Spotlight overlays. * * Fully self-contained and ADDITIVE: it does NOT touch the app's drawing/tool system. * A single full-screen overlay captures pointers. Interaction model tuned for a live touch TV: * - ONE finger => drive the active tool (laser draws / spotlight band follows). Only the primary * pointer is tracked, so a second accidental touch never makes the laser jump/bounce. * - TWO fingers => scroll the content underneath (spotlight band HOLDS its position while scrolling). * * - Laser: ONE solid continuous line that fades toward the tail. The trail is stroked as a few * nested WHOLE-trail paths of increasing opacity (never per segment — that is what produced * joints, dots and bright overlap beads). The tail retracts as points expire. * - Spotlight: on selection the screen dims uniformly (no band). The band appears on first touch, * follows the finger, and when you tap elsewhere it GLIDES (~110ms) to the new spot. * * Exposed on window.presentationTools so OSC / the phone remote can drive it. */ (function () { 'use strict'; var MODE = null; // 'laser' | 'spotlight' | null var overlay = null; // capture layer var canvas = null, ctx = null; // laser trail canvas var laserColor = '#ffff00'; // default yellow // Laser trail: timestamped points. Rendered as nested whole-trail strokes (see laserLoop). var laserPoints = []; // { x, y, t } var laserRAF = 0; var LASER_WIDTH = 6; // px var LASER_LIFETIME = 600; // ms — a point lives this long, then the tail retracts past it var LASER_MIN_STEP = 2; // px — dedupe so we never stack zero-length dots var LASER_MAX_POINTS = 300; var LASER_MAX_DPR = 1; // backing-store cap — see sizeCanvas for the measurements /* * Fade band table (constant — built once, never per frame). * * The trail's fade is quantised into bands (see laserLoop). Spacing them EVENLY in alpha looks * fine at the bright head but segments visibly in the faded tail, because the eye judges * brightness by RATIO: a 4% step on top of 90% is nothing, the same 4% step on top of 4% is * double the brightness. So the bands are packed toward the faint end (gamma = 2), which drops the * worst visible step from ~100% to ~18% while keeping the fade curve within 3% of the original * straight 1-age/lifetime ramp. * * band[m] = { maxAge, alpha }: stroke every point younger than maxAge at this alpha. Nested, so a * point's final opacity is the source-over product of every band covering it. */ var LASER_FADE_STEPS = 64; var LASER_FADE_GAMMA = 2; var LASER_BANDS = (function () { var N = LASER_FADE_STEPS, out = [], prevS = 0, m, S, alpha; for (m = 0; m < N; m++) { S = Math.pow((m + 0.5) / N, LASER_FADE_GAMMA); // cumulative opacity wanted at this boundary alpha = 1 - (1 - S) / (1 - prevS); // ...so this band contributes exactly this if (m === N - 1) alpha = 1; // newest slice fully solid — bright head out.push({ maxAge: LASER_LIFETIME * (1 - S), alpha: alpha }); prevS = S; } return out; })(); // Spotlight state var spotY = 0; // current band position var spotAnimRAF = 0; // glide animation frame var spotYBeforeTouch = 0; // restore point if the touch turns into a 2-finger scroll // Spotlight band height (pinch-resizable). Half-height in px, matching the CSS fallbacks. var SPOT_FEATHER = 20; // px of soft edge beyond the clear band — unchanged look var SPOT_HALF_DEFAULT = 110; // matches the --pt-half fallback in styles.css var SPOT_HALF_MIN = 40; // ~one line of scripture var SPOT_STORE_KEY = 'spotlightHalfPx'; var spotHalf = SPOT_HALF_DEFAULT; var spotSizeRAF = 0; var spotSizeSaveTimer = 0; function spotHalfMax() { return Math.max(120, Math.round(window.innerHeight * 0.45)); } function applySpotSize(h) { spotHalf = Math.max(SPOT_HALF_MIN, Math.min(spotHalfMax(), Math.round(h))); if (overlay) { overlay.style.setProperty('--pt-half', spotHalf + 'px'); overlay.style.setProperty('--pt-edge', (spotHalf + SPOT_FEATHER) + 'px'); } } function loadSpotHalf() { try { var v = parseInt(localStorage.getItem(SPOT_STORE_KEY), 10); if (isFinite(v) && v > 0) return v; } catch (_) {} return SPOT_HALF_DEFAULT; } function saveSpotHalf() { if (spotSizeSaveTimer) clearTimeout(spotSizeSaveTimer); // Debounced: a long pinch writes once, not once per frame. spotSizeSaveTimer = setTimeout(function () { spotSizeSaveTimer = 0; try { localStorage.setItem(SPOT_STORE_KEY, String(spotHalf)); } catch (_) {} }, 300); } /* * Two-finger gesture arbitration. * * Both scrolling and resizing the spotlight use two fingers, so the gesture starts UNDECIDED and * then LATCHES to one of them until a finger lifts — latching is what makes it impossible to * hijack a scroll halfway through. A scroll moves both fingers together (centroid travels, spread * stays put); a pinch changes the spread while the centroid barely moves. Thresholds are * deliberately asymmetric so anything ambiguous resolves to SCROLL, which must never break. */ var twoMode = null; // null | 'pending' | 'scroll' | 'resize' var twoStartDist = 0; var twoStartCentroidY = 0; var twoStartHalf = SPOT_HALF_DEFAULT; var RESIZE_DIST_MIN = 26; // px of spread change before a pinch is believed var SCROLL_CENT_MIN = 10; // px of centroid travel before a scroll is believed var DOMINANCE = 1.4; // one signal must beat the other by this factor // Multi-touch state var pointers = new Map(); // pointerId -> {x,y} var primaryId = null; // the single pointer driving the tool var scrolling = false; // true while 2+ fingers are down var scrollTarget = null; // element being scrolled var scrollLastY = 0; function byId(id) { return document.getElementById(id); } function now() { return (window.performance && performance.now) ? performance.now() : Date.now(); } function sizeCanvas() { if (!canvas) return; /* * Cap the laser canvas backing store at 1x device pixels. * * Measured on the stage TV (1080x1920 CSS, DPR 2): a full-DPR canvas is 2160x3840 = 8.3M pixels, * and re-compositing that layer every frame pinned the app at a locked 30fps (33.1ms median gap, * vs 16.7ms idle) — the trail visibly trailed the finger. The strokes themselves cost only * ~0.16ms, so it was never the drawing; it was the layer traffic. A laser is a soft 6px glow, * not text, so 1x costs 4x fewer pixels for no perceptible difference. */ var dpr = Math.min(window.devicePixelRatio || 1, LASER_MAX_DPR); var w = window.innerWidth, h = window.innerHeight; canvas.width = Math.max(1, Math.floor(w * dpr)); canvas.height = Math.max(1, Math.floor(h * dpr)); canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Re-clamp the spotlight band and re-measure the header: both change with rotation/font size. if (overlay && MODE === 'spotlight') { applyTopOffset(headerBottom()); applySpotSize(spotHalf); applySpot(spotY); } } function ensureOverlay() { if (overlay) return overlay; overlay = document.createElement('div'); overlay.id = 'ptOverlay'; overlay.className = 'pt-overlay'; overlay.style.display = 'none'; canvas = document.createElement('canvas'); canvas.className = 'pt-laser-canvas'; overlay.appendChild(canvas); document.body.appendChild(overlay); ctx = canvas.getContext('2d'); sizeCanvas(); window.addEventListener('resize', sizeCanvas); overlay.addEventListener('pointerdown', onDown); overlay.addEventListener('pointermove', onMove); overlay.addEventListener('pointerup', onUp); overlay.addEventListener('pointercancel', onUp); return overlay; } // ---- Laser (one solid stroke) ---- function addLaserPoint(x, y) { var last = laserPoints.length ? laserPoints[laserPoints.length - 1] : null; if (last && Math.abs(last.x - x) < LASER_MIN_STEP && Math.abs(last.y - y) < LASER_MIN_STEP) return; laserPoints.push({ x: x, y: y, t: now() }); if (laserPoints.length > LASER_MAX_POINTS) laserPoints.shift(); if (!laserRAF) laserRAF = requestAnimationFrame(laserLoop); } function clearLaser() { laserPoints.length = 0; if (laserRAF) { cancelAnimationFrame(laserRAF); laserRAF = 0; } if (ctx) ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); } function laserLoop() { if (!ctx) { laserRAF = 0; return; } var t = now(); var w = window.innerWidth, h = window.innerHeight; // Drop fully-faded points from the tail. while (laserPoints.length && (t - laserPoints[0].t) > LASER_LIFETIME) laserPoints.shift(); ctx.clearRect(0, 0, w, h); var n = laserPoints.length; if (n >= 2) { /* * SOLID LINE + COMET FADE, via nested whole-trail strokes. * * Why not the obvious approaches: drawing each segment with its own alpha (quads OR strokes) * is what produces the "connected segments" look — abutting translucent shapes leave seams at * their shared edges, and where they overlap the alpha blends TWICE, so every joint reads as a * bright bead. Stroking per-segment also puts a round cap on every point. And one full-opacity * path is seamless but has no fade. * * So: draw the trail N times, each pass a SINGLE continuous path starting further toward the * head. Canvas flattens one path into one coverage mask before compositing, so within a pass * there is nothing to double-blend and no internal edges — self-overlap and bends are free. * Pass m covers everything younger than L*(N-m)/N at alpha 1/(N-m); a point of age `a` ends up * under M+1 passes where M = floor(N*(1-a/L)), and the source-over product telescopes to * exactly (M+1)/N ~= 1 - a/L. Same linear ramp, same 600ms lifetime, same opaque head as * before — just quantised into N invisible ~4% steps instead of per-segment beads. */ ctx.globalCompositeOperation = 'source-over'; ctx.strokeStyle = laserColor; ctx.lineWidth = LASER_WIDTH; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; var N = LASER_FADE_STEPS; // Band start indices in ONE reverse pass — i only ever walks backwards (band maxAge grows as m // falls), so this stays O(n+N) per frame instead of rescanning the trail for every band. var starts = new Array(N), m, i = n - 1; for (m = N - 1; m >= 0; m--) { var maxAge = LASER_BANDS[m].maxAge; while (i > 0 && (t - laserPoints[i - 1].t) <= maxAge) i--; starts[m] = i; } for (m = 0; m < N; m++) { var s = starts[m]; if (s >= n - 1) continue; // fewer than 2 points in this band // NOTE: bands that happen to share a start index must BOTH still be stroked — the opacity a // point ends up with is the product of every band covering it, so skipping a "duplicate" // span would leave the trail dimmer than intended (worst on fast flicks, where points are // far apart and several band boundaries land between the same two points). ctx.globalAlpha = LASER_BANDS[m].alpha; ctx.beginPath(); ctx.moveTo(laserPoints[s].x, laserPoints[s].y); for (var k = s + 1; k < n; k++) ctx.lineTo(laserPoints[k].x, laserPoints[k].y); ctx.stroke(); // ONE stroke => ONE coverage mask => no seams } ctx.globalAlpha = 1; } if (laserPoints.length) { laserRAF = requestAnimationFrame(laserLoop); } else { ctx.clearRect(0, 0, w, h); laserRAF = 0; } } // ---- Spotlight ---- /* * The spotlight must NOT dim the top bar — the operator still needs to read the book/chapter title * and hit the navigation buttons mid-spotlight. Raising those elements above the overlay is not * reliable (they live inside .tab-content, which can form its own stacking context), so instead the * overlay STARTS below them. Everything above stays at full brightness and stays tappable. * * Because the overlay is then offset from the viewport top, --pt-cy (which is measured inside the * overlay) has to be written relative to that offset. spotY itself stays in viewport/client * coordinates so all the gesture maths is untouched. */ var ptTopOffset = 0; function headerBottom() { var bottom = 0; try { var bar = document.querySelector('.tab-bar'); if (bar) bottom = Math.max(bottom, bar.getBoundingClientRect().bottom); var panel = document.querySelector('.tab-panel.active') || document; var head = panel.querySelector ? panel.querySelector('.chapter-header') : null; if (head && head.offsetParent !== null) bottom = Math.max(bottom, head.getBoundingClientRect().bottom); } catch (_) {} return Math.max(0, Math.round(bottom)); } function applyTopOffset(px) { ptTopOffset = px || 0; if (overlay) overlay.style.top = ptTopOffset + 'px'; } function applySpot(y) { spotY = y; if (overlay) overlay.style.setProperty('--pt-cy', (y - ptTopOffset) + 'px'); } function cancelSpotGlide() { if (spotAnimRAF) { cancelAnimationFrame(spotAnimRAF); spotAnimRAF = 0; } } // Quick ~110ms glide to a newly-tapped spot (instead of snapping). function glideSpotTo(targetY) { cancelSpotGlide(); var fromY = spotY, t0 = now(), DUR = 110; var step = function () { var p = Math.min(1, (now() - t0) / DUR); var e = 1 - (1 - p) * (1 - p); // ease-out applySpot(fromY + (targetY - fromY) * e); spotAnimRAF = (p < 1) ? requestAnimationFrame(step) : 0; }; spotAnimRAF = requestAnimationFrame(step); } function armSpot(armed) { if (overlay) overlay.classList.toggle('pt-armed', !!armed); } // ---- Two-finger scroll passthrough ---- function findScrollable(el) { while (el && el !== document.body && el !== document.documentElement) { try { var s = window.getComputedStyle(el); if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 2) return el; } catch (_) {} el = el.parentElement; } return document.scrollingElement || document.documentElement; } function scrollTargetAt(x, y) { var prev = overlay.style.pointerEvents; overlay.style.pointerEvents = 'none'; var el = document.elementFromPoint(x, y); overlay.style.pointerEvents = prev || ''; return findScrollable(el); } function avgY() { var s = 0, n = 0; pointers.forEach(function (p) { s += p.y; n++; }); return n ? s / n : 0; } function beginScroll() { var cx = 0, cy = 0, n = 0; pointers.forEach(function (p) { cx += p.x; cy += p.y; n++; }); if (!n) return; scrollTarget = scrollTargetAt(cx / n, cy / n); scrollLastY = avgY(); scrolling = true; // Spotlight: the touch that became a scroll must NOT have moved the band — put it back. if (MODE === 'spotlight') { cancelSpotGlide(); applySpot(spotYBeforeTouch); } } function onDown(e) { e.preventDefault(); pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.size === 1) { primaryId = e.pointerId; if (MODE === 'laser') { addLaserPoint(e.clientX, e.clientY); } else if (MODE === 'spotlight') { spotYBeforeTouch = spotY; var wasArmed = overlay.classList.contains('pt-armed'); armSpot(false); if (wasArmed) { applySpot(e.clientY); } // first-ever touch: appear right here else { glideSpotTo(e.clientY); } // subsequent taps: glide there (~110ms) } } else if (pointers.size === 2) { primaryId = null; if (MODE === 'spotlight') { // Undecided: could become a scroll OR a band resize. Decide on movement, not on touchdown. twoMode = 'pending'; twoStartDist = pointerSpread() || 1; twoStartCentroidY = avgY(); twoStartHalf = spotHalf; // Whichever gesture wins, the band must not have jumped to this second touch. cancelSpotGlide(); applySpot(spotYBeforeTouch); // Resolve the scroll target NOW, while nothing has moved yet. var cx = 0, cy = 0, n = 0; pointers.forEach(function (p) { cx += p.x; cy += p.y; n++; }); scrollTarget = scrollTargetAt(cx / n, cy / n); scrollLastY = twoStartCentroidY; } else { beginScroll(); } } } function pointerSpread() { var pts = []; pointers.forEach(function (p) { pts.push(p); }); if (pts.length < 2) return 0; var dx = pts[1].x - pts[0].x, dy = pts[1].y - pts[0].y; return Math.sqrt(dx * dx + dy * dy); } function onMove(e) { if (!pointers.has(e.pointerId)) return; pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); // ---- Spotlight two-finger arbitration ---- if (MODE === 'spotlight' && twoMode && pointers.size >= 2) { if (twoMode === 'pending') { var dDist = Math.abs(pointerSpread() - twoStartDist); // fingers spreading/closing var dCent = Math.abs(avgY() - twoStartCentroidY); // fingers travelling together // Test BOTH every move; never commit merely because one threshold was crossed first. if (dDist > RESIZE_DIST_MIN && dDist > dCent * DOMINANCE) { twoMode = 'resize'; } else if (dCent > SCROLL_CENT_MIN && dCent * DOMINANCE >= dDist) { twoMode = 'scroll'; scrollLastY = avgY(); scrolling = true; } else { return; // still undecided — move nothing, so neither gesture twitches } } if (twoMode === 'resize') { if (!spotSizeRAF) { spotSizeRAF = requestAnimationFrame(function () { spotSizeRAF = 0; if (twoMode !== 'resize' || pointers.size < 2) return; var d = pointerSpread() || 1; var target = twoStartHalf * (d / twoStartDist); target = Math.max(SPOT_HALF_MIN, Math.min(spotHalfMax(), target)); // Same half-step low-pass the maps carousel uses, so the band never judders. var next = spotHalf + (target - spotHalf) * 0.5; if (Math.abs(next - target) < 0.5) next = target; applySpotSize(next); }); } return; // during a resize the band must not move } // twoMode === 'scroll' falls through to the shared scroll path below. } if (scrolling && pointers.size >= 2) { var y = avgY(); var dy = y - scrollLastY; scrollLastY = y; if (scrollTarget) { try { scrollTarget.scrollTop -= dy; } catch (_) {} } return; } if (e.pointerId === primaryId && pointers.size === 1) { if (MODE === 'laser') addLaserPoint(e.clientX, e.clientY); else if (MODE === 'spotlight') { cancelSpotGlide(); applySpot(e.clientY); } } } function onUp(e) { pointers.delete(e.pointerId); if (pointers.size < 2) { scrolling = false; scrollTarget = null; if (spotSizeRAF) { cancelAnimationFrame(spotSizeRAF); spotSizeRAF = 0; } if (twoMode === 'resize') saveSpotHalf(); twoMode = null; } if (e.pointerId === primaryId) primaryId = null; } // ---- Laser colour dropdown ---- function laserDropdown() { return byId('laserColorDropdown'); } function showLaserDropdown(show) { var d = laserDropdown(); if (d) d.classList.toggle('show', !!show); } function toggleLaserDropdown() { var d = laserDropdown(); if (d) d.classList.toggle('show'); } function setLaserColor(c) { laserColor = c || '#ffff00'; var btn = byId('laserToolBtn'); if (btn) btn.style.setProperty('color', laserColor, 'important'); var d = laserDropdown(); if (d) d.querySelectorAll('.laser-color-option').forEach(function (o) { o.classList.toggle('selected', (o.getAttribute('data-color') || '').toLowerCase() === laserColor.toLowerCase()); }); if (MODE === 'laser') setIndicator('laser'); } function updateButtons() { var l = byId('laserToolBtn'), s = byId('spotlightToolBtn'); if (l) l.classList.toggle('pt-active', MODE === 'laser'); if (s) s.classList.toggle('pt-active', MODE === 'spotlight'); if (MODE !== 'laser') showLaserDropdown(false); } // Reflect laser/spotlight in the app's compact tool indicator. function setIndicator(kind) { try { var ind = byId('toolIndicator'); if (!ind) return; var icon = ind.querySelector('i'); var label = ind.querySelector('span') || (function () { var sp = document.createElement('span'); ind.appendChild(sp); return sp; })(); if (kind === 'laser') { if (icon) { icon.className = 'fas fa-circle-dot'; icon.style.color = laserColor; } label.textContent = 'Laser'; label.style.color = laserColor; } else if (kind === 'spotlight') { if (icon) { icon.className = 'fas fa-bullseye'; icon.style.color = '#cccccc'; } label.textContent = 'Spotlight'; label.style.color = '#cccccc'; } // Deliberately does NOT strip .selected off the drawing-tool buttons. That class belongs to // app.js, which re-adds it from currentTool at dozens of call sites (tab switch, colour pick, // undo/redo). Two writers with no shared state is what made the pen's outline flicker away and // come back mid-laser. setMode() sets a body class instead, and one CSS rule hides the ring for // as long as an overlay owns the screen — single writer, nothing to fight over. } catch (_) {} } function resetPointerState() { pointers.clear(); primaryId = null; scrolling = false; scrollTarget = null; twoMode = null; if (spotSizeRAF) { cancelAnimationFrame(spotSizeRAF); spotSizeRAF = 0; } cancelSpotGlide(); } // Laser/spotlight follow the SAME rules as pen/highlighter: only on tabs where drawing is // allowed. On other tabs the full-screen overlay would also swallow every tap (e.g. the Ask // tab's mic button), so it must never be active there. var NO_DRAW_MODES = ['announcements', 'graphics', 'maps', 'prayer', 'ask']; function activeTabMode() { try { var a = window.bibleApp; var t = a && a.tabs && a.tabs.find(function (x) { return x.id === a.activeTabId; }); return t ? t.mode : null; } catch (_) { return null; } } /* * The book / chapter / verse pickers are a MENU, not something you present over. The pen, * highlighter and eraser already ignore them; laser and spotlight must too, or a stray tap dims the * screen (or draws) while the operator is just choosing a passage. */ function onSelectionScreen() { try { var app = window.bibleApp; if (!app || !app.tabs) return false; var tab = app.tabs.find(function (t) { return t.id === app.activeTabId; }); if (!tab || (tab.mode !== 'bible' && tab.mode !== 'ppsync')) return false; if (!tab.book) return true; // nothing picked yet, so the book list is up if (tab.isSelectingVerses) return true; // selectorContainer is what the header back arrow reveals; the *Cards grids cover the // individual picker steps. Any of them on screen means the operator is choosing, not presenting. var ids = ['selectorContainer', 'bookCards', 'chapterCards', 'verseCards', 'selectionContent']; for (var i = 0; i < ids.length; i++) { var el = document.getElementById(ids[i] + tab.id); if (el && el.offsetParent !== null) return true; // a picker grid is actually on screen } return false; } catch (_) { return false; } } function drawingAllowedHere() { return NO_DRAW_MODES.indexOf(activeTabMode()) === -1 && !onSelectionScreen(); } // Per-tab laser colour, mirroring the pen: whiteboard = white, notes = black, bible (and any // other drawing tab) = the user's picked colour (default yellow). User picks made on a forced // tab stick until the next tab change re-applies the tab default. var userLaserColor = '#ffff00'; var lastColorMode = null; function applyTabLaserColor(mode) { if (mode === 'whiteboard') setLaserColor('#fefefe'); else if (mode === 'notes') setLaserColor('#111111'); else setLaserColor(userLaserColor); } /* * Picking a normal tool must drop out of laser/spotlight. * * Otherwise the full-screen overlay stays up swallowing every touch, so the pen looks broken and * the screen stays dimmed — with no obvious way back. Listen in the CAPTURE phase on the document * so it works for the tool rail AND the floating dock (which is rebuilt/cloned), and match on the * data-tool attribute the buttons already carry. * * Only actual TOOLS exit: undo / redo / trash / menu are actions and leave the spotlight alone. */ /* * ---- ONE delegate for every tool tap ---- * * This replaced three fragile paths that all failed on the actual touchscreen: * 1. laser/spotlight were activated by a 'click' handler bound to the button. On touch the app * selects tools on 'touchend' and calls preventDefault, which can stop the click ever being * synthesised — so a tap on spotlight sometimes did nothing and the previous tool looked STUCK. * 2. the "any other tool cancels the mode" wiring was a querySelectorAll(...).forEach at startup, * so it only covered buttons that existed then, and it was click-only for the same reason. * 3. nothing matched the floating dock's own buttons (they use data-qd, not data-tool). * * Now: listen in the CAPTURE phase for BOTH pointerdown and click, on the document, and resolve the * tool from data-tool / data-qd / id. pointerdown always arrives on touch, click covers the dock's * realClick() forwarding and a mouse. A short same-tool guard stops the two events from * double-toggling (which would turn a tool on and straight back off). */ var TOOLS_THAT_EXIT = ['pen', 'highlight', 'highlighter', 'erase', 'eraser', 'select']; var TOOL_BTN_IDS = { penToolBtn: 'pen', highlightToolBtn: 'highlight', eraseToolBtn: 'erase', selectToolBtn: 'select', penBtn: 'pen', highlightBtn: 'highlight', eraseBtn: 'erase', selectBtn: 'select', laserToolBtn: 'laser', spotlightToolBtn: 'spotlight' }; var lastToolTap = { tool: null, t: 0 }; function resolveTappedTool(target) { try { if (!target || !target.closest) return null; var btn = target.closest('[data-tool], [data-qd], button'); if (!btn) return null; var t = (btn.getAttribute('data-tool') || btn.getAttribute('data-qd') || '').toLowerCase(); if (!t && btn.id) t = TOOL_BTN_IDS[btn.id] || ''; return t || null; } catch (_) { return null; } } function onToolTap(e) { // The floating dock wires its OWN handler to every button it draws, and that handler already calls // laser()/spotlight() and forwards drawing tools to the top rail. Acting here as well double-handled // one tap: the spotlight was armed on pointerdown and then toggled straight back off by the dock's // click (so it took two taps), and the laser was armed on pointerdown, which made the dock read the // same tap as a second tap and open the swatches immediately. Let the dock own its buttons. try { if (e.target && e.target.closest && e.target.closest('.quick-dock')) return; } catch (_) {} /* * Only a deliberate tool change may drop the laser/spotlight. Two things that are NOT one: * * - The tap that OPENED the tool rail. The rail slides in (and on the first open of a session it is * still being positioned), so a tool button can land under the finger between pointerdown and * click — the click then hits a tool the operator never aimed at. app.js already recognises this * and ignores such taps via toolRailInputGuardUntil; this module has to respect the same window, * or the spotlight dies on a tap that the app itself is deliberately discarding. That mismatch is * why re-opening the rail switched the spotlight off, and only on the first open of a session. * - Housekeeping clicks the app fires at tool buttons itself (e.g. forcing the highlighter back to * the pen on a tab switch), which run inside runAutomaticOperation. */ try { if (window.toolRailInputGuardUntil && Date.now() < window.toolRailInputGuardUntil) return; } catch (_) {} try { if (window.__isAutomaticOperation) return; } catch (_) {} var tool = resolveTappedTool(e.target); if (!tool) return; var isMine = (tool === 'laser' || tool === 'spotlight'); var exits = TOOLS_THAT_EXIT.indexOf(tool) !== -1; if (!isMine && !exits) return; // menu / undo / redo / trash: leave the mode alone var t = Date.now(); if (lastToolTap.tool === tool && (t - lastToolTap.t) < 400) return; // same tap seen twice // Only a real finger arms the guard. app.js fires synthetic .click()s at the pen button (the // highlighter hard-reset on every tab switch); if those armed it, the operator's very next real tap // on that same button would be swallowed and the tool would appear stuck. if (e.isTrusted !== false) lastToolTap = { tool: tool, t: t }; if (exits) { if (MODE) { // Flag this tap as the one that put the overlay away. app.js reads (and consumes) it so the tap // counts as picking the tool back up, not as a second tap on an already-active tool — which is // what popped the colour swatches open uninvited. try { window.__ptLeftOverlayAt = t; } catch (_) {} setMode(null); } return; } if (tool === 'laser') { // First tap activates only (matches pen/highlighter). A later tap opens the colours. if (MODE !== 'laser') { setMode('laser'); showLaserDropdown(false); } else { toggleLaserDropdown(); } return; } if (tool === 'spotlight') { // setMode toggles, so tapping the active spotlight puts it away again. The spotlight has no // colours to open, so that second tap has nothing else to do — and it is the natural way to // finish with it without hunting for another tool. setMode('spotlight'); return; } } document.addEventListener('pointerdown', onToolTap, true); document.addEventListener('click', onToolTap, true); // Auto-off when the operator switches to a no-draw tab while a tool is active, // and re-apply the tab's laser colour whenever the tab type changes. setInterval(function () { var mode = activeTabMode(); // Covers BOTH cases: switching to a no-draw tab, and backing out to the book/chapter/verse // pickers while a tool is still active. if (MODE && !drawingAllowedHere()) setMode(null); if (mode && mode !== lastColorMode) { lastColorMode = mode; applyTabLaserColor(mode); } }, 400); // Toggle: calling with the current mode turns it off. function setMode(m) { ensureOverlay(); // Never ACTIVATE on a tab that doesn't allow drawing (turning OFF is always allowed). if (m && MODE !== m && !drawingAllowedHere()) m = null; MODE = (MODE === m) ? null : m; clearLaser(); resetPointerState(); if (MODE === 'laser') { overlay.className = 'pt-overlay pt-laser'; applyTopOffset(0); // laser canvas maps clientX/Y directly, so it must start at the viewport top overlay.style.display = 'block'; sizeCanvas(); } else if (MODE === 'spotlight') { // Armed = uniform dim, no bright band until the first touch. overlay.className = 'pt-overlay pt-spotlight pt-armed'; overlay.style.display = 'block'; // Keep the tab bar + book/chapter title row OUT of the dimming (and still tappable). applyTopOffset(headerBottom()); spotY = Math.round(window.innerHeight / 2); applySpot(spotY); // Band HEIGHT is a remembered preference (pinch to change it). Position is not remembered — // the app always starts fresh. applySpotSize(loadSpotHalf()); } else { overlay.className = 'pt-overlay'; applyTopOffset(0); overlay.style.display = 'none'; } updateButtons(); // The one source of truth for "an overlay tool owns the screen". CSS keys the drawing tools' // selected ring off this, so the ring can be hidden without touching classes app.js maintains. try { document.body.classList.toggle('pt-tool-active', !!MODE); } catch (_) {} if (MODE) setIndicator(MODE); else if (typeof window.updateToolUI === 'function') { try { window.updateToolUI(); } catch (_) {} } } function wire() { ensureOverlay(); var l = byId('laserToolBtn'), s = byId('spotlightToolBtn'), d = laserDropdown(); // Laser + spotlight activation now lives in the onToolTap delegate (pointerdown AND click), so it // also works on the touchscreen and from the floating dock. Do NOT re-add click handlers here: // two paths would double-toggle the mode. if (d) d.querySelectorAll('.laser-color-option').forEach(function (opt) { opt.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); userLaserColor = opt.getAttribute('data-color') || '#ffff00'; // explicit pick = new preference setLaserColor(userLaserColor); if (MODE !== 'laser') setMode('laser'); showLaserDropdown(false); }); }); document.addEventListener('click', function (e) { var dd = laserDropdown(); if (dd && dd.classList.contains('show') && !dd.contains(e.target) && !(l && l.contains(e.target))) showLaserDropdown(false); }); // (Cancel-on-other-tool is handled by the onToolTap delegate — no per-button wiring, so buttons // created later, like the floating dock's, are covered too.) document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && MODE) setMode(null); }); setLaserColor(laserColor); } window.presentationTools = { laser: function () { setMode('laser'); }, spotlight: function () { setMode('spotlight'); }, off: function () { setMode(null); }, getMode: function () { return MODE; }, setLaserColor: setLaserColor, // Spotlight band height, so Settings / OSC / the phone remote can drive it without reaching // into internals. Half-height in px; a pinch on the TV writes the same value. getSpotlightHalf: function () { return spotHalf; }, setSpotlightHalf: function (px) { applySpotSize(px); saveSpotHalf(); return spotHalf; } }; if (document.readyState !== 'loading') wire(); else document.addEventListener('DOMContentLoaded', wire); })();