Better image carousel

This commit is contained in:
2025-11-19 21:15:22 -08:00
parent 1cfcd8ff04
commit 331caf2d6f
3 changed files with 156 additions and 22 deletions

View File

@@ -118,10 +118,44 @@ a:hover {
z-index: 100; 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 { .BigPicture img {
transition-duration: 0.2s; transition-duration: 0.2s;
transition-timing-function: ease-in-quart; transition-timing-function: ease-in-out;
transition-property: height, width; transition-property: height, width, opacity;
} }
.BigPicture-footer { .BigPicture-footer {

View File

@@ -4,6 +4,8 @@ import { Picture } from "components/picture";
export interface Props { export interface Props {
image: Model.Image; image: Model.Image;
previousImage: Model.Image;
nextImage: Model.Image;
onClose: () => void; onClose: () => void;
showNext: () => void; showNext: () => void;
showPrevious: () => void; showPrevious: () => void;
@@ -17,12 +19,18 @@ interface TouchStart {
export const BigPicture: React.FC<Props> = ({ export const BigPicture: React.FC<Props> = ({
image, image,
previousImage,
nextImage,
onClose, onClose,
showNext, showNext,
showPrevious, showPrevious,
width, width,
}) => { }) => {
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null); const [touchStart, setTouchStart] = React.useState<TouchStart | null>(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( const onEscape = React.useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@@ -33,9 +41,19 @@ export const BigPicture: React.FC<Props> = ({
[onClose] [onClose]
); );
const goNext = React.useCallback(() => {
showNext();
}, [showNext]);
const goPrevious = React.useCallback(() => {
showPrevious();
}, [showPrevious]);
const onTouchStart = React.useCallback((e: TouchEvent) => { const onTouchStart = React.useCallback((e: TouchEvent) => {
const touch = e.touches[0]; 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( const onTouchEnd = React.useCallback(
@@ -44,47 +62,113 @@ export const BigPicture: React.FC<Props> = ({
return; return;
} }
const touch = e.changedTouches[0]; 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) { setIsDragging(false);
if (dx < 0) { setTouchStart(null);
showNext();
} else { if (movedEnough) {
showPrevious(); 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(() => { React.useEffect(() => {
window.addEventListener("keyup", onEscape); window.addEventListener("keyup", onEscape);
window.addEventListener("touchstart", onTouchStart); window.addEventListener("touchstart", onTouchStart);
window.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("touchend", onTouchEnd); window.addEventListener("touchend", onTouchEnd);
document.body.classList.add("no-scroll"); document.body.classList.add("no-scroll");
return () => { return () => {
window.removeEventListener("keyup", onEscape); window.removeEventListener("keyup", onEscape);
window.removeEventListener("touchstart", onTouchStart); window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd); window.removeEventListener("touchend", onTouchEnd);
document.body.classList.remove("no-scroll"); document.body.classList.remove("no-scroll");
}; };
}, [onEscape, onTouchEnd, onTouchStart]); }, [onEscape, onTouchEnd, onTouchMove, onTouchStart]);
const scaleWidth = image.width / width; const slideWidth = width;
const scaleHeight = image.height / (window.innerHeight - 80); const trackBase = -slideWidth + dragOffset;
const scale = Math.max(scaleWidth, scaleHeight); 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 ( return (
<div className="BigPicture"> <div className="BigPicture">
<Picture <div className="BigPicture-viewport">
image={image} <div
onClick={() => {}} className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
height={image.height / scale} style={trackStyle}
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 (
<div
className="BigPicture-frame"
key={img.src}
style={slideStyle}
>
<Picture
image={img}
onClick={() => {}}
height={img.height / imgScale}
width={img.width / imgScale}
/>
</div>
);
})}
</div>
</div>
<div className="BigPicture-footer"> <div className="BigPicture-footer">
<a <a
className="BigPicture-footerLink" className="BigPicture-footerLink"

View File

@@ -202,6 +202,20 @@ export const Root: React.FC<Props> = () => {
onImageSelected(images[previous]); onImageSelected(images[previous]);
}, [onImageSelected, selectedImage, selectedSet]); }, [onImageSelected, selectedImage, selectedSet]);
const neighbors = React.useMemo(() => {
if (!selectedSet || !selectedImage) {
return null;
}
const images = selectedSet.images;
const current = images.indexOf(selectedImage);
const previousIdx = current - 1 < 0 ? images.length - 1 : current - 1;
const nextIdx = current + 1 >= images.length ? 0 : current + 1;
return {
previous: images[previousIdx],
next: images[nextIdx],
};
}, [selectedImage, selectedSet]);
const renderSets = () => { const renderSets = () => {
if (!data) { if (!data) {
return null; return null;
@@ -242,6 +256,8 @@ export const Root: React.FC<Props> = () => {
{selectedImage ? ( {selectedImage ? (
<BigPicture <BigPicture
image={selectedImage} image={selectedImage}
previousImage={neighbors?.previous ?? selectedImage}
nextImage={neighbors?.next ?? selectedImage}
onClose={showGrid} onClose={showGrid}
showNext={showNextBigPicture} showNext={showNextBigPicture}
showPrevious={showPreviousBigPicture} showPrevious={showPreviousBigPicture}