From 331caf2d6fd7481f00844551a3923ba5c43030b9 Mon Sep 17 00:00:00 2001 From: Aaron Gutierrez Date: Wed, 19 Nov 2025 21:15:22 -0800 Subject: [PATCH] Better image carousel --- site.css | 38 +++++++++- src/components/big_picture.tsx | 124 +++++++++++++++++++++++++++------ src/components/root.tsx | 16 +++++ 3 files changed, 156 insertions(+), 22 deletions(-) diff --git a/site.css b/site.css index e11860e..9ee2b1f 100644 --- a/site.css +++ b/site.css @@ -118,10 +118,44 @@ a:hover { z-index: 100; } +.BigPicture-viewport { + overflow: hidden; + width: 100%; + height: 100%; + display: flex; + align-items: center; +} + +.BigPicture-track { + display: flex; + align-items: center; + height: 100%; + touch-action: pan-y; + will-change: transform; +} + +.BigPicture-track.is-dragging { + cursor: grabbing; +} + +.BigPicture-frame { + align-items: center; + display: flex; + justify-content: center; + max-width: 100%; + padding: 20px; + width: 100%; + touch-action: pan-y; +} + +.BigPicture-frame.is-dragging { + cursor: grabbing; +} + .BigPicture img { transition-duration: 0.2s; - transition-timing-function: ease-in-quart; - transition-property: height, width; + transition-timing-function: ease-in-out; + transition-property: height, width, opacity; } .BigPicture-footer { diff --git a/src/components/big_picture.tsx b/src/components/big_picture.tsx index 959a4ce..ab6de3e 100644 --- a/src/components/big_picture.tsx +++ b/src/components/big_picture.tsx @@ -4,6 +4,8 @@ import { Picture } from "components/picture"; export interface Props { image: Model.Image; + previousImage: Model.Image; + nextImage: Model.Image; onClose: () => void; showNext: () => void; showPrevious: () => void; @@ -17,12 +19,18 @@ interface TouchStart { export const BigPicture: React.FC = ({ image, + previousImage, + nextImage, onClose, showNext, showPrevious, width, }) => { const [touchStart, setTouchStart] = React.useState(null); + const [dragOffset, setDragOffset] = React.useState(0); + const [isDragging, setIsDragging] = React.useState(false); + const [animating, setAnimating] = React.useState(false); + const [isSnapping, setIsSnapping] = React.useState(false); const onEscape = React.useCallback( (e: KeyboardEvent) => { @@ -33,9 +41,19 @@ export const BigPicture: React.FC = ({ [onClose] ); + const goNext = React.useCallback(() => { + showNext(); + }, [showNext]); + + const goPrevious = React.useCallback(() => { + showPrevious(); + }, [showPrevious]); + const onTouchStart = React.useCallback((e: TouchEvent) => { const touch = e.touches[0]; - setTouchStart({ x: touch.screenX, y: touch.screenY }); + setTouchStart({ x: touch.clientX, y: touch.clientY }); + setIsDragging(true); + setDragOffset(0); }, []); const onTouchEnd = React.useCallback( @@ -44,47 +62,113 @@ export const BigPicture: React.FC = ({ return; } const touch = e.changedTouches[0]; - const dx = touch.screenX - touchStart.x; + const dx = touch.clientX - touchStart.x; + const threshold = Math.max(50, width * 0.08); + const movedEnough = Math.abs(dx) > threshold; - if (Math.abs(dx) / window.innerWidth > 0.05) { - if (dx < 0) { - showNext(); - } else { - showPrevious(); - } + setIsDragging(false); + setTouchStart(null); + + if (movedEnough) { + const target = dx < 0 ? -width : width; + setAnimating(true); + setDragOffset(target); + setTimeout(() => { + setIsSnapping(true); + if (dx < 0) { + goNext(); + } else { + goPrevious(); + } + requestAnimationFrame(() => { + setDragOffset(0); + setAnimating(false); + requestAnimationFrame(() => setIsSnapping(false)); + }); + }, 220); + } else { + setDragOffset(0); + } + }, + [goNext, goPrevious, touchStart, width] + ); + + const onTouchMove = React.useCallback( + (e: TouchEvent) => { + if (!touchStart) { + return; } - setTouchStart(null); + const touch = e.touches[0]; + const dx = touch.clientX - touchStart.x; + // Allow slight vertical movement but ignore large vertical swipes to avoid accidental nav + const dy = Math.abs(touch.clientY - touchStart.y); + if (dy > Math.abs(dx) * 1.5) { + return; + } + setDragOffset(dx); }, - [showNext, showPrevious, touchStart] + [touchStart] ); React.useEffect(() => { window.addEventListener("keyup", onEscape); window.addEventListener("touchstart", onTouchStart); + window.addEventListener("touchmove", onTouchMove, { passive: true }); window.addEventListener("touchend", onTouchEnd); document.body.classList.add("no-scroll"); return () => { window.removeEventListener("keyup", onEscape); window.removeEventListener("touchstart", onTouchStart); + window.removeEventListener("touchmove", onTouchMove); window.removeEventListener("touchend", onTouchEnd); document.body.classList.remove("no-scroll"); }; - }, [onEscape, onTouchEnd, onTouchStart]); + }, [onEscape, onTouchEnd, onTouchMove, onTouchStart]); - const scaleWidth = image.width / width; - const scaleHeight = image.height / (window.innerHeight - 80); - const scale = Math.max(scaleWidth, scaleHeight); + const slideWidth = width; + const trackBase = -slideWidth + dragOffset; + const trackStyle: React.CSSProperties = { + width: `${slideWidth * 3}px`, + transform: `translate3d(${trackBase}px, 0, 0)`, + transition: isDragging || isSnapping ? "none" : "transform 0.25s ease", + }; + const slideStyle: React.CSSProperties = { + width: `${slideWidth}px`, + flex: `0 0 ${slideWidth}px`, + opacity: animating ? 0.98 : 1, + transition: isDragging ? "none" : "opacity 0.2s ease", + }; return (
- {}} - height={image.height / scale} - width={image.width / scale} - /> +
+
+ {[previousImage, image, nextImage].map((img) => { + const imgScaleWidth = img.width / width; + const imgScaleHeight = img.height / (window.innerHeight - 80); + const imgScale = Math.max(imgScaleWidth, imgScaleHeight); + return ( +
+ {}} + height={img.height / imgScale} + width={img.width / imgScale} + /> +
+ ); + })} +
+