fix: re-anchor stray iOS layout-viewport scroll that broke nav taps
This commit is contained in:
@@ -2303,6 +2303,40 @@ function setupPortraitPwaWatcher() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Layout-viewport anchor guard (iOS Safari / standalone PWA)
|
||||||
|
//
|
||||||
|
// The app is a fixed-viewport layout: body is overflow:hidden and only inner
|
||||||
|
// panes scroll, so the document itself must always sit at scroll position 0.
|
||||||
|
// iOS WebKit can still scroll the *layout viewport* behind our back — exiting
|
||||||
|
// native video fullscreen (webkitEnterFullscreen is the only fullscreen path
|
||||||
|
// on iPhone, and iOS enters it by itself when the phone rotates while a video
|
||||||
|
// plays), the on-screen keyboard revealing a focused input, or any
|
||||||
|
// scrollIntoView() walking up into <html>. Once that happens, fixed elements
|
||||||
|
// (bottom nav, mini-bar) are still *drawn* in place but their hit-testing
|
||||||
|
// regions are offset by the stray scroll amount, so taps on the nav buttons
|
||||||
|
// silently do nothing — and because the body isn't user-scrollable there is
|
||||||
|
// no gesture that can undo it. That is the "nav buttons stop working after
|
||||||
|
// playing a video" iPhone bug. Snap the document back to 0 whenever it ends
|
||||||
|
// up scrolled; skipped while an input is focused so we never fight the
|
||||||
|
// keyboard auto-scroll, then re-anchored once focus leaves the field.
|
||||||
|
function setupViewportAnchorGuard() {
|
||||||
|
const editing = () => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
|
||||||
|
};
|
||||||
|
const reanchor = () => {
|
||||||
|
if (editing()) return;
|
||||||
|
const doc = document.scrollingElement || document.documentElement;
|
||||||
|
if (window.scrollY || doc.scrollTop) window.scrollTo(0, 0);
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', reanchor);
|
||||||
|
document.addEventListener('focusout', () => setTimeout(reanchor, 50));
|
||||||
|
// Exiting native video fullscreen is the most reliable reproducer of the
|
||||||
|
// stray-scroll state; the scroll event alone doesn't always fire for it.
|
||||||
|
els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50));
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Events
|
// Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2649,8 +2683,12 @@ document.querySelectorAll('.chip').forEach((c) => {
|
|||||||
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
|
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
|
||||||
$('miniBar').addEventListener('click', () => {
|
$('miniBar').addEventListener('click', () => {
|
||||||
hideMiniBar();
|
hideMiniBar();
|
||||||
// Scroll the player into view if needed
|
// Scroll the player into view if needed. In portrait PWA, scroll only the
|
||||||
els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
// list-pane (the designated scroll container) — scrollIntoView() also
|
||||||
|
// scrolls overflow:hidden ancestors up to <html> on iOS, leaving the
|
||||||
|
// document offset and fixed-element hit testing broken.
|
||||||
|
if (isPortraitPWA()) scrollPlayerIntoViewPortrait();
|
||||||
|
else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
});
|
});
|
||||||
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); });
|
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); });
|
||||||
|
|
||||||
@@ -3104,6 +3142,7 @@ async function boot() {
|
|||||||
wireUI();
|
wireUI();
|
||||||
wireShortcutHelp();
|
wireShortcutHelp();
|
||||||
setupPortraitPwaWatcher();
|
setupPortraitPwaWatcher();
|
||||||
|
setupViewportAnchorGuard();
|
||||||
try {
|
try {
|
||||||
const loaded = await API.loadData();
|
const loaded = await API.loadData();
|
||||||
if (loaded && typeof loaded === 'object') {
|
if (loaded && typeof loaded === 'object') {
|
||||||
|
|||||||
67
tests/viewport-anchor.smoke.spec.js
Normal file
67
tests/viewport-anchor.smoke.spec.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test for the layout-viewport anchor guard.
|
||||||
|
*
|
||||||
|
* Bug: on an iPhone running the installed PWA, playing a video sometimes left
|
||||||
|
* the bottom-nav buttons unresponsive. iOS WebKit can scroll the document's
|
||||||
|
* layout viewport behind the app's back (exiting native video fullscreen,
|
||||||
|
* keyboard dismissal, scrollIntoView walking up into <html>) even though the
|
||||||
|
* body is overflow:hidden. Fixed elements are then still drawn in place but
|
||||||
|
* their hit-testing regions are offset by the stray scroll amount, so taps on
|
||||||
|
* the nav do nothing — and no user gesture can scroll the document back.
|
||||||
|
*
|
||||||
|
* The guard (setupViewportAnchorGuard in app.js) snaps the document back to 0
|
||||||
|
* whenever it ends up scrolled, except while an input is focused (so it never
|
||||||
|
* fights the on-screen keyboard); it re-anchors on blur instead.
|
||||||
|
*
|
||||||
|
* The static test page doesn't overflow, so each test injects a tall spacer
|
||||||
|
* and relaxes the overflow clamp — simulating the scrollable-document state
|
||||||
|
* iOS leaves behind.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Viewport anchor guard — stray document scroll', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.app', { state: 'attached' });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const spacer = document.createElement('div');
|
||||||
|
spacer.style.height = '3000px';
|
||||||
|
document.body.appendChild(spacer);
|
||||||
|
document.documentElement.style.overflow = 'visible';
|
||||||
|
document.body.style.overflow = 'visible';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snaps the document back to 0 after a stray window scroll', async ({ page }) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// boot() focuses the search input; release it so the guard is active.
|
||||||
|
if (document.activeElement) document.activeElement.blur();
|
||||||
|
window.scrollTo(0, 400);
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.scrollY === 0);
|
||||||
|
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves the scroll alone while an input is focused (keyboard), re-anchors on blur', async ({ page }) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById('searchInput').focus();
|
||||||
|
window.scrollTo(0, 300);
|
||||||
|
});
|
||||||
|
// Guard must not fight the keyboard-driven scroll while editing.
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.evaluate(() => document.getElementById('searchInput').blur());
|
||||||
|
await page.waitForFunction(() => window.scrollY === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-anchors when native video fullscreen exits (webkitendfullscreen)', async ({ page }) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (document.activeElement) document.activeElement.blur();
|
||||||
|
window.scrollTo(0, 250);
|
||||||
|
document.getElementById('video').dispatchEvent(new Event('webkitendfullscreen'));
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.scrollY === 0);
|
||||||
|
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user