Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions photomap/frontend/static/css/swiper.css
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -83,7 +87,7 @@

.slide-image {
max-width: 100vw;
max-height: 100dvh;
max-height: var(--visible-viewport-height, 100dvh);
display: block;
margin: auto;
}
Expand Down
80 changes: 68 additions & 12 deletions photomap/frontend/static/javascript/panel-anchor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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. */
Expand All @@ -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);
Expand All @@ -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();
}
127 changes: 126 additions & 1 deletion tests/frontend/panel-anchor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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();
});
});

Expand All @@ -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 });
Expand Down