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