728 lines
34 KiB
JavaScript
728 lines
34 KiB
JavaScript
/*
|
|||
|
|
* 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);
|
||
|
|
})();
|