gtauri viewer: horizontal swipe navigation (left/right to next/previous)

Standard gallery swipe: at fit scale a horizontal drag follows the
finger 1:1 (rubber-banded past the page edge); on release, past 25% of
the width or a fast flick (>0.35 px/ms), the neighbor in the list
opens with a 150 ms ease-out slide-in from the drag direction.
Short drags snap back; vertical drags at fit are inert; zoomed drags
still pan and pinch still zooms. Arrow keys navigate on desktop.

Verified on the emulator: left swipes advance, right swipe returns to
the previous image pixel-identical, 80px drag and vertical drag do not
navigate.
This commit is contained in:
Greg Pomerantz 2026-11-03 11:23:41 -05:00
parent 314022cfb2
commit bca6a9de4a

View File

@ -381,6 +381,8 @@ function vApply() {
let viewerId = null, vFullTimer = null, vFullDone = false, vFullSeq = 0; let viewerId = null, vFullTimer = null, vFullDone = false, vFullSeq = 0;
let slideFrom = null; // pending slide start offset for a swiped-in image
function fitViewer(w, h) { function fitViewer(w, h) {
base.w = w; base.h = h; base.w = w; base.h = h;
vScale = Math.min(innerWidth / w, innerHeight / h); vScale = Math.min(innerWidth / w, innerHeight / h);
@ -388,6 +390,15 @@ function fitViewer(w, h) {
vimg.style.height = h + "px"; vimg.style.height = h + "px";
vimg.style.visibility = "visible"; vimg.style.visibility = "visible";
vload.textContent = ""; vload.textContent = "";
if (slideFrom != null) {
// Incoming swiped image: start where the page strip puts it and
// slide home. The tween owns the transform from here on.
vTx = slideFrom; vTy = 0;
slideFrom = null;
tweenTo(0, 0, 150);
return;
}
if (tween) return; // settle/slide owns the transform right now
vApply(); vApply();
} }
@ -408,8 +419,9 @@ function tryShow(id, url, isFull) {
im.src = url; im.src = url;
} }
function openViewer(id) { function openViewer(id, fromX) {
viewerId = id; viewerId = id;
slideFrom = (fromX == null) ? null : fromX;
vFullDone = false; vFullDone = false;
vScale = 1; vTx = 0; vTy = 0; vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…"; vload.textContent = "loading…";
@ -446,30 +458,111 @@ function closeViewer() {
vimg.src = ""; vimg.src = "";
} }
document.getElementById("vclose").addEventListener("click", closeViewer); document.getElementById("vclose").addEventListener("click", closeViewer);
addEventListener("keydown", (e) => { if (e.key === "Escape") closeViewer(); }); addEventListener("keydown", (e) => {
if (e.key === "Escape") closeViewer();
else if (viewer.classList.contains("open")) {
if (e.key === "ArrowRight") navigate(+1);
else if (e.key === "ArrowLeft") navigate(-1);
}
});
/* pointer-based pan + pinch (touch-action:none keeps the browser from /* pointer-based pan + pinch (touch-action:none keeps the browser from
stealing the gesture) */ stealing the gesture). At FIT scale a horizontal drag is a SWIPE:
it navigates to the neighbor in the list and slides the incoming
image in from the drag direction. When zoomed, drags pan. */
const pts = new Map(); const pts = new Map();
let pinchMid = { x: 0, y: 0 }, pinchDist = 0; let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
let gesture = null; // {mode, x0, y0, sx, t, vx} for a 1-pointer gesture
let tween = null; // in-flight slide/settle animation {raf}
/// Below fitScale()*1.02 the user is "at fit" (swipes navigate).
function atFit() {
return vScale <= fitScale() * 1.02;
}
/// Raw transform write (no clamping) — used by the tween and swipes.
function applyTransform() {
vimg.style.transform =
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")";
}
/// Ease-out tween of the view transform to (tx, ty) over `ms`.
function tweenTo(tx, ty, ms) {
if (tween) cancelAnimationFrame(tween.raf);
const x0 = vTx, y0 = vTy, t0 = performance.now();
const step = (now) => {
const t = Math.min(1, (now - t0) / ms);
const e = 1 - Math.pow(1 - t, 3);
vTx = x0 + (tx - x0) * e;
vTy = y0 + (ty - y0) * e;
applyTransform();
if (t < 1) tween.raf = requestAnimationFrame(step);
else { tween = null; vTx = tx; vTy = ty; }
};
tween = { raf: requestAnimationFrame(step) };
}
/// Go to the neighbor `delta` steps down the list (+1 = swipe left).
/// The incoming image starts on the page strip where the drag left
/// off and slides home.
function navigate(delta) {
const i = ids.indexOf(viewerId);
const ni = i + delta;
if (i < 0 || ni < 0 || ni >= ids.length) return;
const from = vTx + delta * innerWidth;
openViewer(ids[ni], from);
}
viewer.addEventListener("pointerdown", (e) => { viewer.addEventListener("pointerdown", (e) => {
viewer.setPointerCapture(e.pointerId); viewer.setPointerCapture(e.pointerId);
if (tween) { cancelAnimationFrame(tween.raf); tween = null; }
pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) { if (pts.size === 2) {
gesture = null;
const [a, b] = [...pts.values()]; const [a, b] = [...pts.values()];
pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
pinchDist = Math.hypot(a.x - b.x, a.y - b.y); pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
} else {
const now = performance.now();
gesture = { mode: "free", x0: e.clientX, y0: e.clientY,
sx: e.clientX, sy: e.clientY, t: now, vx: 0 };
} }
}); });
viewer.addEventListener("pointermove", (e) => { viewer.addEventListener("pointermove", (e) => {
const p = pts.get(e.pointerId); const p = pts.get(e.pointerId);
if (!p) return; if (!p) return;
if (pts.size === 1) { if (pts.size === 1 && gesture) {
if (atFit()) {
// At fit: horizontal drag = swipe navigation, vertical = inert.
const dx = e.clientX - gesture.x0;
const dy = e.clientY - gesture.y0;
if (gesture.mode === "free") {
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
gesture.mode = Math.abs(dx) >= Math.abs(dy) * 1.15 ? "swipe" : "vblank";
}
if (gesture.mode === "swipe") {
// Velocity over the last ~30 ms (px/ms) for flick detection.
const now = performance.now();
if (now - gesture.t > 30) {
gesture.vx = (e.clientX - gesture.sx) / (now - gesture.t);
gesture.t = now; gesture.sx = e.clientX;
}
// 1:1 follow, rubber-banded once the page edge is passed.
const w = innerWidth;
vTx = Math.abs(dx) > w ? Math.sign(dx) * (w + (Math.abs(dx) - w) * 0.4) : dx;
vTy = 0;
applyTransform();
}
return;
}
// Zoomed: free pan (incremental, as before).
vTx += e.clientX - p.x; vTx += e.clientX - p.x;
vTy += e.clientY - p.y; vTy += e.clientY - p.y;
p.x = e.clientX; p.y = e.clientY; p.x = e.clientX; p.y = e.clientY;
vApply(); vApply();
} else if (pts.size === 2) { return;
}
if (pts.size === 2) {
p.x = e.clientX; p.y = e.clientY; p.x = e.clientX; p.y = e.clientY;
const [a, b] = [...pts.values()]; const [a, b] = [...pts.values()];
const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
@ -490,7 +583,18 @@ viewer.addEventListener("pointermove", (e) => {
vApply(); vApply();
} }
}); });
const endPt = (e) => { pts.delete(e.pointerId); }; const endPt = (e) => {
const g = gesture;
pts.delete(e.pointerId);
if (pts.size === 0 && g && g.mode === "swipe") {
const dx = e.clientX - g.x0;
const w = innerWidth;
if (dx < -w * 0.25 || g.vx < -0.35) navigate(+1);
else if (dx > w * 0.25 || g.vx > 0.35) navigate(-1);
else tweenTo(0, 0, 120); // not far enough: snap back
}
gesture = null;
};
viewer.addEventListener("pointerup", endPt); viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt); viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => { viewer.addEventListener("dblclick", () => {