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
63 changes: 40 additions & 23 deletions photomap/frontend/static/javascript/grid-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
65 changes: 53 additions & 12 deletions photomap/frontend/static/javascript/panel-anchor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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? */
// <input> 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?
*
* <select> stays in: on iPhone its option picker can take the keyboard's
* place and shrink the visual viewport just as the keyboard does.
*/
function isTextEntry(element) {
if (!element) {
if (!isFormControl(element)) {
return false;
}
return element.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName);
if (element.tagName !== "INPUT") {
return true;
}
return !NON_TEXT_INPUT_TYPES.has((element.type || "text").toLowerCase());
}

/** Is this element actually laid out, rather than inside a display: none subtree? */
Expand Down Expand Up @@ -79,15 +113,18 @@ function isRendered(element) {
*/
function isTextEntryFocused() {
const element = document.activeElement;
if (!isTextEntry(element)) {
if (!isFormControl(element)) {
return false;
}
// Any hidden control is blurred, not only text fields: events.js ignores
// shortcuts while an INPUT or SELECT has focus, so a checkbox or select
// left focused inside a closed dialog would otherwise keep them dead.
if (!isRendered(element)) {
noteTextEntryBlur(element);
element.blur();
return false;
}
return true;
return isTextEntry(element);
}

/** Keep the correction held across the keyboard's hide animation. */
Expand Down Expand Up @@ -195,11 +232,11 @@ export function syncPanelAnchor() {
}
}

/** Resync once more after the viewport has settled, coalescing repeat calls. */
/** Resync now and again as the viewport settles, coalescing repeat calls. */
function scheduleSettleResync() {
syncPanelAnchor();
clearTimeout(settleTimer);
settleTimer = setTimeout(syncPanelAnchor, SETTLE_DELAY_MS);
settleTimers.forEach(clearTimeout);
settleTimers = SETTLE_DELAYS_MS.map((delay) => setTimeout(syncPanelAnchor, delay));
}

/**
Expand All @@ -214,6 +251,10 @@ export function initializePanelAnchor(panels) {

window.addEventListener("resize", scheduleSettleResync);
window.addEventListener("orientationchange", scheduleSettleResync);
// The orientation media query flips when layout actually adopts the new
// orientation, which on Chrome for iOS can be well after the resize and
// orientationchange events above have fired with stale dimensions.
window.matchMedia?.("(orientation: portrait)")?.addEventListener?.("change", scheduleSettleResync);
if (window.visualViewport) {
// The visible area shrinking is the signal that fires most reliably on the
// platform this exists for: the layout viewport does not change on a
Expand Down
80 changes: 80 additions & 0 deletions tests/frontend/panel-anchor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,86 @@ describe("resampling after the viewport settles", () => {
});
});

describe("rotation on Chrome for iOS", () => {
// Chrome on iOS fires resize/orientationchange before the rotated
// dimensions are in place, and the rotation animation can outlast a single
// short resample. The published height then stayed at the old
// orientation's value with no later event to correct it.
const heightVar = () => document.documentElement.style.getPropertyValue("--visible-viewport-height");

it("still converges when the new dimensions arrive after the first resample", () => {
jest.useFakeTimers();
setVisualViewport({ height: 870 });
initializePanelAnchor([panel()]);
expect(heightVar()).toBe("870px");

// Event fires with the portrait geometry still in place...
window.dispatchEvent(new Event("orientationchange"));
jest.advanceTimersByTime(400);
expect(heightVar()).toBe("870px");

// ...and landscape only lands later, with no further event.
setLayoutViewportHeight(768);
setVisualViewport({ height: 700 });
jest.advanceTimersByTime(2000);

expect(heightVar()).toBe("700px");
expect(panel().style.transform).toBe("translateY(-68px)");
jest.useRealTimers();
});

it("resyncs when the orientation media query flips", () => {
const mediaListeners = [];
window.matchMedia = () => ({ addEventListener: (_name, fn) => mediaListeners.push(fn) });
try {
initializePanelAnchor([panel()]);
setVisualViewport({ height: 700 });

mediaListeners.forEach((fn) => fn());

expect(panel().style.transform).toBe("translateY(-300px)");
} finally {
delete window.matchMedia;
}
});

it("blurs a focused <select> once it is hidden, so shortcuts are not left dead", () => {
document.body.insertAdjacentHTML(
"beforeend",
`<div id="dlg"><select id="albumSelect"><option>a</option></select></div>`
);
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", `<input id="control" type="${type}" />`);
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", `<input id="query" type="search" />`);
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
Expand Down
Loading