From b5eb3d9798b529e678ffdff4f5a17da104b3a351 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 12:19:21 +1000 Subject: [PATCH] fix(ui): gallery updates on image completion --- .../middleware/listenerMiddleware/index.ts | 2 - .../listeners/galleryImageClicked.ts | 4 +- .../listeners/galleryOffsetChanged.ts | 119 ----------- invokeai/frontend/web/src/app/store/store.ts | 3 +- .../ImageViewer/CurrentImagePreview.tsx | 3 +- .../gallery/components/NewGallery.tsx | 95 +++++---- .../components/NextPrevImageButtons.tsx | 4 - .../gallery/hooks/useGalleryHotkeys.ts | 15 -- .../gallery/store/gallerySelectors.ts | 58 +++--- .../features/gallery/store/gallerySlice.ts | 16 +- .../web/src/features/gallery/store/types.ts | 2 - .../nodes/CurrentImage/CurrentImageNode.tsx | 3 +- .../web/src/services/api/endpoints/images.ts | 4 +- .../frontend/web/src/services/api/index.ts | 2 + .../services/events/onInvocationComplete.tsx | 191 +++++++++--------- 15 files changed, 191 insertions(+), 330 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts 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 856dc31ec7..3629eb345f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -11,7 +11,6 @@ import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddlewar import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; import { addEnsureImageIsSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener'; 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 { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard'; @@ -47,7 +46,6 @@ addDeleteBoardAndImagesFulfilledListener(startAppListening); // Gallery addGalleryImageClickedListener(startAppListening); -addGalleryOffsetChangedListener(startAppListening); // User Invoked addEnqueueRequestedLinear(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index c41ef7c654..f95b7502f0 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -1,7 +1,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { RootState } from 'app/store/store'; -import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { uniq } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; @@ -50,7 +50,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen effect: (action, { dispatch, getState }) => { const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload; const state = getState(); - const queryArgs = selectImageCollectionQueryArgs(state); + const queryArgs = selectListImagesQueryArgs(state); // Get cached image names for selection operations const imageNames = getCachedImageNames(state, queryArgs); 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 deleted file mode 100644 index 359fa647d9..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 === lastImage?.image_name)) { - dispatch(selectionChanged(lastImage ? [lastImage.image_name] : [])); - } - } else { - // We've gone forwards - const firstImage = imageDTOs[0]; - if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) { - dispatch(selectionChanged(firstImage ? [firstImage.image_name] : [])); - } - } - 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 !== lastImage.image_name) { - dispatch(imageToCompareChanged(lastImage.image_name)); - } - } else { - // We've gone forwards - const firstImage = imageDTOs[0]; - if (firstImage && imageToCompare !== firstImage.image_name) { - dispatch(imageToCompareChanged(firstImage.image_name)); - } - } - return; - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index ec757494f5..9397144751 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -39,6 +39,7 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware'; import type { JsonObject } from 'type-fest'; import { STORAGE_PREFIX } from './constants'; +import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware'; import { actionSanitizer } from './middleware/devtools/actionSanitizer'; import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { stateSanitizer } from './middleware/devtools/stateSanitizer'; @@ -176,7 +177,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) - // .concat(getDebugLoggerMiddleware()) + .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); 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 faa0bd91ad..4e57a9b035 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -4,7 +4,6 @@ import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; @@ -115,7 +114,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu left={0} pointerEvents="none" > - + {/* */} )} diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index f9e2cab7b2..36b33fa406 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -5,8 +5,8 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectGalleryImageMinimumWidth, - selectImageCollectionQueryArgs, selectLastSelectedImage, + selectListImagesQueryArgs, } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; @@ -22,7 +22,7 @@ import type { } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; import { useGetImageNamesQuery, useListImagesQuery } from 'services/api/endpoints/images'; -import type { ImageDTO, ListImagesArgs } from 'services/api/types'; +import type { ImageDTO } from 'services/api/types'; import { useDebounce } from 'use-debounce'; import { GalleryImage } from './ImageGrid/GalleryImage'; @@ -30,32 +30,36 @@ import { GalleryImage } from './ImageGrid/GalleryImage'; const log = logger('gallery'); // Constants -const PAGE_SIZE = 100; const VIEWPORT_BUFFER = 2048; const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096; const DEBOUNCE_DELAY = 500; const SPINNER_OPACITY = 0.3; +type ListImagesQueryArgs = ReturnType; + type GridContext = { - queryArgs: ListImagesArgs; + queryArgs: ListImagesQueryArgs; imageNames: string[]; }; export const useDebouncedImageCollectionQueryArgs = () => { - const _galleryQueryArgs = useAppSelector(selectImageCollectionQueryArgs); + const _galleryQueryArgs = useAppSelector(selectListImagesQueryArgs); const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY); return queryArgs; }; // Hook to get an image DTO from cache or trigger loading -const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: ListImagesArgs): ImageDTO | null => { +const useImageDTOFromListQuery = ( + index: number, + imageName: string, + queryArgs: ListImagesQueryArgs +): ImageDTO | null => { const { arg, options } = useMemo(() => { - const pageOffset = Math.floor(index / PAGE_SIZE) * PAGE_SIZE; + const pageOffset = Math.floor(index / queryArgs.limit) * queryArgs.limit; return { arg: { ...queryArgs, offset: pageOffset, - limit: PAGE_SIZE, } satisfies Parameters[0], options: { selectFromResult: ({ data }) => { @@ -76,7 +80,7 @@ const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: L // Individual image component that gets its data from RTK Query cache const ImageAtPosition = memo( - ({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesArgs }) => { + ({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesQueryArgs }) => { const imageDTO = useImageDTOFromListQuery(index, imageName, queryArgs); if (!imageDTO) { @@ -198,30 +202,27 @@ const scrollIntoView = ( return; }; +const getImageIndex = (imageName: string | undefined, imageNames: string[]) => { + if (!imageName || imageNames.length === 0) { + return 0; + } + const index = imageNames.findIndex((n) => n === imageName); + return index >= 0 ? index : 0; +}; + // Hook for keyboard navigation using physical DOM measurements const useKeyboardNavigation = ( imageNames: string[], virtuosoRef: React.RefObject, - rootRef: React.RefObject, - rangeRef: MutableRefObject + rootRef: React.RefObject ) => { const dispatch = useAppDispatch(); const lastSelectedImage = useAppSelector(selectLastSelectedImage); - // Get current index of selected image - const currentIndex = useMemo(() => { - if (!lastSelectedImage || imageNames.length === 0) { - return 0; - } - const index = imageNames.findIndex((name) => name === lastSelectedImage); - return index >= 0 ? index : 0; - }, [lastSelectedImage, imageNames]); - const handleKeyDown = useCallback( (event: KeyboardEvent) => { const rootEl = rootRef.current; const virtuosoGridHandle = virtuosoRef.current; - const range = rangeRef.current; if (!rootEl || !virtuosoGridHandle) { return; } @@ -248,23 +249,24 @@ const useKeyboardNavigation = ( event.preventDefault(); + const currentIndex = getImageIndex(lastSelectedImage, imageNames); let newIndex = currentIndex; switch (event.key) { case 'ArrowLeft': if (currentIndex > 0) { newIndex = currentIndex - 1; - } else { - // Wrap to last image - newIndex = imageNames.length - 1; + // } else { + // // Wrap to last image + // newIndex = imageNames.length - 1; } break; case 'ArrowRight': if (currentIndex < imageNames.length - 1) { newIndex = currentIndex + 1; - } else { - // Wrap to first image - newIndex = 0; + // } else { + // // Wrap to first image + // newIndex = 0; } break; case 'ArrowUp': @@ -289,11 +291,10 @@ const useKeyboardNavigation = ( const newImageName = imageNames[newIndex]; if (newImageName) { dispatch(selectionChanged([newImageName])); - scrollIntoView(newIndex, rootEl, virtuosoGridHandle, range); } } }, - [rootRef, virtuosoRef, rangeRef, imageNames, currentIndex, dispatch] + [rootRef, virtuosoRef, imageNames, lastSelectedImage, dispatch] ); useEffect(() => { @@ -304,6 +305,30 @@ const useKeyboardNavigation = ( }, [handleKeyDown]); }; +const useKeepSelectedImageInView = ( + imageNames: string[], + virtuosoRef: React.RefObject, + rootRef: React.RefObject, + rangeRef: MutableRefObject +) => { + const imageName = useAppSelector(selectLastSelectedImage); + + useEffect(() => { + const virtuosoGridHandle = virtuosoRef.current; + const rootEl = rootRef.current; + const range = rangeRef.current; + + if (!virtuosoGridHandle || !rootEl || !imageNames || imageNames.length === 0) { + return; + } + const index = imageName ? imageNames.indexOf(imageName) : 0; + if (index === -1) { + return; + } + scrollIntoView(index, rootEl, virtuosoGridHandle, range); + }, [imageName, imageNames, rangeRef, rootRef, virtuosoRef]); +}; + const getImageNamesQueryOptions = { selectFromResult: ({ data, isLoading }) => ({ imageNames: data ?? EMPTY_ARRAY, @@ -316,21 +341,15 @@ export const NewGallery = memo(() => { const queryArgs = useDebouncedImageCollectionQueryArgs(); const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); + const rootRef = useRef(null); // Get the ordered list of image names - this is our primary data source for virtualization const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); - // Reset scroll position when query parameters change - useEffect(() => { - if (virtuosoRef.current && imageNames.length > 0) { - virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' }); - } - }, [queryArgs, imageNames.length]); - - const rootRef = useRef(null); + useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); // Enable keyboard navigation - useKeyboardNavigation(imageNames, virtuosoRef, rootRef, rangeRef); + useKeyboardNavigation(imageNames, virtuosoRef, rootRef); const [scroller, setScroller] = useState(null); const [initialize, osInstance] = useOverlayScrollbars({ diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index 573fae141c..d2187d4f87 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -1,18 +1,14 @@ import type { ChakraProps } 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, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { const { t } = useTranslation(); - const { prevImage, nextImage, isOnFirstImageOfView, isOnLastImageOfView } = useGalleryNavigation(); const { isFetching } = useGalleryImages().queryResult; - const { isNextEnabled, goNext, isPrevEnabled, goPrev } = useGalleryPagination(); const shouldShowLeftArrow = useMemo(() => { if (!isOnFirstImageOfView) { diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts index 0f2a175c1c..d386337221 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts @@ -1,8 +1,6 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; -import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation'; -import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; @@ -13,8 +11,6 @@ import { useListImagesQuery } from 'services/api/endpoints/images'; * Registers gallery hotkeys. This hook is a singleton. */ export const useGalleryHotkeys = () => { - // useAssertSingleton('useGalleryHotkeys'); - const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination(); const selection = useAppSelector((s) => s.gallery.selection); const queryArgs = useAppSelector(selectListImagesQueryArgs); const queryResult = useListImagesQuery(queryArgs); @@ -34,17 +30,6 @@ export const useGalleryHotkeys = () => { return canvasRightPanelTab === 'gallery'; }, [appTab, canvasRightPanelTab]); - const { - handleLeftImage, - handleRightImage, - handleUpImage, - handleDownImage, - isOnFirstRow, - isOnLastRow, - isOnFirstImageOfView, - isOnLastImageOfView, - } = useGalleryNavigation(); - useRegisteredHotkeys({ id: 'galleryNavLeft', category: 'gallery', diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index ae9affafdc..f5355a7ba0 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -1,32 +1,13 @@ import { createSelector } from '@reduxjs/toolkit'; -import type { SkipToken } from '@reduxjs/toolkit/query'; -import { skipToken } from '@reduxjs/toolkit/query'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { ListBoardsArgs, ListImagesArgs, SQLiteDirection } from 'services/api/types'; +import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types'; +import type { SetNonNullable } from 'type-fest'; export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); -export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit); -export const selectListImagesQueryArgs = createMemoizedSelector( - selectGallerySlice, - (gallery): ListImagesArgs | SkipToken => - gallery.limit - ? { - board_id: gallery.selectedBoardId, - categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES, - offset: gallery.offset, - limit: gallery.limit, - is_intermediate: false, - starred_first: gallery.starredFirst, - order_dir: gallery.orderDir, - search_term: gallery.searchTerm, - } - : skipToken -); - export const selectListBoardsQueryArgs = createMemoizedSelector( selectGallerySlice, (gallery): ListBoardsArgs => ({ @@ -37,16 +18,35 @@ export const selectListBoardsQueryArgs = createMemoizedSelector( ); export const selectAutoAddBoardId = createSelector(selectGallerySlice, (gallery) => gallery.autoAddBoardId); +export const selectAutoSwitch = createSelector(selectGallerySlice, (gallery) => gallery.shouldAutoSwitch); export const selectSelectedBoardId = createSelector(selectGallerySlice, (gallery) => gallery.selectedBoardId); +export const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); +export const selectGalleryQueryCategories = createSelector(selectGalleryView, (galleryView) => + galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES +); +export const selectGallerySearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); +export const selectGalleryOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.orderDir); +export const selectGalleryStarredFirst = createSelector(selectGallerySlice, (gallery) => gallery.starredFirst); -export const selectImageCollectionQueryArgs = createMemoizedSelector(selectGallerySlice, (gallery) => ({ - board_id: gallery.selectedBoardId === 'none' ? undefined : gallery.selectedBoardId, - categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES, - search_term: gallery.searchTerm || undefined, - order_dir: gallery.orderDir as SQLiteDirection, - is_intermediate: false, - starred_first: true, -})); +export const selectListImagesQueryArgs = createMemoizedSelector( + [ + selectSelectedBoardId, + selectGalleryQueryCategories, + selectGallerySearchTerm, + selectGalleryOrderDir, + selectGalleryStarredFirst, + ], + (board_id, categories, search_term, order_dir, starred_first) => + ({ + board_id, + categories, + search_term, + order_dir, + starred_first, + is_intermediate: false, // We don't show intermediate images in the gallery + limit: 100, // Page size is _always_ 100 + }) satisfies SetNonNullable +); export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, (gallery) => gallery.autoAssignBoardOnClick diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index c21ae398cb..f10d4fd6be 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -16,8 +16,6 @@ const initialGalleryState: GalleryState = { selectedBoardId: 'none', galleryView: 'images', boardSearchText: '', - limit: 20, - offset: 0, starredFirst: true, orderDir: 'DESC', searchTerm: '', @@ -114,7 +112,6 @@ export const gallerySlice = createSlice({ boardIdSelected: (state, action: PayloadAction<{ boardId: BoardId; selectedImageName?: string }>) => { state.selectedBoardId = action.payload.boardId; state.galleryView = 'images'; - state.offset = 0; }, autoAddBoardIdChanged: (state, action: PayloadAction) => { if (!action.payload) { @@ -125,7 +122,6 @@ export const gallerySlice = createSlice({ }, galleryViewChanged: (state, action: PayloadAction) => { state.galleryView = action.payload; - state.offset = 0; }, boardSearchTextChanged: (state, action: PayloadAction) => { state.boardSearchText = action.payload; @@ -143,13 +139,6 @@ export const gallerySlice = createSlice({ comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => { state.comparisonFit = 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; - }, shouldShowArchivedBoardsChanged: (state, action: PayloadAction) => { state.shouldShowArchivedBoards = action.payload; }, @@ -161,7 +150,6 @@ export const gallerySlice = createSlice({ }, searchTermChanged: (state, action: PayloadAction) => { state.searchTerm = action.payload; - state.offset = 0; }, boardsListOrderByChanged: (state, action: PayloadAction) => { state.boardsListOrderBy = action.payload; @@ -188,8 +176,6 @@ export const { comparedImagesSwapped, comparisonFitChanged, comparisonModeCycled, - offsetChanged, - limitChanged, orderDirChanged, starredFirstChanged, shouldShowArchivedBoardsChanged, @@ -212,5 +198,5 @@ export const galleryPersistConfig: PersistConfig = { name: gallerySlice.name, initialState: initialGalleryState, migrate: migrateGalleryState, - persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'imageToCompare'], + persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'imageToCompare'], }; diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index ad901e6d78..b7a2ee4d11 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -18,8 +18,6 @@ export type GalleryState = { selectedBoardId: BoardId; galleryView: GalleryView; boardSearchText: string; - offset: number; - limit: number; starredFirst: boolean; orderDir: OrderDir; searchTerm: string; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx index 1a1001ab9f..4f228ac240 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx @@ -4,7 +4,6 @@ import type { NodeProps } from '@xyflow/react'; import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { DndImage } from 'features/dnd/DndImage'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper'; import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; @@ -75,7 +74,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => { {props.children} {isHovering && ( - + {/* */} )} diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 503a4acb53..cd7303e069 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -15,6 +15,7 @@ import type { UploadImageArg, } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; +import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -53,7 +54,8 @@ export const imagesApi = api.injectEndpoints({ providesTags: (result, error, queryArgs) => { return [ // Make the tags the same as the cache key - { type: 'ImageList', id: JSON.stringify(queryArgs) }, + { type: 'ImageList', id: stableHash(queryArgs) }, + { type: 'Board', id: queryArgs.board_id ?? 'none' }, 'FetchOnReconnect', ]; }, diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index a5d3ddbec8..02c4d77b6a 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -11,6 +11,7 @@ import { $authToken } from 'app/store/nanostores/authToken'; import { $baseUrl } from 'app/store/nanostores/baseUrl'; import { $projectId } from 'app/store/nanostores/projectId'; import queryString from 'query-string'; +import stableHash from 'stable-hash'; const tagTypes = [ 'AppVersion', @@ -110,6 +111,7 @@ export const api = customCreateApi({ tagTypes, endpoints: () => ({}), invalidationBehavior: 'immediately', + serializeQueryArgs: stableHash, }); function getCircularReplacer() { diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index da6adb2fb6..78322f2408 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -1,17 +1,24 @@ import { logger } from 'app/logging/logger'; +import { addAppListener } from 'app/store/middleware/listenerMiddleware'; import type { AppDispatch, AppGetState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; +import { + selectAutoSwitch, + selectGalleryView, + selectListImagesQueryArgs, + selectSelectedBoardId, +} from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; -import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils'; import type { ApiTagDescription } from 'services/api'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; -import { getCategories, getListImagesUrl } from 'services/api/util'; +import { getCategories } from 'services/api/util'; import { $lastProgressEvent } from 'services/events/stores'; +import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import { objectEntries } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -37,22 +44,25 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi const boardTotalAdditions: Record = {}; const boardTagIdsToInvalidate: Set = new Set(); const imageListTagIdsToInvalidate: Set = new Set(); + const listImagesArg = selectListImagesQueryArgs(getState()); for (const imageDTO of imageDTOs) { if (imageDTO.is_intermediate) { return; } - const boardId = imageDTO.board_id ?? 'none'; + const board_id = imageDTO.board_id ?? 'none'; // update the total images for the board - boardTotalAdditions[boardId] = (boardTotalAdditions[boardId] || 0) + 1; + boardTotalAdditions[board_id] = (boardTotalAdditions[board_id] || 0) + 1; // invalidate the board tag - boardTagIdsToInvalidate.add(boardId); + boardTagIdsToInvalidate.add(board_id); // invalidate the image list tag imageListTagIdsToInvalidate.add( - getListImagesUrl({ - board_id: boardId, + stableHash({ + ...listImagesArg, categories: getCategories(imageDTO), + board_id, + offset: 0, }) ); } @@ -86,48 +96,79 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi })); dispatch(imagesApi.util.invalidateTags([...boardTags, ...imageListTags])); - // Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list. + const autoSwitch = selectAutoSwitch(getState()); + if (!autoSwitch) { + return; + } + + // Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list. const lastImageDTO = imageDTOs.at(-1); if (!lastImageDTO) { return; } - const { image_name, board_id } = lastImageDTO; + const { image_name } = lastImageDTO; + const board_id = lastImageDTO.board_id ?? 'none'; - const { shouldAutoSwitch, selectedBoardId, galleryView, offset } = getState().gallery; + /** + * Auto-switch needs a bit of care to avoid race conditions - we need to invalidate the appropriate image list + * query cache, and only after it has loaded, select the new image. + */ + const queryArgs = { + ...listImagesArg, + categories: getCategories(lastImageDTO), + board_id, + offset: 0, + }; - // If auto-switch is enabled, select the new image - if (shouldAutoSwitch) { - // If the image is from a different board, switch to that board - this will also select the image - if (board_id && board_id !== selectedBoardId) { - dispatch( - boardIdSelected({ - boardId: board_id, - selectedImageName: image_name, - }) - ); - } else if (!board_id && selectedBoardId !== 'none') { - dispatch( - boardIdSelected({ - boardId: 'none', - selectedImageName: image_name, - }) - ); - } else { - // Else just select the image, no need to switch boards - dispatch(imageSelected(lastImageDTO.image_name)); + dispatch( + addAppListener({ + predicate: (action) => { + if (!imagesApi.endpoints.listImages.matchFulfilled(action)) { + return false; + } - if (galleryView !== 'images') { - // We also need to update the gallery view to images. This also updates the offset. - dispatch(galleryViewChanged('images')); - } else if (offset > 0) { - // If we are not at the start of the gallery, reset the offset. - dispatch(offsetChanged({ offset: 0 })); - } - } - } + if (stableHash(action.meta.arg.originalArgs) !== stableHash(queryArgs)) { + return false; + } + + return true; + }, + effect: (_action, { getState, dispatch, unsubscribe }) => { + // This is a one-time listener - we always unsubscribe after the first match + unsubscribe(); + + // Auto-switch may have been disabled while we were waiting for the query to resolve - bail if so + const autoSwitch = selectAutoSwitch(getState()); + if (!autoSwitch) { + return; + } + + const selectedBoardId = selectSelectedBoardId(getState()); + + // If the image is from a different board, switch to that board & select the image - otherwise just select the + // image. This implicitly changes the view to 'images' if it was not already. + if (board_id !== selectedBoardId) { + dispatch( + boardIdSelected({ + boardId: board_id, + selectedImageName: image_name, + }) + ); + } else { + // Ensure we are on the 'images' gallery view - that's where this image will be displayed + const galleryView = selectGalleryView(getState()); + if (galleryView !== 'images') { + dispatch(galleryViewChanged('images')); + } + // Else just select the image, no need to switch boards + dispatch(imageSelected(lastImageDTO.image_name)); + } + }, + }) + ); }; const getResultImageDTOs = async (data: S['InvocationCompleteEvent']): Promise => { @@ -151,69 +192,23 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi return imageDTOs; }; - const handleOriginWorkflows = async (data: S['InvocationCompleteEvent']) => { - const { result, invocation_source_id } = data; - - const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]); - if (nes) { - nes.status = zNodeStatus.enum.COMPLETED; - if (nes.progress !== null) { - nes.progress = 1; - } - nes.outputs.push(result); - upsertExecutionState(nes.nodeId, nes); - } - - await addImagesToGallery(data); - }; - - const handleOriginCanvas = async (data: S['InvocationCompleteEvent']) => { - if (!isCanvasOutputEvent(data)) { - return; - } - - await addImagesToGallery(data); - - // // We expect only a single image in the canvas output - // const imageDTO = (await getResultImageDTOs(data))[0]; - - // if (!imageDTO) { - // return; - // } - - // flushSync(() => { - // dispatch( - // stagingAreaImageStaged({ - // stagingAreaImage: { type: 'staged', sessionId: data.session_id, imageDTO, offsetX: 0, offsetY: 0 }, - // }) - // ); - // }); - - // const progressData = $progressImages.get()[data.session_id]; - // if (progressData) { - // $progressImages.setKey(data.session_id, { ...progressData, isFinished: true, resultImage: imageDTO }); - // } else { - // $progressImages.setKey(data.session_id, { sessionId: data.session_id, isFinished: true, resultImage: imageDTO }); - // } - - // $lastCanvasProgressImage.set(null); - }; - - const handleOriginOther = async (data: S['InvocationCompleteEvent']) => { - await addImagesToGallery(data); - }; - return async (data: S['InvocationCompleteEvent']) => { log.debug({ data } as JsonObject, `Invocation complete (${data.invocation.type}, ${data.invocation_source_id})`); - if (data.origin === 'workflows') { - await handleOriginWorkflows(data); - } else if (data.origin === 'canvas') { - await handleOriginCanvas(data); - } else { - await handleOriginOther(data); + const nodeExecutionState = $nodeExecutionStates.get()[data.invocation_source_id]; + + if (nodeExecutionState) { + const _nodeExecutionState = deepClone(nodeExecutionState); + _nodeExecutionState.status = zNodeStatus.enum.COMPLETED; + if (_nodeExecutionState.progress !== null) { + _nodeExecutionState.progress = 1; + } + _nodeExecutionState.outputs.push(data.result); + upsertExecutionState(_nodeExecutionState.nodeId, _nodeExecutionState); } + await addImagesToGallery(data); + $lastProgressEvent.set(null); }; };