Add swipe navigation to the photo detail page
Swipe left for older, right for newer, matching the existing arrow keys. The image tracks the finger and slides out on commit; the exit animation covers the page load, so this stays plain static-site navigation. The keyboard handler moves out of the template into static/photo-nav.js, which the existing //go:embed of static/ picks up with no generator changes. Swipes starting within 24px of a screen edge are left alone — that strip is the browser's own back-gesture. Only touch pointers are handled, so desktop drag-to-save still works, and the axis locks after 10px so vertical drags stay scrolls. With no neighbour that way the image rubber-bands instead of navigating. Also prefetch the neighbour medium JPEGs, not just their HTML, or the next page still blocks on a fresh image request. The keyboard handler now bails on modifier keys; it previously hijacked Alt+ArrowLeft, which is browser-back on Linux and Windows.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// Navigation for the single photo page: arrow keys and touch swipe.
|
||||
//
|
||||
// Both read their destinations from the neighbour links the template already
|
||||
// renders, so a photo with no newer/older sibling simply has nothing to find
|
||||
// and the gesture rubber-bands instead of navigating. If this file fails to
|
||||
// load the page still works — the links remain clickable.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var newer = document.querySelector('[data-nav="newer"]');
|
||||
var older = document.querySelector('[data-nav="older"]');
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Alt+Arrow is back/forward in most browsers; don't shadow it.
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
||||
if (e.target instanceof Element && e.target.matches('input, textarea')) return;
|
||||
if (e.key === 'ArrowLeft' && newer) location.href = newer.href;
|
||||
if (e.key === 'ArrowRight' && older) location.href = older.href;
|
||||
});
|
||||
|
||||
// ── Swipe ─────────────────────────────────────────────────────────────
|
||||
|
||||
var wrap = document.querySelector('.dimg-wrap');
|
||||
var img = wrap && wrap.querySelector('img');
|
||||
if (!wrap || !img) return;
|
||||
|
||||
var EDGE_DEAD_ZONE = 24; // px from either screen edge, left to the browser
|
||||
var AXIS_LOCK = 10; // px of travel before we decide scroll vs swipe
|
||||
var MIN_TRAVEL = 60; // px needed to commit
|
||||
var TRAVEL_RATIO = 0.18; // ...or this fraction of the viewport, whichever is more
|
||||
var MIN_VELOCITY = 0.5; // px/ms; a fast flick commits below MIN_TRAVEL
|
||||
var VELOCITY_WINDOW = 30; // ms between velocity samples
|
||||
var RESISTANCE = 0.35; // drag factor when there is no neighbour that way
|
||||
var FADE = 0.35; // how far the image dims across a full-width drag
|
||||
var EXIT_MS = 180; // keep in sync with the CSS transition duration
|
||||
|
||||
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
var pointerId = null;
|
||||
var startX = 0, startY = 0;
|
||||
var prevX = 0, prevT = 0;
|
||||
var dx = 0;
|
||||
var locked = false; // horizontal drag in progress
|
||||
var abandoned = false; // decided this gesture was a vertical scroll
|
||||
var leaving = false; // committed, animating out
|
||||
var swiped = false; // suppress the click that trails a swipe
|
||||
|
||||
// Swipe left (content moves left, the thing to the right comes in) → older.
|
||||
// Matches ArrowRight above.
|
||||
function targetFor(delta) {
|
||||
return delta < 0 ? older : newer;
|
||||
}
|
||||
|
||||
function paint() {
|
||||
var shift = targetFor(dx) ? dx : dx * RESISTANCE;
|
||||
var progress = Math.min(Math.abs(shift) / wrap.clientWidth, 1);
|
||||
img.style.transform = 'translate3d(' + shift + 'px, 0, 0)';
|
||||
img.style.opacity = String(1 - progress * FADE);
|
||||
}
|
||||
|
||||
function release() {
|
||||
pointerId = null;
|
||||
locked = false;
|
||||
abandoned = false;
|
||||
dx = 0;
|
||||
}
|
||||
|
||||
function snapBack() {
|
||||
wrap.classList.remove('dragging');
|
||||
wrap.classList.add('animating');
|
||||
img.style.transform = '';
|
||||
img.style.opacity = '';
|
||||
window.setTimeout(function () {
|
||||
wrap.classList.remove('animating');
|
||||
}, EXIT_MS);
|
||||
release();
|
||||
}
|
||||
|
||||
function commit(target) {
|
||||
if (reduceMotion) {
|
||||
location.href = target.href;
|
||||
return;
|
||||
}
|
||||
|
||||
leaving = true;
|
||||
wrap.classList.remove('dragging');
|
||||
wrap.classList.add('animating');
|
||||
img.style.transform =
|
||||
'translate3d(' + (dx < 0 ? -wrap.clientWidth : wrap.clientWidth) + 'px, 0, 0)';
|
||||
img.style.opacity = '0';
|
||||
|
||||
var went = false;
|
||||
function go() {
|
||||
if (went) return;
|
||||
went = true;
|
||||
location.href = target.href;
|
||||
}
|
||||
// transitionend does not fire if the tab is backgrounded mid-animation.
|
||||
img.addEventListener('transitionend', go, { once: true });
|
||||
window.setTimeout(go, EXIT_MS + 70);
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
wrap.addEventListener('pointerdown', function (e) {
|
||||
// A click can only follow a pointerdown, so clearing here is enough to
|
||||
// keep a genuine tap working after an earlier swipe snapped back.
|
||||
swiped = false;
|
||||
|
||||
if (leaving) return;
|
||||
if (pointerId !== null) return; // already tracking a finger
|
||||
if (e.pointerType !== 'touch') return; // leave mouse drag-to-save alone
|
||||
if (!e.isPrimary) return; // second finger: pinch, not swipe
|
||||
if (e.clientX < EDGE_DEAD_ZONE ||
|
||||
e.clientX > window.innerWidth - EDGE_DEAD_ZONE) return;
|
||||
|
||||
pointerId = e.pointerId;
|
||||
startX = prevX = e.clientX;
|
||||
startY = e.clientY;
|
||||
prevT = e.timeStamp;
|
||||
dx = 0;
|
||||
locked = false;
|
||||
abandoned = false;
|
||||
});
|
||||
|
||||
wrap.addEventListener('pointermove', function (e) {
|
||||
if (e.pointerId !== pointerId || abandoned) return;
|
||||
|
||||
dx = e.clientX - startX;
|
||||
var dy = e.clientY - startY;
|
||||
|
||||
if (!locked) {
|
||||
if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) return;
|
||||
if (Math.abs(dy) >= Math.abs(dx)) {
|
||||
abandoned = true; // vertical: hand the gesture back to the scroller
|
||||
return;
|
||||
}
|
||||
locked = true;
|
||||
wrap.classList.remove('animating');
|
||||
wrap.classList.add('dragging');
|
||||
wrap.setPointerCapture(pointerId);
|
||||
}
|
||||
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
if (e.timeStamp - prevT > VELOCITY_WINDOW) {
|
||||
prevX = e.clientX;
|
||||
prevT = e.timeStamp;
|
||||
}
|
||||
|
||||
paint();
|
||||
}, { passive: false });
|
||||
|
||||
wrap.addEventListener('pointerup', function (e) {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
|
||||
if (!locked) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
|
||||
swiped = true;
|
||||
|
||||
var target = targetFor(dx);
|
||||
var elapsed = e.timeStamp - prevT;
|
||||
var velocity = elapsed > 0 ? (e.clientX - prevX) / elapsed : 0;
|
||||
var far = Math.abs(dx) > Math.max(MIN_TRAVEL, window.innerWidth * TRAVEL_RATIO);
|
||||
// A flick only counts if it is still moving the way the drag went.
|
||||
var flicked = Math.abs(velocity) > MIN_VELOCITY && (velocity < 0) === (dx < 0);
|
||||
|
||||
if (target && (far || flicked)) {
|
||||
commit(target);
|
||||
} else {
|
||||
snapBack();
|
||||
}
|
||||
});
|
||||
|
||||
wrap.addEventListener('pointercancel', function (e) {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
if (locked) snapBack();
|
||||
else release();
|
||||
});
|
||||
|
||||
// The image sits inside an <a> to the full-size original. Without this a
|
||||
// swipe that ends over it opens that link in a new tab.
|
||||
wrap.addEventListener('click', function (e) {
|
||||
if (!swiped) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, true);
|
||||
})();
|
||||
@@ -248,6 +248,33 @@ body {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Swipe navigation. pan-y claims the horizontal axis for the gesture handler
|
||||
while leaving vertical scrolling native. will-change is scoped to the two
|
||||
transient states so the image is not permanently promoted to its own layer. */
|
||||
|
||||
.dimg-wrap {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.dimg-wrap.dragging {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.dimg-wrap.dragging img {
|
||||
transition: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.dimg-wrap.animating img {
|
||||
transition: transform 180ms ease-out, opacity 180ms ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dimg-wrap.animating img { transition: none; }
|
||||
}
|
||||
|
||||
.dsidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
|
||||
Reference in New Issue
Block a user