diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 97155b0f05e..f8f0f1369e2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -7,14 +7,20 @@ 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); @@ -22,6 +28,14 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu 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(false); @@ -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 ( - - {imageDTO && ( - + {/* Previous image slot (left third) */} + - - - )} + {previousImageDTO && ( + + + + )} + + + {/* Current image slot (middle third) */} + + {imageDTO && ( + + + + )} + + + {/* Next image slot (right third) */} + + {nextImageDTO && ( + + + + )} + + + {!imageDTO && } {withProgress && ( @@ -96,7 +230,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu )} - + ); }); CurrentImagePreview.displayName = 'CurrentImagePreview'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useSwipeNavigation.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useSwipeNavigation.ts new file mode 100644 index 00000000000..c3ac3268dce --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useSwipeNavigation.ts @@ -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, + }; +};