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:
2026-08-19 23:24:42 +02:00
parent 210ce69e18
commit 15822ed227
3 changed files with 225 additions and 13 deletions
+193
View File
@@ -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);
})();
+27
View File
@@ -248,6 +248,33 @@ body {
height: auto; 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 { .dsidebar {
width: 220px; width: 220px;
flex-shrink: 0; flex-shrink: 0;
+5 -13
View File
@@ -8,8 +8,10 @@
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style> <style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed"> <link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
{{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/">{{end}} {{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/">
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">{{end}} <link rel="prefetch" as="image" href="/{{.Newer.Medium}}">{{end}}
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
</head> </head>
<body> <body>
<div class="photo-page"> <div class="photo-page">
@@ -43,16 +45,6 @@
© 2000{{.Year}} {{.Author}}. All rights reserved. © 2000{{.Year}} {{.Author}}. All rights reserved.
</footer> </footer>
<script> <script src="/static/photo-nav.js" defer></script>
(function () {
const newer = document.querySelector('[data-nav="newer"]');
const older = document.querySelector('[data-nav="older"]');
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea')) return;
if (e.key === 'ArrowLeft' && newer) location.href = newer.href;
if (e.key === 'ArrowRight' && older) location.href = older.href;
});
})();
</script>
</body> </body>
</html> </html>