diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index df4a3778ee..633cfd4249 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -385,6 +385,8 @@
"viewerImage": "Viewer Image",
"compareImage": "Compare Image",
"openInViewer": "Open in Viewer",
+ "selectAllOnPage": "Select All On Page",
+ "selectAllOnBoard": "Select All On Board",
"selectForCompare": "Select for Compare",
"selectAnImageToCompare": "Select an Image to Compare",
"slider": "Slider",
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts
index 3f831de5c6..5db5f687a1 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts
@@ -2,8 +2,7 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { imagesApi } from 'services/api/endpoints/images';
-import type { ImageCache } from 'services/api/types';
-import { getListImagesUrl, imagesSelectors } from 'services/api/util';
+import { getListImagesUrl } from 'services/api/util';
export const addFirstListImagesListener = (startAppListening: AppStartListening) => {
startAppListening({
@@ -18,13 +17,10 @@ export const addFirstListImagesListener = (startAppListening: AppStartListening)
cancelActiveListeners();
unsubscribe();
- // TODO: figure out how to type the predicate
- const data = action.payload as ImageCache;
+ const data = action.payload;
- if (data.ids.length > 0) {
- // Select the first image
- const firstImage = imagesSelectors.selectAll(data)[0];
- dispatch(imageSelected(firstImage ?? null));
+ if (data.items.length > 0) {
+ dispatch(imageSelected(data.items[0] ?? null));
}
},
});
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts
index 2c1aa6ec8b..9388bab722 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts
@@ -3,7 +3,6 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { imagesApi } from 'services/api/endpoints/images';
-import { imagesSelectors } from 'services/api/util';
export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => {
startAppListening({
@@ -35,11 +34,12 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(queryArgs)(getState());
if (boardImagesData && boardIdSelected.match(action) && action.payload.selectedImageName) {
- const selectedImage = imagesSelectors.selectById(boardImagesData, action.payload.selectedImageName);
+ const selectedImage = boardImagesData.items.find(
+ (item) => item.image_name === action.payload.selectedImageName
+ );
dispatch(imageSelected(selectedImage || null));
} else if (boardImagesData) {
- const firstImage = imagesSelectors.selectAll(boardImagesData)[0];
- dispatch(imageSelected(firstImage || null));
+ dispatch(imageSelected(boardImagesData.items[0] || null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
index 8c24badc76..a62cf62861 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts
@@ -22,11 +22,10 @@ import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
-import { clamp, forEach } from 'lodash-es';
+import { forEach } from 'lodash-es';
import { api } from 'services/api';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
-import { imagesSelectors } from 'services/api/util';
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
state.nodes.present.nodes.forEach((node) => {
@@ -123,23 +122,11 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
const lastSelectedImage = state.gallery.selection[state.gallery.selection.length - 1]?.image_name;
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
- const { image_name } = imageDTO;
-
const baseQueryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
- const cachedImageDTOs = data ? imagesSelectors.selectAll(data) : [];
-
- const deletedImageIndex = cachedImageDTOs.findIndex((i) => i.image_name === image_name);
-
- const filteredImageDTOs = cachedImageDTOs.filter((i) => i.image_name !== image_name);
-
- const newSelectedImageIndex = clamp(deletedImageIndex, 0, filteredImageDTOs.length - 1);
-
- const newSelectedImageDTO = filteredImageDTOs[newSelectedImageIndex];
-
- if (newSelectedImageDTO) {
- dispatch(imageSelected(newSelectedImageDTO));
+ if (data && data.items[0]) {
+ dispatch(imageSelected(data.items[0]));
} else {
dispatch(imageSelected(null));
}
@@ -188,10 +175,8 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
const queryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
- const newSelectedImageDTO = data ? imagesSelectors.selectAll(data)[0] : undefined;
-
- if (newSelectedImageDTO) {
- dispatch(imageSelected(newSelectedImageDTO));
+ if (data && data.items[0]) {
+ dispatch(imageSelected(data.items[0]));
} else {
dispatch(imageSelected(null));
}
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
index 7cb0703af8..0cd77dc2e7 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
@@ -15,7 +15,12 @@ import {
} from 'features/controlLayers/store/controlLayersSlice';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import { isValidDrop } from 'features/dnd/util/isValidDrop';
-import { imageSelected, imageToCompareChanged, isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
+import {
+ imageSelected,
+ imageToCompareChanged,
+ isImageViewerOpenChanged,
+ selectionChanged,
+} from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
import { imagesApi } from 'services/api/endpoints/images';
@@ -216,6 +221,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
board_id: boardId,
})
);
+ dispatch(selectionChanged([]));
return;
}
@@ -233,6 +239,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
imageDTO,
})
);
+ dispatch(selectionChanged([]));
return;
}
@@ -248,6 +255,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
board_id: boardId,
})
);
+ dispatch(selectionChanged([]));
return;
}
@@ -261,6 +269,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
imageDTOs,
})
);
+ dispatch(selectionChanged([]));
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 f01bbeafae..40cacc78cb 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
@@ -8,14 +8,14 @@ import {
galleryViewChanged,
imageSelected,
isImageViewerOpenChanged,
+ offsetChanged,
} from 'features/gallery/store/gallerySlice';
-import { IMAGE_CATEGORIES, IMAGE_LIMIT } from 'features/gallery/store/types';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { CANVAS_OUTPUT } from 'features/nodes/util/graph/constants';
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
-import { imageListDefaultSort } from 'services/api/util';
+import { getCategories, getListImagesUrl } from 'services/api/util';
import { socketInvocationComplete } from 'services/events/actions';
// These nodes output an image, but do not actually *save* an image, so we don't want to handle the gallery logic on them
@@ -52,32 +52,6 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
}
if (!imageDTO.is_intermediate) {
- /**
- * Cache updates for when an image result is received
- * - add it to the no_board/images
- */
-
- dispatch(
- imagesApi.util.updateQueryData(
- 'listImages',
- {
- board_id: imageDTO.board_id ?? 'none',
- categories: IMAGE_CATEGORIES,
- offset: gallery.offset,
- limit: gallery.limit,
- is_intermediate: false,
- },
- (draft) => {
- const updatedListLength = draft.items.unshift(imageDTO);
- draft.items.sort(imageListDefaultSort());
- if (updatedListLength > IMAGE_LIMIT) {
- draft.items.pop();
- }
- draft.total += 1;
- }
- )
- );
-
// update the total images for the board
dispatch(
boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => {
@@ -86,7 +60,18 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
})
);
- dispatch(imagesApi.util.invalidateTags([{ type: 'Board', id: imageDTO.board_id ?? 'none' }]));
+ dispatch(
+ imagesApi.util.invalidateTags([
+ { type: 'Board', id: imageDTO.board_id ?? 'none' },
+ {
+ type: 'ImageList',
+ id: getListImagesUrl({
+ board_id: imageDTO.board_id ?? 'none',
+ categories: getCategories(imageDTO),
+ }),
+ },
+ ])
+ );
const { shouldAutoSwitch } = gallery;
@@ -106,6 +91,8 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
);
}
+ dispatch(offsetChanged(0));
+
if (!imageDTO.board_id && gallery.selectedBoardId !== 'none') {
dispatch(
boardIdSelected({
diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryBulkSelect.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryBulkSelect.tsx
new file mode 100644
index 0000000000..e1978203d9
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBulkSelect.tsx
@@ -0,0 +1,47 @@
+import { Flex, IconButton, Tag, TagCloseButton, TagLabel, Tooltip } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
+import { selectionChanged } from 'features/gallery/store/gallerySlice';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { BiSelectMultiple } from 'react-icons/bi';
+
+export const GalleryBulkSelect = () => {
+ const dispatch = useAppDispatch();
+ const { selection } = useAppSelector((s) => s.gallery);
+ const { t } = useTranslation();
+ const { imageDTOs } = useGalleryImages();
+
+ const onClickClearSelection = useCallback(() => {
+ dispatch(selectionChanged([]));
+ }, [dispatch]);
+
+ const onClickSelectAllPage = useCallback(() => {
+ dispatch(selectionChanged(selection.concat(imageDTOs)));
+ }, [dispatch, imageDTOs, selection]);
+
+ return (
+
+
+
+ {selection.length} {t('common.selected')}
+
+ {selection.length > 0 && (
+
+
+
+ )}
+
+
+
+ }
+ aria-label="Bulk select"
+ onClick={onClickSelectAllPage}
+ />
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx
index a2682a2ee1..00f3b4463e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx
@@ -10,6 +10,7 @@ import { RiServerLine } from 'react-icons/ri';
import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
+import { GalleryBulkSelect } from './GalleryBulkSelect';
import GallerySettingsPopover from './GallerySettingsPopover';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
import { GalleryPagination } from './ImageGrid/GalleryPagination';
@@ -71,6 +72,8 @@ const ImageGalleryContent = () => {
+
+
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts
index 0d60dacbad..550027e17c 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts
@@ -1,7 +1,7 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { offsetChanged } from 'features/gallery/store/gallerySlice';
-import { useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
@@ -50,6 +50,13 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
dispatch(offsetChanged((pages - 1) * (limit || 0)));
}, [dispatch, pages, limit]);
+ // handle when total/pages decrease and user is on high page number (ie bulk removing or deleting)
+ useEffect(() => {
+ if (currentPage + 1 > pages) {
+ goToLast();
+ }
+ }, [currentPage, pages, goToLast]);
+
// calculate the page buttons to display - current page with 3 around it
const pageButtons = useMemo(() => {
const buttons = [];
@@ -77,6 +84,10 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
return `${startItem}-${endItem} of ${total}`;
}, [total, currentPage, limit]);
+ const numberOnPage = useMemo(() => {
+ return Math.min((currentPage + 1) * (limit || 0), total);
+ }, [currentPage, limit, total]);
+
const api = useMemo(
() => ({
count,
@@ -94,6 +105,7 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
isFirstEnabled,
isLastEnabled,
rangeDisplay,
+ numberOnPage,
}),
[
count,
@@ -111,6 +123,7 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
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 810a63ae49..a2f0be6b4b 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -107,7 +107,7 @@ export const gallerySlice = createSlice({
offsetChanged: (state, action: PayloadAction) => {
state.offset = action.payload;
},
- limitChanged: (state, action: PayloadAction) => {
+ limitChanged: (state, action: PayloadAction) => {
state.limit = action.payload;
},
},
diff --git a/invokeai/frontend/web/src/services/api/util.ts b/invokeai/frontend/web/src/services/api/util.ts
index 78cfd58974..0db6da3ce0 100644
--- a/invokeai/frontend/web/src/services/api/util.ts
+++ b/invokeai/frontend/web/src/services/api/util.ts
@@ -1,56 +1,9 @@
-import { createEntityAdapter } from '@reduxjs/toolkit';
-import { getSelectorsOptions } from 'app/store/createMemoizedSelector';
import { dateComparator } from 'common/util/dateComparator';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import queryString from 'query-string';
import { buildV1Url } from 'services/api';
-import type { ImageCache, ImageDTO, ListImagesArgs } from './types';
-
-export const getIsImageInDateRange = (data: ImageCache | undefined, imageDTO: ImageDTO) => {
- if (!data) {
- return false;
- }
-
- const totalCachedImageDtos = imagesSelectors.selectAll(data);
-
- if (totalCachedImageDtos.length <= 1) {
- return true;
- }
-
- const cachedStarredImages = [];
- const cachedUnstarredImages = [];
-
- for (let index = 0; index < totalCachedImageDtos.length; index++) {
- const image = totalCachedImageDtos[index];
- if (image?.starred) {
- cachedStarredImages.push(image);
- }
- if (!image?.starred) {
- cachedUnstarredImages.push(image);
- }
- }
-
- if (imageDTO.starred) {
- const lastStarredImage = cachedStarredImages[cachedStarredImages.length - 1];
- // if starring or already starred, want to look in list of starred images
- if (!lastStarredImage) {
- return true;
- } // no starred images showing, so always show this one
- const createdDate = new Date(imageDTO.created_at);
- const oldestDate = new Date(lastStarredImage.created_at);
- return createdDate >= oldestDate;
- } else {
- const lastUnstarredImage = cachedUnstarredImages[cachedUnstarredImages.length - 1];
- // if unstarring or already unstarred, want to look in list of unstarred images
- if (!lastUnstarredImage) {
- return false;
- } // no unstarred images showing, so don't show this one
- const createdDate = new Date(imageDTO.created_at);
- const oldestDate = new Date(lastUnstarredImage.created_at);
- return createdDate >= oldestDate;
- }
-};
+import type { ImageDTO, ListImagesArgs } from './types';
export const getCategories = (imageDTO: ImageDTO) => {
if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) {
@@ -59,22 +12,6 @@ export const getCategories = (imageDTO: ImageDTO) => {
return ASSETS_CATEGORIES;
};
-// The adapter is not actually the data store - it just provides helper functions to interact
-// with some other store of data. We will use the RTK Query cache as that store.
-export const imagesAdapter = createEntityAdapter({
- selectId: (image) => image.image_name,
- sortComparer: (a, b) => {
- // Compare starred images first
- if (a.starred && !b.starred) {
- return -1;
- }
- if (!a.starred && b.starred) {
- return 1;
- }
- return dateComparator(b.created_at, a.created_at);
- },
-});
-
export const imageListDefaultSort = () => {
return (a: ImageDTO, b: ImageDTO) => {
if (a.starred && !b.starred) {
@@ -87,9 +24,6 @@ export const imageListDefaultSort = () => {
};
};
-// Create selectors for the adapter.
-export const imagesSelectors = imagesAdapter.getSelectors(undefined, getSelectorsOptions);
-
// Helper to create the url for the listImages endpoint. Also we use it to create the cache key.
export const getListImagesUrl = (queryArgs: ListImagesArgs) =>
buildV1Url(`images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`);