gtauri viewer: two-image swipe commit — outgoing stays until off-screen

Fix: on release the old image vanished instantly and only the new one
slid in. Now both images live on the page strip for the whole commit:
vimg (outgoing) slides off-screen while vimg2 (incoming, the drag
preview) slides home, over a 200 ms smoothstep; only then do the
elements swap roles (vimg.src = the already-decoded preview image,
vimg2 hidden after the swap paints — no blank frame).

Details:
- navigate() returns whether it committed; at the library edges it
  refuses and the drag snaps back (previously a past-the-edge drag
  could leave the image stuck mid-strip).
- commit tweens are locked: pointer/dblclick input is ignored for the
  ~200 ms so a second gesture can't fight the animation.
- openViewer gains a keepVisible (promoted) path that skips the
  hide+thumb round-trip; vFullSeq reset per open (safety-net counter
  could previously exhaust after ~11 opens).
- removed the now-dead slideFrom slide-in mechanism (its purpose is
  subsumed by the two-image commit).

Verified on the emulator: mid-commit frame shows both images
(outgoing sliding off left, incoming from right); snap-back slides
both back; edge drag right on the first image snaps home.
This commit is contained in:
Greg Pomerantz 2026-11-03 13:04:53 -05:00
parent 16cd868f22
commit 029780cc09

View File

@ -383,8 +383,6 @@ 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
/* Live swipe preview: while a horizontal drag is in flight at fit /* Live swipe preview: while a horizontal drag is in flight at fit
scale, the picture being swiped IN renders on the page strip scale, the picture being swiped IN renders on the page strip
(vimg2) and follows the finger — no blank side. Its grid thumb is (vimg2) and follows the finger — no blank side. Its grid thumb is
@ -428,15 +426,7 @@ 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) { if (tween) return; // a settle/commit animation owns the transform
// 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();
} }
@ -457,16 +447,24 @@ function tryShow(id, url, isFull) {
im.src = url; im.src = url;
} }
function openViewer(id, fromX) { /// openViewer(id, keepVisible): keepVisible = the image was promoted
/// by a swipe commit — vimg ALREADY shows its thumbnail, so don't
/// hide it or re-request the thumb (only full-res is wanted).
function openViewer(id, keepVisible) {
viewerId = id; viewerId = id;
navHide(); // the incoming image takes over from the preview if (!keepVisible) navHide();
slideFrom = (fromX == null) ? null : fromX;
vFullDone = false; vFullDone = false;
vFullSeq = 0;
vScale = 1; vTx = 0; vTy = 0; vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…"; if (keepVisible) {
vimg.style.visibility = "hidden"; vimg.style.visibility = "visible";
// 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms). vload.textContent = "";
tryShow(id, "http://gallery.localhost/thumb/" + id, false); } else {
vload.textContent = "loading…";
vimg.style.visibility = "hidden";
// 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms).
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
}
// 2) Full-res: Rust reads the file OFF the main thread; first request // 2) Full-res: Rust reads the file OFF the main thread; first request
// gets the placeholder, `full-ready` triggers the swap. // gets the placeholder, `full-ready` triggers the swap.
tryShow(id, "http://gallery.localhost/full/" + id, true); tryShow(id, "http://gallery.localhost/full/" + id, true);
@ -545,17 +543,52 @@ function tweenTo(tx, ty, ms, onDone) {
} }
/// Go to the neighbor `delta` steps down the list (+1 = swipe left). /// Go to the neighbor `delta` steps down the list (+1 = swipe left).
/// The incoming image starts on the page strip where the drag left /// Both images stay on the page strip for the whole animation:
/// off and slides home. /// the incoming one slides home while the outgoing one slides off
/// screen; only then do the roles swap (returns false at the edges).
function navigate(delta) { function navigate(delta) {
if (tween && tween.commit) return false; // commit already in flight
const i = ids.indexOf(viewerId); const i = ids.indexOf(viewerId);
const ni = i + delta; const ni = i + delta;
if (i < 0 || ni < 0 || ni >= ids.length) return; if (i < 0 || ni < 0 || ni >= ids.length) return false;
const from = vTx + delta * innerWidth; commitTo(ni, delta);
openViewer(ids[ni], from); return true;
}
/// Commit a swipe: animate vTx to the outgoing offset (the incoming
/// preview tracks at vTx + delta*W and lands exactly on 0), then swap
/// the elements' roles. The commit tween is locked: input is ignored
/// until it finishes (~200 ms).
function commitTo(ni, delta) {
navSet(ids[ni], delta); // the incoming (no-op if the drag showed it)
const out = -delta * innerWidth;
const x0 = vTx, t0 = performance.now(), ms = 200;
const step = (now) => {
const t = Math.min(1, (now - t0) / ms);
const e = t * t * (3 - 2 * t); // smoothstep: pick up, land soft
vTx = x0 + (out - x0) * e;
vTy = 0;
applyTransform();
navLayout();
if (t < 1) {
tween = { raf: requestAnimationFrame(step), commit: true };
} else {
// vimg2 (incoming) is exactly at 0: it becomes primary.
const id = ids[ni];
vimg.src = vimg2.src; // cached + decoded → paints immediately
vTx = 0;
applyTransform();
tween = null;
openViewer(id, true);
// Hide the outgoing preview only once the primary has the bytes.
vimg.onload = () => { if (viewerId === id) navHide(); };
}
};
tween = { raf: requestAnimationFrame(step), commit: true };
} }
viewer.addEventListener("pointerdown", (e) => { viewer.addEventListener("pointerdown", (e) => {
if (tween && tween.commit) return; // a swipe commit is in flight
viewer.setPointerCapture(e.pointerId); viewer.setPointerCapture(e.pointerId);
if (tween) { cancelAnimationFrame(tween.raf); tween = null; } 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 });
@ -640,15 +673,22 @@ const endPt = (e) => {
if (pts.size === 0 && g && g.mode === "swipe") { if (pts.size === 0 && g && g.mode === "swipe") {
const dx = e.clientX - g.x0; const dx = e.clientX - g.x0;
const w = innerWidth; const w = innerWidth;
if (dx < -w * 0.25 || g.vx < -0.35) navigate(+1); // Commit past the threshold; at the library edges navigate
else if (dx > w * 0.25 || g.vx > 0.35) navigate(-1); // refuses (nothing to commit to) and we snap back instead.
else tweenTo(0, 0, 120, navHide); // not far enough: snap back if (dx < -w * 0.25 || g.vx < -0.35) {
if (!navigate(+1)) tweenTo(0, 0, 120, navHide);
} else if (dx > w * 0.25 || g.vx > 0.35) {
if (!navigate(-1)) tweenTo(0, 0, 120, navHide);
} else {
tweenTo(0, 0, 120, navHide); // not far enough: snap back
}
} }
gesture = null; gesture = null;
}; };
viewer.addEventListener("pointerup", endPt); viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt); viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => { viewer.addEventListener("dblclick", () => {
if (tween && tween.commit) return;
vScale = fitScale(); vScale = fitScale();
vTx = 0; vTy = 0; vApply(); vTx = 0; vTy = 0; vApply();
}); });