Add swipe navigation to lightbox

This commit is contained in:
sezzim 2025-10-25 20:22:22 -07:00
parent d78462bdb4
commit dcc825c2a6
3 changed files with 110 additions and 33 deletions

View file

@ -76,6 +76,7 @@ const CLASSNAME_FOOTER_RIGHT = `${CLASSNAME_FOOTER}-right`;
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`; const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`; const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
const CLASSNAME_SWIPE = `${CLASSNAME_CAROUSEL}-swipe`;
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`; const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
const CLASSNAME_IMAGE_CONTAINER = `${CLASSNAME_CAROUSEL}-image-container`; const CLASSNAME_IMAGE_CONTAINER = `${CLASSNAME_CAROUSEL}-image-container`;
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`; const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
@ -91,7 +92,7 @@ const SCROLL_ZOOM_TIMEOUT = 250;
const ZOOM_NONE_EPSILON = 0.015; const ZOOM_NONE_EPSILON = 0.015;
interface ILightboxCarouselProps { interface ILightboxCarouselProps {
instantTransition: boolean; transition: string | null;
currentIndex: number; currentIndex: number;
images: ILightboxImage[]; images: ILightboxImage[];
displayMode: GQL.ImageLightboxDisplayMode; displayMode: GQL.ImageLightboxDisplayMode;
@ -106,11 +107,12 @@ interface ILightboxCarouselProps {
debouncedScrollReset: () => void; debouncedScrollReset: () => void;
handleLeft: () => void; handleLeft: () => void;
handleRight: () => void; handleRight: () => void;
overrideTransition: (t: string) => void;
} }
const LightboxCarousel = forwardRef(function ( const LightboxCarousel = forwardRef(function (
{ {
instantTransition, transition,
currentIndex, currentIndex,
images, images,
displayMode, displayMode,
@ -125,15 +127,36 @@ const LightboxCarousel = forwardRef(function (
debouncedScrollReset, debouncedScrollReset,
handleLeft, handleLeft,
handleRight, handleRight,
overrideTransition,
}: ILightboxCarouselProps, }: ILightboxCarouselProps,
carouselRef: ForwardedRef<HTMLDivElement> carouselRef: ForwardedRef<HTMLDivElement>
) { ) {
const [carouselShift, setCarouselShift] = useState(0);
function handleMoveCarousel(delta: number) {
overrideTransition(CLASSNAME_INSTANT);
setCarouselShift(carouselShift + delta);
}
function handleReleaseCarousel(
event: React.TouchEvent,
swipeDuration: number
) {
const cappedDuration = Math.max(50, Math.min(500, swipeDuration)) / 1000;
const adjustedShift = carouselShift / (2 * cappedDuration);
if (adjustedShift < -window.innerWidth / 2) {
handleRight();
} else if (adjustedShift > window.innerWidth / 2) {
handleLeft();
}
setCarouselShift(0);
overrideTransition(CLASSNAME_SWIPE);
}
return ( return (
<div <div
className={cx(CLASSNAME_CAROUSEL, { className={CLASSNAME_CAROUSEL + (transition ? ` ${transition}` : "")}
[CLASSNAME_INSTANT]: instantTransition, style={{ left: `calc(${currentIndex * -100}vw + ${carouselShift}px)` }}
})}
style={{ left: `${currentIndex * -100}vw` }}
ref={carouselRef} ref={carouselRef}
> >
{images.map((image, i) => ( {images.map((image, i) => (
@ -160,6 +183,8 @@ const LightboxCarousel = forwardRef(function (
onLeft={handleLeft} onLeft={handleLeft}
onRight={handleRight} onRight={handleRight}
isVideo={isVideo(image.visual_files?.[0] ?? {})} isVideo={isVideo(image.visual_files?.[0] ?? {})}
moveCarousel={handleMoveCarousel}
releaseCarousel={handleReleaseCarousel}
/> />
) : undefined} ) : undefined}
</div> </div>
@ -203,7 +228,7 @@ export const LightboxComponent: React.FC<IProps> = ({
const [index, setIndex] = useState<number | null>(null); const [index, setIndex] = useState<number | null>(null);
const [movingLeft, setMovingLeft] = useState(false); const [movingLeft, setMovingLeft] = useState(false);
const oldIndex = useRef<number | null>(null); const oldIndex = useRef<number | null>(null);
const [instantTransition, setInstantTransition] = useState(false); const [transition, setTransition] = useState<string | null>(null);
const [isSwitchingPage, setIsSwitchingPage] = useState(true); const [isSwitchingPage, setIsSwitchingPage] = useState(true);
const [isFullscreen, setFullscreen] = useState(false); const [isFullscreen, setFullscreen] = useState(false);
const [showOptions, setShowOptions] = useState(false); const [showOptions, setShowOptions] = useState(false);
@ -315,15 +340,15 @@ export const LightboxComponent: React.FC<IProps> = ({
} }
}, [isSwitchingPage, images, index]); }, [isSwitchingPage, images, index]);
const disableInstantTransition = useDebounce( const restoreTransition = useDebounce(() => setTransition(null), 400);
() => setInstantTransition(false),
400
);
const setInstant = useCallback(() => { const overrideTransition = useCallback(
setInstantTransition(true); (t: string) => {
disableInstantTransition(); setTransition(t);
}, [disableInstantTransition]); restoreTransition();
},
[restoreTransition]
);
useEffect(() => { useEffect(() => {
if (images.length < 2) return; if (images.length < 2) return;
@ -487,12 +512,12 @@ export const LightboxComponent: React.FC<IProps> = ({
const handleKey = useCallback( const handleKey = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft")) if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft"))
setInstant(); overrideTransition(CLASSNAME_INSTANT);
if (e.key === "ArrowLeft") handleLeft(); if (e.key === "ArrowLeft") handleLeft();
else if (e.key === "ArrowRight") handleRight(); else if (e.key === "ArrowRight") handleRight();
else if (e.key === "Escape") close(); else if (e.key === "Escape") close();
}, },
[setInstant, handleLeft, handleRight, close] [overrideTransition, handleLeft, handleRight, close]
); );
const handleFullScreenChange = () => { const handleFullScreenChange = () => {
if (clearIntervalCallback.current) { if (clearIntervalCallback.current) {
@ -934,7 +959,7 @@ export const LightboxComponent: React.FC<IProps> = ({
</Button> </Button>
)} )}
<LightboxCarousel <LightboxCarousel
instantTransition={instantTransition} transition={transition}
currentIndex={currentIndex} currentIndex={currentIndex}
images={images} images={images}
displayMode={displayMode} displayMode={displayMode}
@ -949,6 +974,7 @@ export const LightboxComponent: React.FC<IProps> = ({
debouncedScrollReset={debouncedScrollReset} debouncedScrollReset={debouncedScrollReset}
handleLeft={handleLeft} handleLeft={handleLeft}
handleRight={handleRight} handleRight={handleRight}
overrideTransition={overrideTransition}
/> />
{allowNavigation && ( {allowNavigation && (
<Button <Button

View file

@ -102,6 +102,8 @@ interface IProps {
debouncedScrollReset: () => void; debouncedScrollReset: () => void;
onLeft: () => void; onLeft: () => void;
onRight: () => void; onRight: () => void;
moveCarousel: (v: number) => void;
releaseCarousel: (ev: React.TouchEvent, swipeDuration: number) => void;
isVideo: boolean; isVideo: boolean;
} }
@ -123,9 +125,11 @@ export const LightboxImage: React.FC<IProps> = ({
debouncedScrollReset, debouncedScrollReset,
onLeft, onLeft,
onRight, onRight,
moveCarousel,
releaseCarousel,
isVideo, isVideo,
}) => { }) => {
const [defaultZoom, setDefaultZoom] = useState(1); const [defaultZoom, setDefaultZoom] = useState<number | null>(null);
const [moving, setMoving] = useState(false); const [moving, setMoving] = useState(false);
const [positionX, setPositionX] = useState(0); const [positionX, setPositionX] = useState(0);
const [positionY, setPositionY] = useState(0); const [positionY, setPositionY] = useState(0);
@ -139,6 +143,7 @@ export const LightboxImage: React.FC<IProps> = ({
const resetPositionRef = useRef(resetPosition); const resetPositionRef = useRef(resetPosition);
const startPoints = useRef<number[]>([0, 0]); const startPoints = useRef<number[]>([0, 0]);
const startTime = useRef<number>(0);
const pointerCache = useRef<React.PointerEvent[]>([]); const pointerCache = useRef<React.PointerEvent[]>([]);
const prevDiff = useRef<number | undefined>(); const prevDiff = useRef<number | undefined>();
@ -200,7 +205,10 @@ export const LightboxImage: React.FC<IProps> = ({
}, },
[imageWidth, boxWidth, imageHeight, boxHeight] [imageWidth, boxWidth, imageHeight, boxHeight]
); );
const panBounds = calcPanBounds(defaultZoom * zoom); const panBounds =
defaultZoom !== null
? calcPanBounds(defaultZoom * zoom)
: { minX: 0, maxX: 0, minY: 0, maxY: 0, nonZero: false };
const minMaxY = useCallback( const minMaxY = useCallback(
(appliedZoom: number) => { (appliedZoom: number) => {
@ -279,6 +287,9 @@ export const LightboxImage: React.FC<IProps> = ({
]); ]);
useEffect(() => { useEffect(() => {
if (defaultZoom === null) {
return;
}
if (resetPosition !== resetPositionRef.current) { if (resetPosition !== resetPositionRef.current) {
resetPositionRef.current = resetPosition; resetPositionRef.current = resetPosition;
@ -364,7 +375,7 @@ export const LightboxImage: React.FC<IProps> = ({
} }
function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) { function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) {
if (!current) return; if (!current || defaultZoom === null) return;
const [minY, maxY] = minMaxY(zoom * defaultZoom); const [minY, maxY] = minMaxY(zoom * defaultZoom);
@ -466,6 +477,7 @@ export const LightboxImage: React.FC<IProps> = ({
function onImageMouseDown(ev: React.MouseEvent) { function onImageMouseDown(ev: React.MouseEvent) {
startPoints.current = [ev.pageX, ev.pageY]; startPoints.current = [ev.pageX, ev.pageY];
startTime.current = ev.timeStamp;
setMoving(true); setMoving(true);
mouseDownEvent.current = ev.nativeEvent; mouseDownEvent.current = ev.nativeEvent;
@ -497,24 +509,58 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onTouchStart(ev: React.TouchEvent) { const onTouchStart = useCallback(
ev.preventDefault(); (ev: TouchEvent) => {
if (ev.touches.length === 1) { ev.preventDefault();
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; if (ev.touches.length === 1) {
setMoving(true); startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
startTime.current = ev.timeStamp;
setMoving(true);
}
},
[startPoints, startTime, setMoving]
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
} }
} container.addEventListener("touchstart", onTouchStart);
return () => {
container.removeEventListener("touchstart", onTouchStart);
};
}, [containerRef, onTouchStart]);
function onTouchMove(ev: React.TouchEvent) { function onTouchMove(ev: React.TouchEvent) {
if (!moving) return; if (!moving) return;
if (ev.touches.length === 1) { if (ev.touches.length === 1) {
const posX = ev.touches[0].pageX - startPoints.current[0]; const deltaX = ev.touches[0].pageX - startPoints.current[0];
const posY = ev.touches[0].pageY - startPoints.current[1]; const deltaY = ev.touches[0].pageY - startPoints.current[1];
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
setPositionX(positionX + posX); if (panBounds.minX != panBounds.maxX) {
setPositionY(positionY + posY); const newPositionX = Math.max(
panBounds.minX,
Math.min(panBounds.maxX, positionX + deltaX)
);
const newPositionY = Math.max(
panBounds.minY,
Math.min(panBounds.maxY, positionY + deltaY)
);
setPositionX(newPositionX);
setPositionY(newPositionY);
} else {
moveCarousel(deltaX);
}
}
}
function onTouchEnd(ev: React.TouchEvent) {
if (ev.changedTouches.length === 1 && ev.touches.length === 0) {
releaseCarousel(ev, ev.timeStamp - startTime.current);
} }
} }
@ -581,6 +627,8 @@ export const LightboxImage: React.FC<IProps> = ({
})} })}
style={{ touchAction: "none" }} style={{ touchAction: "none" }}
onWheel={(e) => onContainerScroll(e)} onWheel={(e) => onContainerScroll(e)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
> >
{defaultZoom ? ( {defaultZoom ? (
<picture> <picture>
@ -603,8 +651,6 @@ export const LightboxImage: React.FC<IProps> = ({
onMouseDown={onImageMouseDown} onMouseDown={onImageMouseDown}
onMouseUp={onImageMouseUp} onMouseUp={onImageMouseUp}
onMouseMove={onImageMouseOver} onMouseMove={onImageMouseOver}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}

View file

@ -144,6 +144,11 @@
transition-duration: 0ms; transition-duration: 0ms;
} }
&-swipe {
transition-duration: 200ms;
transition-timing-function: ease-out;
}
&-image-container { &-image-container {
content-visibility: auto; content-visibility: auto;
width: 100vw; width: 100vw;