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 });