diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 48e7fa800c..2d3db05bf7 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -23,6 +23,7 @@ import { addEnqueueRequestedCanvasListener } from 'app/store/middleware/listener import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; +import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged'; import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema'; import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard'; import { addRequestedSingleImageDeletionListener } from 'app/store/middleware/listenerMiddleware/listeners/imageDeleted'; @@ -79,6 +80,7 @@ addImagesUnstarredListener(startAppListening); // Gallery addGalleryImageClickedListener(startAppListening); +addGalleryOffsetChangedListener(startAppListening); // User Invoked addEnqueueRequestedCanvasListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts new file mode 100644 index 0000000000..51095700e3 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts @@ -0,0 +1,119 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { imageToCompareChanged, offsetChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; + +export const addGalleryOffsetChangedListener = (startAppListening: AppStartListening) => { + /** + * When the user changes pages in the gallery, we need to wait until the next page of images is loaded, then maybe + * update the selection. + * + * There are a three scenarios: + * + * 1. The page is changed by clicking the pagination buttons. No changes to selection are needed. + * + * 2. The page is changed by using the arrow keys (without alt). + * - When going backwards, select the last image. + * - When going forwards, select the first image. + * + * 3. The page is changed by using the arrows keys with alt. This means the user is changing the comparison image. + * - When going backwards, select the last image _as the comparison image_. + * - When going forwards, select the first image _as the comparison image_. + */ + startAppListening({ + actionCreator: offsetChanged, + effect: async (action, { dispatch, getState, getOriginalState, take, cancelActiveListeners }) => { + // Cancel any active listeners to prevent the selection from changing without user input + cancelActiveListeners(); + + const { withHotkey } = action.payload; + + if (!withHotkey) { + // User changed pages by clicking the pagination buttons - no changes to selection + return; + } + + const originalState = getOriginalState(); + const prevOffset = originalState.gallery.offset; + const offset = getState().gallery.offset; + + if (offset === prevOffset) { + // The page didn't change - bail + return; + } + + /** + * We need to wait until the next page of images is loaded before updating the selection, so we use the correct + * page of images. + * + * The simplest way to do it would be to use `take` to wait for the next fulfilled action, but RTK-Q doesn't + * dispatch an action on cache hits. This means the `take` will only return if the cache is empty. If the user + * changes to a cached page - a common situation - the `take` will never resolve. + * + * So we need to take a two-step approach. First, check if we have data in the cache for the page of images. If + * we have data cached, use it to update the selection. If we don't have data cached, wait for the next fulfilled + * action, which updates the cache, then use the cache to update the selection. + */ + + // Check if we have data in the cache for the page of images + const queryArgs = selectListImagesQueryArgs(getState()); + let { data } = imagesApi.endpoints.listImages.select(queryArgs)(getState()); + + // No data yet - wait for the network request to complete + if (!data) { + const takeResult = await take(imagesApi.endpoints.listImages.matchFulfilled, 5000); + if (!takeResult) { + // The request didn't complete in time - bail + return; + } + data = takeResult[0].payload; + } + + // We awaited a network request - state could have changed, get fresh state + const state = getState(); + const { selection, imageToCompare } = state.gallery; + const imageDTOs = data?.items; + + if (!imageDTOs) { + // The page didn't load - bail + return; + } + + if (withHotkey === 'arrow') { + // User changed pages by using the arrow keys - selection changes to first or last image depending + if (offset < prevOffset) { + // We've gone backwards + const lastImage = imageDTOs[imageDTOs.length - 1]; + if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) { + dispatch(selectionChanged(lastImage ? [lastImage] : [])); + } + } else { + // We've gone forwards + const firstImage = imageDTOs[0]; + if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) { + dispatch(selectionChanged(firstImage ? [firstImage] : [])); + } + } + return; + } + + if (withHotkey === 'alt+arrow') { + // User changed pages by using the arrow keys with alt - comparison image changes to first or last depending + if (offset < prevOffset) { + // We've gone backwards + const lastImage = imageDTOs[imageDTOs.length - 1]; + if (lastImage && imageToCompare?.image_name !== lastImage.image_name) { + dispatch(imageToCompareChanged(lastImage)); + } + } else { + // We've gone forwards + const firstImage = imageDTOs[0]; + if (firstImage && imageToCompare?.image_name !== firstImage.image_name) { + dispatch(imageToCompareChanged(firstImage)); + } + } + return; + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts index 40cacc78cb..2fc6210397 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts @@ -91,7 +91,7 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi ); } - dispatch(offsetChanged(0)); + dispatch(offsetChanged({ offset: 0 })); if (!imageDTO.board_id && gallery.selectedBoardId !== 'none') { dispatch( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx index 8b0f118f92..ddc23ebe4b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx @@ -1,11 +1,20 @@ import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; import { ELLIPSIS, useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; +import { useCallback } from 'react'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; export const GalleryPagination = () => { const { goPrev, goNext, isPrevEnabled, isNextEnabled, pageButtons, goToPage, currentPage, total } = useGalleryPagination(); + const onClickPrev = useCallback(() => { + goPrev(); + }, [goPrev]); + + const onClickNext = useCallback(() => { + goNext(); + }, [goNext]); + if (!total) { return null; } @@ -16,7 +25,7 @@ export const GalleryPagination = () => { size="sm" aria-label="prev" icon={} - onClick={goPrev} + onClick={onClickPrev} isDisabled={!isPrevEnabled} variant="ghost" /> @@ -45,7 +54,7 @@ export const GalleryPagination = () => { size="sm" aria-label="next" icon={} - onClick={goNext} + onClick={onClickNext} isDisabled={!isNextEnabled} variant="ghost" /> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index e82a575991..6e111e59c0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -17,8 +17,8 @@ export const GallerySelectionCountTag = () => { }, [dispatch]); const onSelectPage = useCallback(() => { - dispatch(selectionChanged(imageDTOs)); - }, [dispatch, imageDTOs]); + dispatch(selectionChanged([...selection, ...imageDTOs])); + }, [dispatch, selection, imageDTOs]); useHotkeys(['ctrl+a', 'meta+a'], onSelectPage, { preventDefault: true }, [onSelectPage]); diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index 400eb38a94..7b2c06359b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -1,11 +1,11 @@ import type { ChakraProps } from '@invoke-ai/ui-library'; -import { Box, Flex, IconButton, Spinner } from '@invoke-ai/ui-library'; +import { Box, IconButton } from '@invoke-ai/ui-library'; import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation'; import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; -import { memo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiCaretDoubleRightBold, PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; +import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; const nextPrevButtonStyles: ChakraProps['sx'] = { color: 'base.100', @@ -14,52 +14,78 @@ const nextPrevButtonStyles: ChakraProps['sx'] = { const NextPrevImageButtons = () => { const { t } = useTranslation(); - - const { prevImage, nextImage, isOnFirstImage, isOnLastImage } = useGalleryNavigation(); + const { prevImage, nextImage, isOnFirstImageOfView, isOnLastImageOfView } = useGalleryNavigation(); const { isFetching } = useGalleryImages().queryResult; - const { isNextEnabled, goNext } = useGalleryPagination(); + const { isNextEnabled, goNext, isPrevEnabled, goPrev } = useGalleryPagination(); + + const shouldShowLeftArrow = useMemo(() => { + if (!isOnFirstImageOfView) { + return true; + } + if (isPrevEnabled) { + return true; + } + return false; + }, [isOnFirstImageOfView, isPrevEnabled]); + + const onClickLeftArrow = useCallback(() => { + if (isOnFirstImageOfView) { + if (isPrevEnabled && !isFetching) { + goPrev('arrow'); + } + } else { + prevImage(); + } + }, [goPrev, isFetching, isOnFirstImageOfView, isPrevEnabled, prevImage]); + + const shouldShowRightArrow = useMemo(() => { + if (!isOnLastImageOfView) { + return true; + } + if (isNextEnabled) { + return true; + } + return false; + }, [isNextEnabled, isOnLastImageOfView]); + + const onClickRightArrow = useCallback(() => { + if (isOnLastImageOfView) { + if (isNextEnabled && !isFetching) { + goNext('arrow'); + } + } else { + nextImage(); + } + }, [goNext, isFetching, isNextEnabled, isOnLastImageOfView, nextImage]); return ( - {!isOnFirstImage && ( + {shouldShowLeftArrow && ( } variant="unstyled" - onClick={prevImage} + onClick={onClickLeftArrow} boxSize={16} sx={nextPrevButtonStyles} + isDisabled={isFetching} /> )} - {!isOnLastImage && ( + {shouldShowRightArrow && ( } variant="unstyled" - onClick={nextImage} + onClick={onClickRightArrow} boxSize={16} sx={nextPrevButtonStyles} + isDisabled={isFetching} /> )} - {isOnLastImage && isNextEnabled && !isFetching && ( - } - variant="unstyled" - onClick={goNext} - boxSize={16} - sx={nextPrevButtonStyles} - /> - )} - {isOnLastImage && isNextEnabled && isFetching && ( - - - - )} ); diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts index 6dd7065204..5b14fb7a8c 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts @@ -28,7 +28,8 @@ export const useGalleryHotkeys = () => { handleRightImage, handleUpImage, handleDownImage, - areImagesBelowCurrent, + isOnFirstRow, + isOnLastRow, isOnFirstImageOfView, isOnLastImageOfView, } = useGalleryNavigation(); @@ -37,7 +38,7 @@ export const useGalleryHotkeys = () => { ['left', 'alt+left'], (e) => { if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) { - goPrev(); + goPrev(e.altKey ? 'alt+arrow' : 'arrow'); return; } canNavigateGallery && handleLeftImage(e.altKey); @@ -52,7 +53,7 @@ export const useGalleryHotkeys = () => { return; } if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) { - goNext(); + goNext(e.altKey ? 'alt+arrow' : 'arrow'); return; } if (!isOnLastImageOfView) { @@ -65,22 +66,26 @@ export const useGalleryHotkeys = () => { useHotkeys( ['up', 'alt+up'], (e) => { + if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) { + goPrev(e.altKey ? 'alt+arrow' : 'arrow'); + return; + } handleUpImage(e.altKey); }, { preventDefault: true }, - [handleUpImage] + [handleUpImage, canNavigateGallery, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching] ); useHotkeys( ['down', 'alt+down'], (e) => { - if (!areImagesBelowCurrent && isNextEnabled && !queryResult.isFetching) { - goNext(); + if (isOnLastRow && isNextEnabled && !queryResult.isFetching) { + goNext(e.altKey ? 'alt+arrow' : 'arrow'); return; } handleDownImage(e.altKey); }, { preventDefault: true }, - [areImagesBelowCurrent, goNext, isNextEnabled, queryResult.isFetching, handleDownImage] + [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage] ); }; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts index 3227cbdfa1..1655e284cb 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts @@ -112,7 +112,8 @@ type UseGalleryNavigationReturn = { nextImage: () => void; isOnFirstImage: boolean; isOnLastImage: boolean; - areImagesBelowCurrent: boolean; + isOnFirstRow: boolean; + isOnLastRow: boolean; isOnFirstImageOfView: boolean; isOnLastImageOfView: boolean; }; @@ -167,10 +168,11 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => { [lastSelectedImageIndex, loadedImagesCount] ); - const areImagesBelowCurrent = useMemo(() => { - const imagesPerRow = getImagesPerRow(); - return lastSelectedImageIndex + imagesPerRow < loadedImagesCount; - }, [lastSelectedImageIndex, loadedImagesCount]); + const isOnFirstRow = useMemo(() => lastSelectedImageIndex < getImagesPerRow(), [lastSelectedImageIndex]); + const isOnLastRow = useMemo( + () => lastSelectedImageIndex >= loadedImagesCount - getImagesPerRow(), + [lastSelectedImageIndex, loadedImagesCount] + ); const isOnFirstImageOfView = useMemo(() => { return lastSelectedImageIndex === 0; @@ -223,7 +225,8 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => { handleDownImage, isOnFirstImage, isOnLastImage, - areImagesBelowCurrent, + isOnFirstRow, + isOnLastRow, nextImage, prevImage, isOnFirstImageOfView, diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts index cfd4a488c3..75bea2dcb1 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts @@ -80,25 +80,31 @@ export const useGalleryPagination = () => { return offset > 0; }, [count, offset]); - const goNext = useCallback(() => { - dispatch(offsetChanged(offset + (limit || 0))); - }, [dispatch, offset, limit]); + const goNext = useCallback( + (withHotkey?: 'arrow' | 'alt+arrow') => { + dispatch(offsetChanged({ offset: offset + (limit || 0), withHotkey })); + }, + [dispatch, offset, limit] + ); - const goPrev = useCallback(() => { - dispatch(offsetChanged(Math.max(offset - (limit || 0), 0))); - }, [dispatch, offset, limit]); + const goPrev = useCallback( + (withHotkey?: 'arrow' | 'alt+arrow') => { + dispatch(offsetChanged({ offset: Math.max(offset - (limit || 0), 0), withHotkey })); + }, + [dispatch, offset, limit] + ); const goToPage = useCallback( (page: number) => { - dispatch(offsetChanged(page * (limit || 0))); + dispatch(offsetChanged({ offset: page * (limit || 0) })); }, [dispatch, limit] ); const goToFirst = useCallback(() => { - dispatch(offsetChanged(0)); + dispatch(offsetChanged({ offset: 0 })); }, [dispatch]); const goToLast = useCallback(() => { - dispatch(offsetChanged((pages - 1) * (limit || 0))); + dispatch(offsetChanged({ offset: (pages - 1) * (limit || 0) })); }, [dispatch, pages, limit]); // handle when total/pages decrease and user is on high page number (ie bulk removing or deleting) @@ -127,44 +133,22 @@ export const useGalleryPagination = () => { return Math.min((currentPage + 1) * (limit || 0), total); }, [currentPage, limit, total]); - const api = useMemo( - () => ({ - count, - total, - currentPage, - pages, - isNextEnabled, - isPrevEnabled, - goNext, - goPrev, - goToPage, - goToFirst, - goToLast, - pageButtons, - isFirstEnabled, - isLastEnabled, - rangeDisplay, - numberOnPage, - }), - [ - count, - total, - currentPage, - pages, - isNextEnabled, - isPrevEnabled, - goNext, - goPrev, - goToPage, - goToFirst, - goToLast, - pageButtons, - isFirstEnabled, - isLastEnabled, - rangeDisplay, - numberOnPage, - ] - ); - - return api; + return { + count, + total, + currentPage, + pages, + isNextEnabled, + isPrevEnabled, + goNext, + goPrev, + goToPage, + goToFirst, + goToLast, + pageButtons, + isFirstEnabled, + isLastEnabled, + rangeDisplay, + numberOnPage, + }; }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 76376df4dd..c94115ecfc 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -104,8 +104,9 @@ export const gallerySlice = createSlice({ comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => { state.comparisonFit = action.payload; }, - offsetChanged: (state, action: PayloadAction) => { - state.offset = action.payload; + offsetChanged: (state, action: PayloadAction<{ offset: number; withHotkey?: 'arrow' | 'alt+arrow' }>) => { + const { offset } = action.payload; + state.offset = offset; }, limitChanged: (state, action: PayloadAction) => { state.limit = action.payload;