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