From b520547b873cb263141ce4833fd8a8af7bd5e0d4 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 9 Nov 2026 23:31:45 -0500 Subject: [PATCH] Harden panel anchoring and grid layout against late rotation geometry - panel-anchor: a focused checkbox, radio, range or button no longer holds the keyboard correction (it never raises a keyboard and nothing moves focus off it, so the hold could outlive later rotations). Any hidden focused form control is now blurred, not just text fields. - panel-anchor: resample at 150/400/800/1500ms after a viewport event instead of once at 300ms, and also resync on the orientation media query flipping. - grid-view: re-check geometry 1.5s after a resize and on orientation media query changes; keep the timers on the instance so a rebuilt handler cancels the previous one's; skip checks while the grid is hidden. Co-Authored-By: Claude Opus 5.5 --- .../frontend/static/javascript/grid-view.js | 63 +++++++++------ .../static/javascript/panel-anchor.js | 65 ++++++++++++--- tests/frontend/panel-anchor.test.js | 80 +++++++++++++++++++ 3 files changed, 173 insertions(+), 35 deletions(-) diff --git a/photomap/frontend/static/javascript/grid-view.js b/photomap/frontend/static/javascript/grid-view.js index 69f342ca..84846e00 100644 --- a/photomap/frontend/static/javascript/grid-view.js +++ b/photomap/frontend/static/javascript/grid-view.js @@ -564,34 +564,51 @@ class GridViewManager { } setupGridResizeHandler() { - let resizeTimeout; - + // The timers live on the instance, not in this closure: a rebuild calls + // this again, and the new handler has to be able to cancel checks the + // previous one left pending. const handleResize = async () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(async () => { - if (!state.gridViewActive) { - return; - } + clearTimeout(this.resizeTimeout); + clearTimeout(this.resizeRecheckTimeout); + // Chrome on iOS fires resize before a rotation's new dimensions are in + // place, and the rotation can still be animating 300ms later. Check the + // geometry once more after it has certainly settled; when it has not + // changed since the first check this does nothing. + this.resizeRecheckTimeout = setTimeout(() => checkGeometry(), 1500); + this.resizeTimeout = setTimeout(() => checkGeometry(), 300); + }; - const newGeometry = this.calculateGridGeometry(); - - if (this.gridGeometryChanged(newGeometry)) { - // New instance first, then the reset — the order the thumbnail-size - // handler uses. The other way round, the reset built the pages at the - // old geometry and scrolled past the page it prepends; the instance - // created afterwards then started at slide 0, which is that - // previous page, and — since creating it forgets which images are - // loaded but keeps their slides — the batch loads that followed - // appended a second copy of every tile. Visible on every resize that - // changes the grid's shape, including entering and leaving - // fullscreen, as the grid coming back one page early. - this.initializeGridSwiper(); - await this.resetAllSlides(); - } - }, 300); + const checkGeometry = async () => { + // Still hidden while the grid fades in: the container measures zero + // wide, and showing it re-measures anyway. + if (!state.gridViewActive || !this.isVisible()) { + return; + } + + const newGeometry = this.calculateGridGeometry(); + + if (this.gridGeometryChanged(newGeometry)) { + // New instance first, then the reset — the order the thumbnail-size + // handler uses. The other way round, the reset built the pages at the + // old geometry and scrolled past the page it prepends; the instance + // created afterwards then started at slide 0, which is that + // previous page, and — since creating it forgets which images are + // loaded but keeps their slides — the batch loads that followed + // appended a second copy of every tile. Visible on every resize that + // changes the grid's shape, including entering and leaving + // fullscreen, as the grid coming back one page early. + this.initializeGridSwiper(); + await this.resetAllSlides(); + } }; this.addEventListener(window, "resize", handleResize); + // Flips when layout actually adopts the new orientation — on Chrome for + // iOS that can come after the resize event carried stale dimensions. + const orientationQuery = window.matchMedia?.("(orientation: portrait)"); + if (orientationQuery?.addEventListener) { + this.addEventListener(orientationQuery, "change", handleResize); + } } updateCurrentSlideHighlight(globalIndex = null) { diff --git a/photomap/frontend/static/javascript/panel-anchor.js b/photomap/frontend/static/javascript/panel-anchor.js index 446b1c6f..f6c4e07a 100644 --- a/photomap/frontend/static/javascript/panel-anchor.js +++ b/photomap/frontend/static/javascript/panel-anchor.js @@ -24,10 +24,15 @@ // than this, so nothing real is filtered out. const MIN_OVERSHOOT_PX = 24; -// A resample after the viewport has had time to settle. The iPadOS fullscreen +// Resamples after the viewport has had time to settle. The iPadOS fullscreen // exit is animated, so a sample taken mid-transition can be wrong, and the -// event that would correct it may already have fired. -const SETTLE_DELAY_MS = 300; +// event that would correct it may already have fired. Rotation is worse: +// Chrome on iOS fires resize/orientationchange before the new dimensions are +// in place, and the rotation animation can outlast a single short resample, +// leaving --visible-viewport-height pinned at the old orientation's height +// with nothing left to correct it. A few spaced samples cover the slow case; +// each is a cheap no-op once the geometry has settled. +const SETTLE_DELAYS_MS = [150, 400, 800, 1500]; // How long after a text field blurs the correction stays held. On iPad the // blur arrives while the software keyboard is still fully on screen — hiding @@ -39,16 +44,45 @@ const SETTLE_DELAY_MS = 300; const KEYBOARD_COLLAPSE_MS = 700; let anchored = []; -let settleTimer = null; +let settleTimers = []; let keyboardSettling = false; let keyboardCollapseTimer = null; -/** Can focusing this element summon the software keyboard? */ +// types that do not summon the software keyboard. A focused checkbox, +// radio, range or button must not hold the correction: nothing ever moves +// focus off it again, so the hold would outlive every later rotation. +const NON_TEXT_INPUT_TYPES = new Set([ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", +]); + +/** Is this a form control that can hold focus? */ +function isFormControl(element) { + return Boolean(element) && (element.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)); +} + +/** + * Can focusing this element summon the software keyboard? + * + * once it is hidden, so shortcuts are not left dead", () => { + document.body.insertAdjacentHTML( + "beforeend", + `
` + ); + initializePanelAnchor([panel()]); + document.getElementById("albumSelect").focus(); + + document.getElementById("dlg").style.display = "none"; + syncPanelAnchor(); + + expect(document.activeElement).not.toBe(document.getElementById("albumSelect")); + }); + + it.each(["checkbox", "radio", "range", "button"])("is not held by a focused %s input", (type) => { + document.body.insertAdjacentHTML("beforeend", ``); + initializePanelAnchor([panel()]); + document.getElementById("control").focus(); + + setVisualViewport({ height: 700 }); + window.dispatchEvent(new Event("orientationchange")); + + expect(panel().style.transform).toBe("translateY(-300px)"); + }); + + it("is still held by a focused search-type text field", () => { + document.body.insertAdjacentHTML("beforeend", ``); + initializePanelAnchor([panel()]); + document.getElementById("query").focus(); + + setVisualViewport({ height: 620 }); + syncPanelAnchor(); + + expect(panel().style.transform).toBe(""); + }); +}); + describe("the --visible-viewport-height custom property", () => { // The swiper container and slide images are sized with 100dvh, which // resolves against the same stranded layout viewport as the panels: after a