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:
parent
314022cfb2
commit
bca6a9de4a
116
harnesses/gtauri/frontend/dist/index.html
vendored
116
harnesses/gtauri/frontend/dist/index.html
vendored
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user