From 029780cc0971de0e3e5a882c7ec14f0e22f64c93 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Tue, 3 Nov 2026 13:04:53 -0500 Subject: [PATCH] =?UTF-8?q?gtauri=20viewer:=20two-image=20swipe=20commit?= =?UTF-8?q?=20=E2=80=94=20outgoing=20stays=20until=20off-screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- harnesses/gtauri/frontend/dist/index.html | 92 ++++++++++++++++------- 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/harnesses/gtauri/frontend/dist/index.html b/harnesses/gtauri/frontend/dist/index.html index 18f5c6f..a6e97bd 100644 --- a/harnesses/gtauri/frontend/dist/index.html +++ b/harnesses/gtauri/frontend/dist/index.html @@ -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(); });