| index af140a9..a4eb6ca 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1567,7 +1567,7 @@ function paintHeaderAvatar(id) { |
| paintAvatar(el, threadName[id] || "?", loader).then((url) => { |
| if (url) { |
| el.classList.add("zoomable"); |
| - el.onclick = () => openLightbox(url); |
| + el.onclick = () => openLightbox(url, el); |
| } |
| }); |
| } |
| @@ -1912,7 +1912,7 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache |
| loadBlob(a.id, threadId).then((url) => { |
| if (url) { |
| img.src = url; |
| - img.onclick = () => openLightbox(url); |
| + img.onclick = () => openLightbox(url, img); |
| if (scroll) feed.scrollTop = feed.scrollHeight; |
| } |
| }); |
| @@ -2584,25 +2584,44 @@ function attachSwipeReply(el, meta) { |
| startY = 0, |
| dx = 0, |
| mode = "", // "" undecided, "swipe", or "no" (vertical/other) |
| - icon = null; |
| + icon = null, |
| + raf = 0; |
| + |
| + // Write the transform once per frame (rAF-batched) using translate3d, which |
| + // keeps the bubble on a GPU compositor layer — no per-move repaint of the |
| + // subtree, so the drag stays smooth even in the iOS webview. |
| + const render = () => { |
| + raf = 0; |
| + el.style.transform = "translate3d(" + dx + "px,0,0)"; |
| + if (icon) icon.style.opacity = Math.min(1, dx / TRIGGER); |
| + }; |
| + const schedule = () => { |
| + if (!raf) raf = requestAnimationFrame(render); |
| + }; |
| |
| const cleanup = (animate) => { |
| + if (raf) { |
| + cancelAnimationFrame(raf); |
| + raf = 0; |
| + } |
| if (icon) { |
| icon.remove(); |
| icon = null; |
| } |
| if (animate) { |
| el.style.transition = "transform 0.18s ease"; |
| - el.style.transform = "translateX(0)"; |
| + el.style.transform = "translate3d(0,0,0)"; |
| const done = () => { |
| el.style.transition = ""; |
| el.style.transform = ""; |
| + el.style.willChange = ""; |
| el.removeEventListener("transitionend", done); |
| }; |
| el.addEventListener("transitionend", done); |
| } else { |
| el.style.transition = ""; |
| el.style.transform = ""; |
| + el.style.willChange = ""; |
| } |
| }; |
| |
| @@ -2621,6 +2640,7 @@ function attachSwipeReply(el, meta) { |
| if (Math.abs(ax) < 10 && Math.abs(ay) < 10) return; |
| mode = ax > 0 && Math.abs(ax) > Math.abs(ay) * 1.3 ? "swipe" : "no"; |
| if (mode === "swipe") { |
| + el.style.willChange = "transform"; // promote the bubble to its own layer for the drag |
| icon = document.createElement("div"); |
| icon.className = "swipe-reply-icon"; |
| icon.innerHTML = ICON_REPLY; |
| @@ -2629,8 +2649,7 @@ function attachSwipeReply(el, meta) { |
| } |
| if (mode !== "swipe") return; |
| dx = Math.max(0, Math.min(ax, MAX)); |
| - el.style.transform = "translateX(" + dx + "px)"; |
| - if (icon) icon.style.opacity = Math.min(1, dx / TRIGGER); |
| + schedule(); |
| }); |
| const onEnd = (e) => { |
| if (e.pointerType && e.pointerType !== "touch") return; |
| @@ -3033,56 +3052,298 @@ function buildFileCard(a, threadId) { |
| return card; |
| } |
| |
| -function openLightbox(url) { |
| +function openLightbox(url, source) { |
| + const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| const back = document.createElement("div"); |
| back.className = "lightbox"; |
| + // The dark backing is a separate layer so we can dim it by animating *opacity* |
| + // (composited on the GPU, no repaint) instead of recomputing its color per move. |
| + const backdrop = document.createElement("div"); |
| + backdrop.className = "lightbox-backdrop"; |
| + backdrop.style.opacity = "0"; // faded in by the open animation |
| const img = document.createElement("img"); |
| img.src = url; |
| img.draggable = false; |
| + img.style.visibility = "hidden"; // stays hidden until laid out, so the fly-in has no flash |
| + back.appendChild(backdrop); |
| back.appendChild(img); |
| + |
| + let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch |
| + let closing = false; |
| const close = () => { |
| + clearTimeout(tapTimer); |
| back.remove(); |
| document.removeEventListener("keydown", onKey); |
| }; |
| const onKey = (e) => { |
| - if (e.key === "Escape") close(); |
| + if (e.key === "Escape") animateClose(); |
| }; |
| - // Tapping the backdrop (not the image) closes. |
| + // Tapping empty space (not the photo) closes — unless a gesture just ran, whose |
| + // synthesized click we swallow. |
| back.addEventListener("click", (e) => { |
| - if (e.target === back) close(); |
| + if (e.target === img) return; |
| + if (suppressBackdropClick) { |
| + suppressBackdropClick = false; |
| + return; |
| + } |
| + animateClose(); |
| }); |
| document.addEventListener("keydown", onKey); |
| document.body.appendChild(back); |
| |
| - // Flick-to-dismiss with momentum: drag the image and a fast flick (or a big drag) |
| - // flings it off in the release direction, carrying its velocity; otherwise it |
| - // springs back. A plain tap closes. Velocity is px/ms, sampled between moves. |
| - let dragging = false; |
| - let moved = false; |
| - let startX = 0; |
| - let startY = 0; |
| - let x = 0; |
| - let y = 0; |
| + // --- gesture state ------------------------------------------------------- |
| + // One transform drives everything: translate3d + rotate + scale (GPU-composited). |
| + // A single finger at 1× flick-dismisses; two fingers pinch-zoom-and-rotate about |
| + // their midpoint; one finger while zoomed pans; double-tap toggles zoom. |
| + const MIN_SCALE = 1; |
| + const MAX_SCALE = 5; |
| + const TAP_MS = 280; // double-tap window / lone-tap dismiss delay |
| + let scale = 1; |
| + let tx = 0; |
| + let ty = 0; |
| + let rot = 0; // rotation °: free during a pinch (springs upright), or a 1× drag tumble |
| + let mode = ""; // "drag" (1× dismiss) | "pan" (zoomed) | "pinch" |
| + let pinched = false; // did this gesture involve a pinch? if so it settles, never dismisses |
| + let suppressBackdropClick = false; |
| + let raf = 0; |
| + |
| + // The image's at-rest centre in screen coords — the anchor for all zoom math and |
| + // the fly-in/out target. Measured whenever we sit at the identity transform. |
| + let baseCX = 0; |
| + let baseCY = 0; |
| + let haveBase = false; |
| + const setBase = () => { |
| + const r = img.getBoundingClientRect(); |
| + baseCX = r.left + r.width / 2; |
| + baseCY = r.top + r.height / 2; |
| + haveBase = true; |
| + }; |
| + const ensureBase = () => { |
| + if (scale === 1 && tx === 0 && ty === 0) setBase(); |
| + }; |
| + |
| + const pointers = new Map(); // pointerId -> {x, y} |
| + // single-finger drag/pan tracking |
| + let sx = 0; |
| + let sy = 0; |
| + let panStartTx = 0; |
| + let panStartTy = 0; |
| let lastX = 0; |
| let lastY = 0; |
| let lastT = 0; |
| let vx = 0; |
| let vy = 0; |
| - img.addEventListener("pointerdown", (e) => { |
| - dragging = true; |
| - moved = false; |
| - startX = lastX = e.clientX; |
| - startY = lastY = e.clientY; |
| - lastT = e.timeStamp || performance.now(); |
| - vx = vy = x = y = 0; |
| + let moved = false; |
| + // pinch anchors (captured when the second finger lands) |
| + let pinchStartDist = 0; |
| + let pinchStartScale = 1; |
| + let pinchStartAngle = 0; |
| + let pinchStartRot = 0; |
| + let pinchVX = 0; |
| + let pinchVY = 0; |
| + // double-tap tracking |
| + let lastTapT = 0; |
| + let lastTapX = 0; |
| + let lastTapY = 0; |
| + |
| + const render = () => { |
| + raf = 0; |
| + let t = `translate3d(${tx}px, ${ty}px, 0)`; |
| + if (rot) t += ` rotate(${rot}deg)`; |
| + if (scale !== 1) t += ` scale(${scale})`; |
| + img.style.transform = t; |
| + // The backdrop only dims while dragging a 1× image toward dismissal. |
| + backdrop.style.opacity = |
| + mode === "drag" && !pinched ? String(Math.max(0, 1 - Math.hypot(tx, ty) / 700)) : "1"; |
| + }; |
| + const schedule = () => { |
| + if (!raf) raf = requestAnimationFrame(render); |
| + }; |
| + |
| + // Fly the photo up from the thumbnail it was opened from, then ease to rest |
| + // (a FLIP / shared-element transition). Runs once the image has a size. |
| + let openStarted = false; |
| + const runOpenAnim = () => { |
| + if (openStarted) return; |
| + openStarted = true; |
| + img.style.visibility = ""; |
| + setBase(); |
| + const S = source && source.getBoundingClientRect(); |
| + if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) { |
| + backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s"; |
| + backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear |
| + return; |
| + } |
| + const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight); |
| + const fromX = S.left + S.width / 2 - baseCX; |
| + const fromY = S.top + S.height / 2 - baseCY; |
| img.style.transition = "none"; |
| + img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`; |
| + void img.offsetWidth; // reflow so the change below animates from here |
| + img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1)"; |
| + img.style.transform = "translate3d(0,0,0)"; |
| + backdrop.style.transition = "opacity 0.3s"; |
| + backdrop.style.opacity = "1"; |
| + }; |
| + if (img.complete && img.naturalWidth) runOpenAnim(); |
| + else { |
| + img.addEventListener("load", runOpenAnim, { once: true }); |
| + img.addEventListener("error", runOpenAnim, { once: true }); |
| + } |
| + |
| + // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove. |
| + // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen. |
| + const animateClose = () => { |
| + if (closing) return; |
| + closing = true; |
| + clearTimeout(tapTimer); |
| + if (raf) { |
| + cancelAnimationFrame(raf); |
| + raf = 0; |
| + } |
| + const S = source && source.getBoundingClientRect(); |
| + if (!haveBase || !S || !S.width || reduceMotion) return close(); |
| + const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight); |
| + const toX = S.left + S.width / 2 - baseCX; |
| + const toY = S.top + S.height / 2 - baseCY; |
| + img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1)"; |
| + img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`; |
| + backdrop.style.transition = "opacity 0.28s"; |
| + backdrop.style.opacity = "0"; |
| + setTimeout(close, 300); |
| + }; |
| + |
| + // Keep the transformed image within reach of the viewport when zoomed. |
| + const clampPan = () => { |
| + const maxX = Math.max(0, (img.clientWidth * scale - window.innerWidth) / 2 + 20); |
| + const maxY = Math.max(0, (img.clientHeight * scale - window.innerHeight) / 2 + 20); |
| + tx = Math.max(-maxX, Math.min(maxX, tx)); |
| + ty = Math.max(-maxY, Math.min(maxY, ty)); |
| + }; |
| + |
| + // Animate to a target scale anchored at a screen point (double-tap). Zooming |
| + // out always recentres. |
| + const zoomTo = (target, cx, cy) => { |
| + if (raf) { |
| + cancelAnimationFrame(raf); |
| + raf = 0; |
| + } |
| + if (target <= MIN_SCALE) { |
| + scale = 1; |
| + tx = 0; |
| + ty = 0; |
| + } else { |
| + const vX = (cx - baseCX - tx) / scale; |
| + const vY = (cy - baseCY - ty) / scale; |
| + scale = Math.min(MAX_SCALE, target); |
| + tx = cx - baseCX - scale * vX; |
| + ty = cy - baseCY - scale * vY; |
| + clampPan(); |
| + } |
| + rot = 0; |
| + img.style.transition = "transform 0.25s cubic-bezier(0.2,0.8,0.2,1)"; |
| + img.style.transform = scale === 1 ? "translate3d(0,0,0)" : `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`; |
| + backdrop.style.transition = "opacity 0.25s"; |
| + backdrop.style.opacity = "1"; |
| + }; |
| + |
| + // Ease back to the identity transform (1×, centred, upright). |
| + const springBack = () => { |
| + scale = 1; |
| + tx = 0; |
| + ty = 0; |
| + rot = 0; |
| + img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)"; |
| + img.style.transform = "translate3d(0,0,0)"; |
| + backdrop.style.transition = "opacity 0.28s"; |
| + backdrop.style.opacity = "1"; |
| + }; |
| + |
| + // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any |
| + // rotation the pinch left behind. |
| + const settleZoom = () => { |
| + clampPan(); |
| + rot = 0; |
| + img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)"; |
| + img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`; |
| + backdrop.style.transition = "opacity 0.28s"; |
| + backdrop.style.opacity = "1"; |
| + }; |
| + |
| + img.addEventListener("pointerdown", (e) => { |
| img.setPointerCapture?.(e.pointerId); |
| + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); |
| + clearTimeout(tapTimer); // a new touch means this isn't a tap-and-wait dismiss |
| + if (raf) { |
| + cancelAnimationFrame(raf); |
| + raf = 0; |
| + } |
| + img.style.transition = "none"; |
| + backdrop.style.transition = "none"; |
| + ensureBase(); |
| + if (pointers.size >= 2) { |
| + // Second finger down: start a pinch, anchoring the current midpoint + angle. |
| + const [a, b] = [...pointers.values()]; |
| + pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y) || 1; |
| + pinchStartScale = scale; |
| + pinchStartAngle = Math.atan2(b.y - a.y, b.x - a.x); |
| + pinchStartRot = rot; |
| + const midX = (a.x + b.x) / 2; |
| + const midY = (a.y + b.y) / 2; |
| + pinchVX = (midX - baseCX - tx) / scale; |
| + pinchVY = (midY - baseCY - ty) / scale; |
| + mode = "pinch"; |
| + moved = true; |
| + pinched = true; |
| + suppressBackdropClick = true; |
| + } else { |
| + // First finger of a fresh gesture: pan if zoomed, else a candidate dismiss drag. |
| + pinched = false; |
| + sx = e.clientX; |
| + sy = e.clientY; |
| + panStartTx = tx; |
| + panStartTy = ty; |
| + lastX = e.clientX; |
| + lastY = e.clientY; |
| + lastT = e.timeStamp || performance.now(); |
| + vx = vy = 0; |
| + moved = false; |
| + rot = 0; |
| + mode = scale > 1 ? "pan" : "drag"; |
| + } |
| }); |
| + |
| img.addEventListener("pointermove", (e) => { |
| - if (!dragging) return; |
| - x = e.clientX - startX; |
| - y = e.clientY - startY; |
| - if (Math.abs(x) > 6 || Math.abs(y) > 6) moved = true; |
| + const p = pointers.get(e.pointerId); |
| + if (!p) return; |
| + p.x = e.clientX; |
| + p.y = e.clientY; |
| + if (mode === "pinch") { |
| + const pts = [...pointers.values()]; |
| + if (pts.length < 2) return; |
| + const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1; |
| + const midX = (pts[0].x + pts[1].x) / 2; |
| + const midY = (pts[0].y + pts[1].y) / 2; |
| + scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, (pinchStartScale * dist) / pinchStartDist)); |
| + const ang = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x); |
| + rot = pinchStartRot + (ang - pinchStartAngle) * (180 / Math.PI); // free twist |
| + tx = midX - baseCX - scale * pinchVX; |
| + ty = midY - baseCY - scale * pinchVY; |
| + clampPan(); |
| + schedule(); |
| + return; |
| + } |
| + if (mode === "pan") { |
| + tx = panStartTx + (e.clientX - sx); |
| + ty = panStartTy + (e.clientY - sy); |
| + clampPan(); |
| + schedule(); |
| + return; |
| + } |
| + // mode === "drag": 1× dismiss drag with velocity sampling (px/ms). |
| + tx = e.clientX - sx; |
| + ty = e.clientY - sy; |
| + if (Math.abs(tx) > 6 || Math.abs(ty) > 6) moved = true; |
| const now = e.timeStamp || performance.now(); |
| const dt = now - lastT; |
| if (dt > 0) { |
| @@ -3092,22 +3353,45 @@ function openLightbox(url) { |
| lastX = e.clientX; |
| lastY = e.clientY; |
| lastT = now; |
| - // Fade the backdrop as the image is dragged away. |
| - back.style.background = `rgba(0,0,0,${Math.max(0, 0.9 - Math.hypot(x, y) / 700)})`; |
| - img.style.transform = `translate(${x}px, ${y}px) rotate(${x / 40}deg)`; |
| + rot = tx / 40; |
| + schedule(); |
| }); |
| - const release = (e) => { |
| - if (!dragging) return; |
| - dragging = false; |
| + |
| + const armSuppress = () => { |
| + suppressBackdropClick = true; |
| + setTimeout(() => (suppressBackdropClick = false), 350); |
| + }; |
| + |
| + const flingOrSettle = (e) => { |
| + // A tap on the photo. A second tap within the window is a double-tap (toggle |
| + // zoom); otherwise a lone tap dismisses at 1× — but only after the window |
| + // elapses, so it can't fire before a double-tap is ruled out. |
| + if (!moved) { |
| + const now = e.timeStamp || performance.now(); |
| + if (now - lastTapT < TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) < 30) { |
| + lastTapT = 0; |
| + zoomTo(scale > 1 ? MIN_SCALE : 2.5, e.clientX, e.clientY); |
| + armSuppress(); |
| + return; |
| + } |
| + lastTapT = now; |
| + lastTapX = e.clientX; |
| + lastTapY = e.clientY; |
| + if (scale === 1) tapTimer = setTimeout(animateClose, TAP_MS); // lone tap → dismiss into place |
| + return; |
| + } |
| + // Anything that involved a pinch, or ended zoomed, settles — it never dismisses, |
| + // so pinching back down to 1× can't fling the photo away by accident. |
| + if (pinched || mode === "pan" || scale > 1) { |
| + if (scale <= 1.001) springBack(); |
| + else settleZoom(); |
| + armSuppress(); |
| + return; |
| + } |
| + // Clean 1× drag: spring back, or fling off with momentum. |
| const speed = Math.hypot(vx, vy); // px/ms |
| - const dist = Math.hypot(x, y); |
| - if (speed < 0.55 && dist < 130) { |
| - if (!moved) return close(); // a tap |
| - // Not enough — spring back. |
| - img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)"; |
| - img.style.transform = "translate(0,0)"; |
| - back.style.transition = "background 0.28s"; |
| - back.style.background = ""; |
| + if (speed < 0.55 && Math.hypot(tx, ty) < 130) { |
| + springBack(); |
| return; |
| } |
| // Fling: continue in the release direction (fall back to the drag direction if it |
| @@ -3115,23 +3399,50 @@ function openLightbox(url) { |
| let dx = vx; |
| let dy = vy; |
| if (speed < 0.05) { |
| - dx = x; |
| - dy = y; |
| + dx = tx; |
| + dy = ty; |
| } |
| const norm = Math.hypot(dx, dy) || 1; |
| const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25; |
| - const tx = x + (dx / norm) * reach; |
| - const ty = y + (dy / norm) * reach; |
| - const spin = Math.max(-65, Math.min(65, tx / 20)); // tumbles more as it leaves |
| + const fx = tx + (dx / norm) * reach; |
| + const fy = ty + (dy / norm) * reach; |
| + const spin = Math.max(-65, Math.min(65, fx / 20)); // tumbles more as it leaves |
| + closing = true; |
| img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)"; |
| - img.style.transform = `translate(${tx}px, ${ty}px) rotate(${spin}deg)`; |
| - back.style.transition = "opacity 0.45s, background 0.45s"; |
| - back.style.background = "rgba(0,0,0,0)"; |
| - back.style.opacity = "0"; |
| + img.style.transform = `translate3d(${fx}px, ${fy}px, 0) rotate(${spin}deg)`; |
| + backdrop.style.transition = "opacity 0.45s"; |
| + backdrop.style.opacity = "0"; |
| setTimeout(close, 480); |
| }; |
| - img.addEventListener("pointerup", release); |
| - img.addEventListener("pointercancel", release); |
| + |
| + const onUp = (e) => { |
| + if (!pointers.has(e.pointerId)) return; |
| + pointers.delete(e.pointerId); |
| + img.releasePointerCapture?.(e.pointerId); |
| + if (raf) { |
| + cancelAnimationFrame(raf); |
| + raf = 0; |
| + } |
| + if (pointers.size === 1) { |
| + // Dropped from a pinch to one finger — hand off to pan/drag without a jump. |
| + const [rp] = [...pointers.values()]; |
| + sx = rp.x; |
| + sy = rp.y; |
| + panStartTx = tx; |
| + panStartTy = ty; |
| + lastX = rp.x; |
| + lastY = rp.y; |
| + lastT = e.timeStamp || performance.now(); |
| + vx = vy = 0; |
| + moved = true; // came from a pinch; not a tap |
| + mode = scale > 1 ? "pan" : "drag"; |
| + return; |
| + } |
| + if (pointers.size >= 1) return; |
| + flingOrSettle(e); |
| + }; |
| + img.addEventListener("pointerup", onUp); |
| + img.addEventListener("pointercancel", onUp); |
| } |
| |
| // ---- websocket ---------------------------------------------------------- |