Better image carousel
This commit is contained in:
38
site.css
38
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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user