diff --git a/harnesses/gtauri/frontend/dist/index.html b/harnesses/gtauri/frontend/dist/index.html index d114f1f..5758208 100644 --- a/harnesses/gtauri/frontend/dist/index.html +++ b/harnesses/gtauri/frontend/dist/index.html @@ -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", () => {