Files
bible-app/overlay-close-guard.js
T

94 lines
5.1 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/*
* overlay-close-guard.js — an X must only ever close. Isolated + additive.
*
* The bug it kills: tapping the hamburger menu's X on the book-selection screen closed the menu AND
* opened whichever book sat behind the X. Mechanism: the menu closes on pointerdown (init.js, and the
* listener is passive so it cannot preventDefault), which makes the overlay pointer-events:none while
* the finger is still down. When the finger lifts, Chrome synthesises the compatibility click and
* hit-tests the point FRESH — the overlay no longer answers, so the click lands on the book card, whose
* delegated click handler opens it.
*
* Two prongs, both on window in the CAPTURE phase so they run ahead of every existing consumer (the book
* grid, verse grid, the header delegate, the tab strip, presentation-tools) without touching any of them:
*
* 1. touchstart -> preventDefault on a close control. On a real touchscreen this cancels the whole
* compat mouse/click chain, so no stray click is ever born. This is the same technique the splash
* overlay already uses, which is why the splash never taps through.
* 2. click -> swallow ONE click that started on a close control but retargeted somewhere else. This
* covers pen, mouse, and the TVs that reportedly emit no TouchEvents at all.
*
* Both are tightly gated (one-shot, 350ms, 40px) so a deliberate second tap is never eaten.
*/
(function () {
'use strict';
// Every close control in the app. [data-overlay-close] is the forward hook: any future close button
// gets this protection by adding that one attribute.
var CLOSE_SEL = '.touch-menu-close, .word-info-close, .popup-close, .tab-close-btn, ' +
'.prayer-keyboard-close, [data-overlay-close]';
/*
* Prong 1 (cancelling the compat click on touch) applies to the hamburger menu's X and NOTHING else.
* It is the only control that hides its overlay before a click can exist, so it is the only one that
* needs the click chain cancelled.
*
* It must never be widened again: the word-popup close is wired to `click` alone, so preventing the
* touchstart there stopped the X from doing anything at all on the touchscreen. Every other close
* control is covered by prong 2 below, which allows a click ON the control and only swallows one that
* drifted off it.
*/
var CLOSE_SEL_TOUCH = '.touch-menu-close';
var armAt = 0, armX = 0, armY = 0;
function onCloseControl(target, sel) {
try { return !!(target && target.closest && target.closest(sel)); } catch (_) { return false; }
}
// Arm on pointerdown. This runs before the control's own (target-phase) listener, so we still capture
// the point even though the overlay is hidden immediately afterwards.
window.addEventListener('pointerdown', function (e) {
if (!onCloseControl(e.target, CLOSE_SEL)) return;
armAt = Date.now(); armX = e.clientX; armY = e.clientY;
}, true);
// Prong 1: on real touch, stop the click from ever being synthesised.
window.addEventListener('touchstart', function (e) {
if (!onCloseControl(e.target, CLOSE_SEL_TOUCH)) return;
if (e.cancelable) e.preventDefault();
}, { capture: true, passive: false });
/*
* Prong 3: the same "the UI moved under my finger" fault, on the tool rail.
*
* Tapping the tool-indicator chip opens the rail and immediately hides the chip, so Chromium
* re-hit-tests the tap point when the finger lifts — and the rail is still sliding in (0.25s) with
* pointer-events already live. The click therefore lands on whatever button happens to be passing
* under the finger. Measured by tap length: ~60ms hits the pen/eraser (which switched the spotlight
* off), ~100ms hits the menu button (Settings opens by itself), and 200-500ms hits the TRASH — an
* accidental wipe of the annotations, mid-service.
*
* app.js already declares this window (toolRailInputGuardUntil) and ignores tool selections during it,
* but only its own handler honoured that. Enforce the same intent for every consumer, once, here.
*/
window.addEventListener('click', function (e) {
var until = 0;
try { until = window.toolRailInputGuardUntil || 0; } catch (_) { return; }
if (!until || Date.now() >= until) return;
// The chip is the opener; its own handler must still run.
try { if (e.target && e.target.closest && e.target.closest('#toolIndicator')) return; } catch (_) {}
try { e.preventDefault(); e.stopImmediatePropagation(); } catch (_) {}
}, true);
// Prong 2: one-shot swallow of a click that drifted off the close control.
window.addEventListener('click', function (e) {
if (!armAt) return;
var dt = Date.now() - armAt;
var near = Math.abs(e.clientX - armX) < 40 && Math.abs(e.clientY - armY) < 40;
armAt = 0; // consume: at most one click, ever
if (dt > 350 || !near) return; // a deliberate later tap is left alone
if (onCloseControl(e.target, CLOSE_SEL)) return; // a genuine click on the X itself must work
try { e.preventDefault(); e.stopImmediatePropagation(); } catch (_) {}
}, true);
})();