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 slideFrom = null; // pending slide start offset for a swiped-in image
|
||||
|
||||
function fitViewer(w, h) {
|
||||
base.w = w; base.h = h;
|
||||
vScale = Math.min(innerWidth / w, innerHeight / h);
|
||||
|
|
@ -388,6 +390,15 @@ function fitViewer(w, h) {
|
|||
vimg.style.height = h + "px";
|
||||
vimg.style.visibility = "visible";
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -408,8 +419,9 @@ function tryShow(id, url, isFull) {
|
|||
im.src = url;
|
||||
}
|
||||
|
||||
function openViewer(id) {
|
||||
function openViewer(id, fromX) {
|
||||
viewerId = id;
|
||||
slideFrom = (fromX == null) ? null : fromX;
|
||||
vFullDone = false;
|
||||
vScale = 1; vTx = 0; vTy = 0;
|
||||
vload.textContent = "loading…";
|
||||
|
|
@ -446,30 +458,111 @@ function closeViewer() {
|
|||
vimg.src = "";
|
||||
}
|
||||
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
|
||||
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();
|
||||
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.setPointerCapture(e.pointerId);
|
||||
if (tween) { cancelAnimationFrame(tween.raf); tween = null; }
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pts.size === 2) {
|
||||
gesture = null;
|
||||
const [a, b] = [...pts.values()];
|
||||
pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
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) => {
|
||||
const p = pts.get(e.pointerId);
|
||||
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;
|
||||
vTy += e.clientY - p.y;
|
||||
p.x = e.clientX; p.y = e.clientY;
|
||||
vApply();
|
||||
} else if (pts.size === 2) {
|
||||
return;
|
||||
}
|
||||
if (pts.size === 2) {
|
||||
p.x = e.clientX; p.y = e.clientY;
|
||||
const [a, b] = [...pts.values()];
|
||||
const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
|
|
@ -490,7 +583,18 @@ viewer.addEventListener("pointermove", (e) => {
|
|||
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("pointercancel", endPt);
|
||||
viewer.addEventListener("dblclick", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user