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);
};
};