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?
+ *
+ *