Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,35 @@ import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer
import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons';
import { selectShouldShowItemDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useCallback, useRef, useState } from 'react';
import { animate, AnimatePresence, motion, useMotionValue } from 'framer-motion';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';

import { useImageViewerContext } from './context';
import { NoContentForViewer } from './NoContentForViewer';
import { ProgressImage } from './ProgressImage2';
import { ProgressIndicator } from './ProgressIndicator2';
import { useSwipeNavigation } from './useSwipeNavigation';

// Minimum duration for carousel transitions (in seconds)
// This prevents instant transitions when users flick rapidly
const MIN_TRANSITION_DURATION = 0.3;

export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => {
const shouldShowItemDetails = useAppSelector(selectShouldShowItemDetails);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const { onLoadImage, $progressEvent, $progressImage } = useImageViewerContext();
const progressEvent = useStore($progressEvent);
const progressImage = useStore($progressImage);
const { onDragEnd, previousImageName, nextImageName, canNavigatePrevious, canNavigateNext } = useSwipeNavigation();

// Get adjacent images for swipe preview
const previousImageDTO = useImageDTO(previousImageName);
const nextImageDTO = useImageDTO(nextImageName);

// Track drag state
const dragX = useMotionValue(0);

// Show and hide the next/prev buttons on mouse move
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState<boolean>(false);
Expand All @@ -38,28 +52,148 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu

const withProgress = shouldShowProgressInViewer && progressImage !== null;

// Reset drag state when image changes
useEffect(() => {
// Instantly reset position when image changes (navigation occurred)
dragX.set(0);
}, [imageDTO?.image_name, dragX]);

const handleDragEnd = useCallback(
(event: MouseEvent | TouchEvent | PointerEvent, info: { offset: { x: number; y: number } }) => {
// Check if navigation will occur (threshold is 50px in useSwipeNavigation)
const willNavigate = Math.abs(info.offset.x) > 50;

onDragEnd(event, info);

// Only animate back to center if navigation did NOT occur
// If navigation occurs, the useEffect will reset position when image changes
if (!willNavigate) {
animate(dragX, 0, {
type: 'spring',
stiffness: 300,
damping: 30,
duration: MIN_TRANSITION_DURATION,
});
}
},
[onDragEnd, dragX]
);

// Set drag constraints based on navigation boundaries
// Prevent dragging past the first/last image
const dragConstraints = useMemo(() => {
return {
left: canNavigateNext ? undefined : 0, // Prevent dragging left if can't go to next
right: canNavigatePrevious ? undefined : 0, // Prevent dragging right if can't go to previous
};
}, [canNavigatePrevious, canNavigateNext]);

return (
<Flex
<Box
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
width="full"
height="full"
alignItems="center"
justifyContent="center"
position="relative"
overflow="hidden"
>
{imageDTO && (
<Flex
key={imageDTO.image_name}
w="full"
h="full"
position="absolute"
{/* Horizontal carousel container with 3 image slots */}
<Box
as={motion.div}
drag={imageDTO ? 'x' : false}
dragConstraints={dragConstraints}
dragElastic={0.1}
dragMomentum={false}
onDragEnd={handleDragEnd}
style={{ x: dragX }}
width="300%"
height="full"
display="flex"
flexDirection="row"
position="absolute"
left="-100%"
>
{/* Previous image slot (left third) */}
<Box
width="33.333%"
height="full"
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
flexShrink={0}
>
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} borderRadius="base" />
</Flex>
)}
{previousImageDTO && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
alignItems="center"
justifyContent="center"
pointerEvents="none"
>
<DndImage imageDTO={previousImageDTO} borderRadius="base" />
</Box>
)}
</Box>

{/* Current image slot (middle third) */}
<Box
width="33.333%"
height="full"
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
flexShrink={0}
>
{imageDTO && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
alignItems="center"
justifyContent="center"
>
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} borderRadius="base" />
</Box>
)}
</Box>

{/* Next image slot (right third) */}
<Box
width="33.333%"
height="full"
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
flexShrink={0}
>
{nextImageDTO && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
alignItems="center"
justifyContent="center"
pointerEvents="none"
>
<DndImage imageDTO={nextImageDTO} borderRadius="base" />
</Box>
)}
</Box>
</Box>

{!imageDTO && <NoContentForViewer />}
{withProgress && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center" bg="base.900">
Expand Down Expand Up @@ -96,7 +230,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
</Box>
)}
</AnimatePresence>
</Flex>
</Box>
);
});
CurrentImagePreview.displayName = 'CurrentImagePreview';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { clamp } from 'es-toolkit/compat';
import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { useCallback, useMemo } from 'react';

const SWIPE_THRESHOLD = 50; // Minimum distance in pixels to trigger swipe

export const useSwipeNavigation = () => {
const dispatch = useAppDispatch();
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
const { imageNames, isFetching } = useGalleryImageNames();

const currentIndex = useMemo(
() => (lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) : -1),
[imageNames, lastSelectedItem]
);

const isOnFirstItem = useMemo(() => currentIndex === 0, [currentIndex]);
const isOnLastItem = useMemo(() => currentIndex === imageNames.length - 1, [currentIndex, imageNames.length]);

const previousImageName = useMemo(
() => (currentIndex > 0 ? imageNames[currentIndex - 1] : null),
[currentIndex, imageNames]
);

const nextImageName = useMemo(
() => (currentIndex < imageNames.length - 1 ? imageNames[currentIndex + 1] : null),
[currentIndex, imageNames]
);

const navigateToPrevious = useCallback(() => {
if (isOnFirstItem || isFetching) {
return;
}
const targetIndex = currentIndex - 1;
const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1);
const n = imageNames.at(clampedIndex);
if (!n) {
return;
}
dispatch(imageSelected(n));
}, [dispatch, imageNames, currentIndex, isOnFirstItem, isFetching]);

const navigateToNext = useCallback(() => {
if (isOnLastItem || isFetching) {
return;
}
const targetIndex = currentIndex + 1;
const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1);
const n = imageNames.at(clampedIndex);
if (!n) {
return;
}
dispatch(imageSelected(n));
}, [dispatch, imageNames, currentIndex, isOnLastItem, isFetching]);

const onDragEnd = useCallback(
(_event: MouseEvent | TouchEvent | PointerEvent, info: { offset: { x: number; y: number } }) => {
// Swipe right (positive x) = go to previous image
// Swipe left (negative x) = go to next image
if (info.offset.x > SWIPE_THRESHOLD) {
navigateToPrevious();
} else if (info.offset.x < -SWIPE_THRESHOLD) {
navigateToNext();
}
},
[navigateToPrevious, navigateToNext]
);

return {
onDragEnd,
canNavigatePrevious: !isOnFirstItem,
canNavigateNext: !isOnLastItem,
previousImageName,
nextImageName,
};
};