From 3507c2763d9f7177d0482af5ec5bc7707c864c1f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 11:31:00 -0400 Subject: [PATCH] fix: keep the whole photo on screen after leaving fullscreen on iPad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #382 pulled the bottom panels back into the visible area after iPadOS strands the layout viewport at its fullscreen size, but the photo itself has the same problem: the swiper container and slide images are sized with 100dvh, which resolves against that same stranded layout viewport. After leaving fullscreen the bottom ~40px of the image — the height of the returning browser chrome — hangs off the bottom of the tablet, and neither pinch nor rotation brings it back. Only landscape shows it, because that is the orientation in which a contain-fit photo actually touches the top and bottom edges; in portrait the loss falls inside the letterbox bars. panel-anchor.js already measures the overshoot; it now also publishes the visible height as a --visible-viewport-height custom property on the root element, and the three 100dvh sizing rules in swiper.css consume it with 100dvh as the fallback. Where the viewports agree the property is never set and the CSS behaves exactly as before. Swiper notices the container resize through its default ResizeObserver, so no explicit update call is needed. Sizing the photo by the correction raises the stakes on two moments where the sync previously misfired against the small panels and now would visibly bounce the whole image, so both hold the current correction instead of recomputing: - The blur edge of a text field. On iPad the blur arrives while the software keyboard is still fully on screen — hiding the search panel blurs its input first, the keyboard collapses after — so the first resyncs after blur read the keyboard as a ~360px overshoot. The hold now extends from focus until 700ms past blur, and the expiry resamples the truth. - Pinch-zoom. Clearing the correction the moment scale crosses 1.01 grew the container back to the stranded 100dvh under the user's fingers (and dropped the panels back off-screen). The overshoot has not gone away because the user zoomed, so it is held; zoom-out resyncs via the viewport resize. Holding instead of recomputing also stops a rotate-with-keyboard-up from publishing a height that mixes the held overshoot with a fresh clientHeight. Not touched: grid view sizes its rows from window.innerHeight and recomputes only on its own events, so it still under-shows the bottom row on a stranded viewport — a follow-up, not part of this fix. The 100vh modals are centered and unaffected in practice. Tests: panel-anchor.test.js covers the property being published with the correction, staying unset when the viewports agree, clearing when they re-converge, the keyboard hold across focus, blur edge and collapse, the pinch hold and its release, and the rotate-with-keyboard case. Frontend 665 passed, lint and format clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLxwVusSvKkfdJS3g9QbB9 --- photomap/frontend/static/css/swiper.css | 10 +- .../static/javascript/panel-anchor.js | 80 +++++++++-- tests/frontend/panel-anchor.test.js | 127 +++++++++++++++++- 3 files changed, 201 insertions(+), 16 deletions(-) diff --git a/photomap/frontend/static/css/swiper.css b/photomap/frontend/static/css/swiper.css index bb81d398..a2b4799a 100644 --- a/photomap/frontend/static/css/swiper.css +++ b/photomap/frontend/static/css/swiper.css @@ -1,7 +1,11 @@ /* ===== SWIPER CORE STYLES ===== */ .swiper { width: 100vw; - height: 100dvh; + /* --visible-viewport-height is set by panel-anchor.js when iPadOS strands + the layout viewport at fullscreen size after leaving fullscreen; 100dvh + resolves against that stranded viewport and would hang the bottom of the + photo off the screen. */ + height: var(--visible-viewport-height, 100dvh); box-sizing: border-box; background: #111; position: relative; @@ -16,7 +20,7 @@ .swiper-slide img { width: 100vw; - height: 100dvh; + height: var(--visible-viewport-height, 100dvh); object-fit: contain; display: block; margin: 0 auto; @@ -83,7 +87,7 @@ .slide-image { max-width: 100vw; - max-height: 100dvh; + max-height: var(--visible-viewport-height, 100dvh); display: block; margin: auto; } diff --git a/photomap/frontend/static/javascript/panel-anchor.js b/photomap/frontend/static/javascript/panel-anchor.js index 5c9acac8..1b96c563 100644 --- a/photomap/frontend/static/javascript/panel-anchor.js +++ b/photomap/frontend/static/javascript/panel-anchor.js @@ -29,17 +29,44 @@ const MIN_OVERSHOOT_PX = 24; // event that would correct it may already have fired. const SETTLE_DELAY_MS = 300; +// 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 +// the search panel blurs its input first, the keyboard collapses after — so +// the first resyncs after blur sample a viewport still shrunk by the +// keyboard. Recomputing then would briefly treat the keyboard as browser +// chrome and bounce everything sized by the correction. The keyboard's hide +// animation is well under this; the timer resyncs with the truth at the end. +const KEYBOARD_COLLAPSE_MS = 700; + let anchored = []; -let appliedOvershoot = 0; let settleTimer = null; +let keyboardSettling = false; +let keyboardCollapseTimer = null; + +/** Can focusing this element summon the software keyboard? */ +function isTextEntry(element) { + if (!element) { + return false; + } + return element.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName); +} /** Is the software keyboard likely to be the reason the viewport shrank? */ function isTextEntryFocused() { - const active = document.activeElement; - if (!active) { - return false; + return isTextEntry(document.activeElement); +} + +/** Keep the correction held across the keyboard's hide animation. */ +function noteTextEntryBlur(element) { + if (!isTextEntry(element)) { + return; } - return active.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName); + keyboardSettling = true; + clearTimeout(keyboardCollapseTimer); + keyboardCollapseTimer = setTimeout(() => { + keyboardSettling = false; + syncPanelAnchor(); + }, KEYBOARD_COLLAPSE_MS); } /** @@ -81,16 +108,40 @@ export function syncPanelAnchor() { // opening the text search dialog would otherwise fling both panels several // hundred pixels up into the middle of the photo, on top of the dialog they // belong under. There is nothing in the geometry to tell the two apart, so - // while a text field holds focus the last correction is held instead of - // recomputed. - const overshoot = isTextEntryFocused() ? appliedOvershoot : liveOvershoot(); - appliedOvershoot = overshoot; + // from focus until the keyboard has finished collapsing after blur, the + // correction is left exactly as it is — recomputing on the blur edge would + // sample the still-raised keyboard, and recomputing the published height + // from a fresh clientHeight against a held overshoot mixes two moments. + // + // Pinch-zoom holds for the same reason: zoomed in, the visual viewport is a + // window onto the page and WebKit already treats fixed elements specially. + // Recomputing per-frame would drag the panels under the user's fingers, and + // clearing would grow the photo 40px mid-gesture on a stranded viewport — + // the overshoot has not gone away just because the user zoomed. + const zoomed = window.visualViewport && window.visualViewport.scale > 1.01; + if (isTextEntryFocused() || keyboardSettling || zoomed) { + return; + } + + const overshoot = liveOvershoot(); anchored.forEach((panel) => { if (panel) { panel.style.transform = overshoot ? `translateY(${-overshoot}px)` : ""; } }); + + // The swiper container and slide images are sized with 100dvh, which + // resolves against the same stranded layout viewport the panels are anchored + // to — so after a stranded fullscreen exit the bottom of the photo hangs off + // the screen too. Publish the visible height for those rules to consume + // (they fall back to 100dvh when it is unset). + const root = document.documentElement; + if (overshoot) { + root.style.setProperty("--visible-viewport-height", `${root.clientHeight - overshoot}px`); + } else { + root.style.removeProperty("--visible-viewport-height"); + } } /** Resync once more after the viewport has settled, coalescing repeat calls. */ @@ -107,7 +158,8 @@ function scheduleSettleResync() { */ export function initializePanelAnchor(panels) { anchored = panels.filter(Boolean); - appliedOvershoot = 0; + keyboardSettling = false; + clearTimeout(keyboardCollapseTimer); window.addEventListener("resize", scheduleSettleResync); window.addEventListener("orientationchange", scheduleSettleResync); @@ -119,9 +171,13 @@ export function initializePanelAnchor(panels) { window.visualViewport.addEventListener("scroll", scheduleSettleResync); } // Focus changes bracket the software keyboard, and the held correction has - // to be recomputed once it goes away again. + // to be recomputed once it goes away again — but only after the keyboard's + // hide animation, which starts after the blur, has finished. window.addEventListener("focusin", scheduleSettleResync); - window.addEventListener("focusout", scheduleSettleResync); + window.addEventListener("focusout", (event) => { + noteTextEntryBlur(event.target); + scheduleSettleResync(); + }); syncPanelAnchor(); } diff --git a/tests/frontend/panel-anchor.test.js b/tests/frontend/panel-anchor.test.js index dec6d338..d31c84f2 100644 --- a/tests/frontend/panel-anchor.test.js +++ b/tests/frontend/panel-anchor.test.js @@ -40,6 +40,7 @@ beforeEach(() => { afterEach(() => { delete window.visualViewport; + document.documentElement.style.removeProperty("--visible-viewport-height"); }); describe("a layout viewport taller than the visible area", () => { @@ -125,6 +126,23 @@ describe("cases that must not move the panels", () => { expect(panel().style.transform).toBe(""); }); + it("holds an applied correction through a pinch instead of clearing it", () => { + // The stranded overshoot has not gone away just because the user zoomed; + // clearing it mid-gesture would grow the photo 130px under their fingers. + setVisualViewport({ height: 870 }); + initializePanelAnchor([panel()]); + expect(panel().style.transform).toBe("translateY(-130px)"); + + setVisualViewport({ height: 500, scale: 2 }); + syncPanelAnchor(); + expect(panel().style.transform).toBe("translateY(-130px)"); + + // Zoomed back out onto a viewport that has recovered, it clears. + setVisualViewport({ height: 1000 }); + syncPanelAnchor(); + expect(panel().style.transform).toBe(""); + }); + it("does nothing on a browser without visualViewport", () => { setVisualViewport(null); setLayoutViewportHeight(1000); @@ -167,7 +185,8 @@ describe("the software keyboard", () => { expect(panel().style.transform).toBe("translateY(-130px)"); }); - it("recomputes once the field is blurred again", () => { + it("recomputes once the field is blurred and the keyboard has collapsed", () => { + jest.useFakeTimers(); setVisualViewport({ height: 870 }); initializePanelAnchor([panel()]); document.getElementById("searchInput").focus(); @@ -176,9 +195,31 @@ describe("the software keyboard", () => { document.getElementById("searchInput").blur(); setVisualViewport({ height: 1000 }); + jest.runAllTimers(); + + expect(panel().style.transform).toBe(""); + jest.useRealTimers(); + }); + + it("does not treat the still-raised keyboard as chrome on the blur edge", () => { + // Hiding the search panel blurs its input while the keyboard is still + // fully on screen — the keyboard only collapses afterwards. Sampling at + // the blur would read the keyboard as a 360px overshoot and fling the + // panels (and now the photo) before growing them back. + jest.useFakeTimers(); + initializePanelAnchor([panel()]); + document.getElementById("searchInput").focus(); + setVisualViewport({ height: 640 }); syncPanelAnchor(); + document.getElementById("searchInput").blur(); + syncPanelAnchor(); expect(panel().style.transform).toBe(""); + + setVisualViewport({ height: 1000 }); + jest.runAllTimers(); + expect(panel().style.transform).toBe(""); + jest.useRealTimers(); }); }); @@ -201,6 +242,90 @@ describe("resampling after the viewport settles", () => { }); }); +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 + // stranded fullscreen exit the bottom of the photo hangs off the screen. + // swiper.css consumes this property with 100dvh as its fallback. + const heightVar = () => document.documentElement.style.getPropertyValue("--visible-viewport-height"); + + it("publishes the visible height while the layout viewport is stranded", () => { + setVisualViewport({ height: 870 }); + initializePanelAnchor([panel()]); + + expect(heightVar()).toBe("870px"); + }); + + it("is unset while the viewports agree, leaving the 100dvh fallback in force", () => { + initializePanelAnchor([panel()]); + + expect(heightVar()).toBe(""); + }); + + it("clears again once the viewport catches up", () => { + setVisualViewport({ height: 870 }); + initializePanelAnchor([panel()]); + expect(heightVar()).toBe("870px"); + + setVisualViewport({ height: 1000 }); + syncPanelAnchor(); + + expect(heightVar()).toBe(""); + }); + + it("is held with the panel correction while the software keyboard is up", () => { + setVisualViewport({ height: 870 }); + initializePanelAnchor([panel()]); + document.getElementById("searchInput").focus(); + + setVisualViewport({ height: 490 }); + syncPanelAnchor(); + + expect(heightVar()).toBe("870px"); + }); + + it("does not shrink the photo for the keyboard alone", () => { + initializePanelAnchor([panel()]); + document.getElementById("searchInput").focus(); + + setVisualViewport({ height: 620 }); + syncPanelAnchor(); + + expect(heightVar()).toBe(""); + }); + + it("does not shrink the photo on the blur edge while the keyboard is still up", () => { + jest.useFakeTimers(); + initializePanelAnchor([panel()]); + document.getElementById("searchInput").focus(); + setVisualViewport({ height: 640 }); + syncPanelAnchor(); + + document.getElementById("searchInput").blur(); + syncPanelAnchor(); + expect(heightVar()).toBe(""); + + setVisualViewport({ height: 1000 }); + jest.runAllTimers(); + expect(heightVar()).toBe(""); + jest.useRealTimers(); + }); + + it("stays untouched on a rotate with the keyboard up, not recomputed from mixed moments", () => { + // A held overshoot from portrait against a fresh landscape clientHeight + // would publish a height belonging to neither orientation. + setVisualViewport({ height: 870 }); + initializePanelAnchor([panel()]); + document.getElementById("searchInput").focus(); + + setVisualViewport({ height: 400 }); + setLayoutViewportHeight(768); + window.dispatchEvent(new Event("orientationchange")); + + expect(heightVar()).toBe("870px"); + }); +}); + describe("visibleViewportBottom", () => { it("reports the visible bottom edge, not the layout one", () => { setVisualViewport({ height: 870 });