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