Files
puttaalu 15822ed227 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.
2026-08-19 23:24:42 +02:00

194 lines
6.5 KiB
JavaScript

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