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;
}
.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 {

View File

@@ -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<Props> = ({
image,
previousImage,
nextImage,
onClose,
showNext,
showPrevious,
width,
}) => {
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(
(e: KeyboardEvent) => {
@@ -33,9 +41,19 @@ export const BigPicture: React.FC<Props> = ({
[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<Props> = ({
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 (
<div className="BigPicture">
<Picture
image={image}
onClick={() => {}}
height={image.height / scale}
width={image.width / scale}
/>
<div className="BigPicture-viewport">
<div
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
style={trackStyle}
>
{[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">
<a
className="BigPicture-footerLink"

View File

@@ -202,6 +202,20 @@ export const Root: React.FC<Props> = () => {
onImageSelected(images[previous]);
}, [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 = () => {
if (!data) {
return null;
@@ -242,6 +256,8 @@ export const Root: React.FC<Props> = () => {
{selectedImage ? (
<BigPicture
image={selectedImage}
previousImage={neighbors?.previous ?? selectedImage}
nextImage={neighbors?.next ?? selectedImage}
onClose={showGrid}
showNext={showNextBigPicture}
showPrevious={showPreviousBigPicture}