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 slideFrom = null; // pending slide start offset for a swiped-in image
/* Live swipe preview: while a horizontal drag is in flight at fit
scale, the picture being swiped IN renders on the page strip
(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.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
if (tween) return; // a settle/commit animation owns the transform
vApply();
}
@ -457,16 +447,24 @@ function tryShow(id, url, isFull) {
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;
navHide(); // the incoming image takes over from the preview
slideFrom = (fromX == null) ? null : fromX;
if (!keepVisible) navHide();
vFullDone = false;
vFullSeq = 0;
vScale = 1; vTx = 0; vTy = 0;
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);
if (keepVisible) {
vimg.style.visibility = "visible";
vload.textContent = "";
} 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
// gets the placeholder, `full-ready` triggers the swap.
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).
/// The incoming image starts on the page strip where the drag left
/// off and slides home.
/// Both images stay on the page strip for the whole animation:
/// 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) {
if (tween && tween.commit) return false; // commit already in flight
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);
if (i < 0 || ni < 0 || ni >= ids.length) return false;
commitTo(ni, delta);
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) => {
if (tween && tween.commit) return; // a swipe commit is in flight
viewer.setPointerCapture(e.pointerId);
if (tween) { cancelAnimationFrame(tween.raf); tween = null; }
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") {
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, navHide); // not far enough: snap back
// Commit past the threshold; at the library edges navigate
// refuses (nothing to commit to) and we snap back instead.
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;
};
viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => {
if (tween && tween.commit) return;
vScale = fitScale();
vTx = 0; vTy = 0; vApply();
});