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 47136211a8..0b3accfd4d 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
@@ -5,6 +5,7 @@ import type {
UnknownAction,
} from '@reduxjs/toolkit';
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
+import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import type { AppDispatch, RootState } from 'app/store/store';
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
@@ -117,6 +118,9 @@ addImageToDeleteSelectedListener();
addImagesStarredListener();
addImagesUnstarredListener();
+// Gallery
+addGalleryImageClickedListener();
+
// User Invoked
addEnqueueRequestedCanvasListener();
addEnqueueRequestedNodes();
@@ -135,19 +139,7 @@ addCanvasMergedListener();
addStagingAreaImageSavedListener();
addCommitStagingAreaImageListener();
-/**
- * Socket.IO Events - these handle SIO events directly and pass on internal application actions.
- * We don't handle SIO events in slices via `extraReducers` because some of these events shouldn't
- * actually be handled at all.
- *
- * For example, we don't want to respond to progress events for canceled sessions. To avoid
- * duplicating the logic to determine if an event should be responded to, we handle all of that
- * "is this session canceled?" logic in these listeners.
- *
- * The `socketGeneratorProgress` listener will then only dispatch the `appSocketGeneratorProgress`
- * action if it should be handled by the rest of the application. It is this `appSocketGeneratorProgress`
- * action that is handled by reducers in slices.
- */
+// Socket.IO
addGeneratorProgressListener();
addGraphExecutionStateCompleteListener();
addInvocationCompleteListener();
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
new file mode 100644
index 0000000000..4287f3ec16
--- /dev/null
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts
@@ -0,0 +1,80 @@
+import { createAction } from '@reduxjs/toolkit';
+import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
+import { selectionChanged } from 'features/gallery/store/gallerySlice';
+import { imagesApi } from 'services/api/endpoints/images';
+import type { ImageDTO } from 'services/api/types';
+import { imagesSelectors } from 'services/api/util';
+
+import { startAppListening } from '..';
+
+export const galleryImageClicked = createAction<{
+ imageDTO: ImageDTO;
+ shiftKey: boolean;
+ ctrlKey: boolean;
+ metaKey: boolean;
+}>('gallery/imageClicked');
+
+/**
+ * This listener handles the logic for selecting images in the gallery.
+ *
+ * Previously, this logic was in a `useCallback` with the whole gallery selection as a dependency. Every time
+ * the selection changed, the callback got recreated and all images rerendered. This could easily block for
+ * hundreds of ms, more for lower end devices.
+ *
+ * Moving this logic into a listener means we don't need to recalculate anything dynamically and the gallery
+ * is much more responsive.
+ */
+
+export const addGalleryImageClickedListener = () => {
+ startAppListening({
+ actionCreator: galleryImageClicked,
+ effect: async (action, { dispatch, getState }) => {
+ const { imageDTO, shiftKey, ctrlKey, metaKey } = action.payload;
+ const state = getState();
+ const queryArgs = selectListImagesQueryArgs(state);
+ const { data: listImagesData } =
+ imagesApi.endpoints.listImages.select(queryArgs)(state);
+
+ if (!listImagesData) {
+ // Should never happen if we have clicked a gallery image
+ return;
+ }
+
+ const imageDTOs = imagesSelectors.selectAll(listImagesData);
+ const selection = state.gallery.selection;
+
+ if (shiftKey) {
+ const rangeEndImageName = imageDTO.image_name;
+ const lastSelectedImage = selection[selection.length - 1]?.image_name;
+ const lastClickedIndex = imageDTOs.findIndex(
+ (n) => n.image_name === lastSelectedImage
+ );
+ const currentClickedIndex = imageDTOs.findIndex(
+ (n) => n.image_name === rangeEndImageName
+ );
+ if (lastClickedIndex > -1 && currentClickedIndex > -1) {
+ // We have a valid range!
+ const start = Math.min(lastClickedIndex, currentClickedIndex);
+ const end = Math.max(lastClickedIndex, currentClickedIndex);
+ const imagesToSelect = imageDTOs.slice(start, end + 1);
+ dispatch(selectionChanged(selection.concat(imagesToSelect)));
+ }
+ } else if (ctrlKey || metaKey) {
+ if (
+ selection.some((i) => i.image_name === imageDTO.image_name) &&
+ selection.length > 1
+ ) {
+ dispatch(
+ selectionChanged(
+ selection.filter((n) => n.image_name !== imageDTO.image_name)
+ )
+ );
+ } else {
+ dispatch(selectionChanged(selection.concat(imageDTO)));
+ }
+ } else {
+ dispatch(selectionChanged([imageDTO]));
+ }
+ },
+ });
+};
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 49a6c1265d..ccc6130cff 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
@@ -8,7 +8,7 @@ import {
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
-import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
+import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field';
@@ -49,7 +49,7 @@ export const addRequestedSingleImageDeletionListener = () => {
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
const { image_name } = imageDTO;
- const baseQueryArgs = selectListImagesBaseQueryArgs(state);
+ const baseQueryArgs = selectListImagesQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
@@ -180,9 +180,9 @@ export const addRequestedMultipleImageDeletionListener = () => {
imagesApi.endpoints.deleteImages.initiate({ imageDTOs })
).unwrap();
const state = getState();
- const baseQueryArgs = selectListImagesBaseQueryArgs(state);
+ const queryArgs = selectListImagesQueryArgs(state);
const { data } =
- imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
+ imagesApi.endpoints.listImages.select(queryArgs)(state);
const newSelectedImageDTO = data
? imagesSelectors.selectAll(data)[0]
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 28bf04513b..fdd5b45907 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
@@ -12,7 +12,6 @@ import type {
} from 'features/dnd/types';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
-import { workflowExposedFieldAdded } from 'features/nodes/store/workflowSlice';
import {
initialImageChanged,
selectOptimalDimension,
@@ -35,10 +34,10 @@ export const addImageDroppedListener = () => {
if (activeData.payloadType === 'IMAGE_DTO') {
log.debug({ activeData, overData }, 'Image dropped');
- } else if (activeData.payloadType === 'IMAGE_DTOS') {
+ } else if (activeData.payloadType === 'GALLERY_SELECTION') {
log.debug(
{ activeData, overData },
- `Images (${activeData.payload.imageDTOs.length}) dropped`
+ `Images (${getState().gallery.selection.length}) dropped`
);
} else if (activeData.payloadType === 'NODE_FIELD') {
log.debug(
@@ -49,19 +48,6 @@ export const addImageDroppedListener = () => {
log.debug({ activeData, overData }, `Unknown payload dropped`);
}
- if (
- overData.actionType === 'ADD_FIELD_TO_LINEAR' &&
- activeData.payloadType === 'NODE_FIELD'
- ) {
- const { nodeId, field } = activeData.payload;
- dispatch(
- workflowExposedFieldAdded({
- nodeId,
- fieldName: field.name,
- })
- );
- }
-
/**
* Image dropped on current image
*/
@@ -207,10 +193,9 @@ export const addImageDroppedListener = () => {
*/
if (
overData.actionType === 'ADD_TO_BOARD' &&
- activeData.payloadType === 'IMAGE_DTOS' &&
- activeData.payload.imageDTOs
+ activeData.payloadType === 'GALLERY_SELECTION'
) {
- const { imageDTOs } = activeData.payload;
+ const imageDTOs = getState().gallery.selection;
const { boardId } = overData.context;
dispatch(
imagesApi.endpoints.addImagesToBoard.initiate({
@@ -226,10 +211,9 @@ export const addImageDroppedListener = () => {
*/
if (
overData.actionType === 'REMOVE_FROM_BOARD' &&
- activeData.payloadType === 'IMAGE_DTOS' &&
- activeData.payload.imageDTOs
+ activeData.payloadType === 'GALLERY_SELECTION'
) {
- const { imageDTOs } = activeData.payload;
+ const imageDTOs = getState().gallery.selection;
dispatch(
imagesApi.endpoints.removeImagesFromBoard.initiate({
imageDTOs,
diff --git a/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx b/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx
index 4647fd8c3f..956ebfb4b6 100644
--- a/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx
+++ b/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx
@@ -1,5 +1,6 @@
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, Heading, Image } from '@chakra-ui/react';
+import { useAppSelector } from 'app/store/storeHooks';
import { InvText } from 'common/components/InvText/wrapper';
import type { TypesafeDraggableData } from 'features/dnd/types';
import { memo } from 'react';
@@ -34,6 +35,7 @@ const multiImageStyles: ChakraProps['sx'] = {
const DragPreview = (props: OverlayDragImageProps) => {
const { t } = useTranslation();
+ const selectionCount = useAppSelector((s) => s.gallery.selection.length);
if (!props.dragData) {
return null;
}
@@ -79,10 +81,10 @@ const DragPreview = (props: OverlayDragImageProps) => {
);
}
- if (props.dragData.payloadType === 'IMAGE_DTOS') {
+ if (props.dragData.payloadType === 'GALLERY_SELECTION') {
return (
- {props.dragData.payload.imageDTOs.length}
+ {selectionCount}
{t('parameters.images')}
);
diff --git a/invokeai/frontend/web/src/features/dnd/types/index.ts b/invokeai/frontend/web/src/features/dnd/types/index.ts
index 1a6713e1c1..ac62eb65cd 100644
--- a/invokeai/frontend/web/src/features/dnd/types/index.ts
+++ b/invokeai/frontend/web/src/features/dnd/types/index.ts
@@ -10,6 +10,7 @@ import type {
useDroppable as useOriginalDroppable,
UseDroppableArguments,
} from '@dnd-kit/core';
+import type { BoardId } from 'features/gallery/store/types';
import type {
FieldInputInstance,
FieldInputTemplate,
@@ -51,15 +52,6 @@ export type NodesImageDropData = BaseDropData & {
};
};
-export type NodesMultiImageDropData = BaseDropData & {
- actionType: 'SET_MULTI_NODES_IMAGE';
- context: { nodeId: string; fieldName: string };
-};
-
-export type AddToBatchDropData = BaseDropData & {
- actionType: 'ADD_TO_BATCH';
-};
-
export type AddToBoardDropData = BaseDropData & {
actionType: 'ADD_TO_BOARD';
context: { boardId: string };
@@ -69,21 +61,14 @@ export type RemoveFromBoardDropData = BaseDropData & {
actionType: 'REMOVE_FROM_BOARD';
};
-export type AddFieldToLinearViewDropData = BaseDropData & {
- actionType: 'ADD_FIELD_TO_LINEAR';
-};
-
export type TypesafeDroppableData =
| CurrentImageDropData
| InitialImageDropData
| ControlAdapterDropData
| CanvasInitialImageDropData
| NodesImageDropData
- | AddToBatchDropData
- | NodesMultiImageDropData
| AddToBoardDropData
- | RemoveFromBoardDropData
- | AddFieldToLinearViewDropData;
+ | RemoveFromBoardDropData;
type BaseDragData = {
id: string;
@@ -103,15 +88,15 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO };
};
-export type ImageDTOsDraggableData = BaseDragData & {
- payloadType: 'IMAGE_DTOS';
- payload: { imageDTOs: ImageDTO[] };
+export type GallerySelectionDraggableData = BaseDragData & {
+ payloadType: 'GALLERY_SELECTION';
+ payload: { boardId: BoardId };
};
export type TypesafeDraggableData =
| NodeFieldDraggableData
| ImageDraggableData
- | ImageDTOsDraggableData;
+ | GallerySelectionDraggableData;
export interface UseDroppableTypesafeArguments
extends Omit {
diff --git a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
index 8720ff71cd..c691a1deba 100644
--- a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
+++ b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
@@ -16,8 +16,6 @@ export const isValidDrop = (
}
switch (actionType) {
- case 'ADD_FIELD_TO_LINEAR':
- return payloadType === 'NODE_FIELD';
case 'SET_CURRENT_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_INITIAL_IMAGE':
@@ -28,15 +26,13 @@ export const isValidDrop = (
return payloadType === 'IMAGE_DTO';
case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO';
- case 'SET_MULTI_NODES_IMAGE':
- return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
- case 'ADD_TO_BATCH':
- return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
case 'ADD_TO_BOARD': {
// If the board is the same, don't allow the drop
// Check the payload types
- const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
+ const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
+ payloadType
+ );
if (!isPayloadValid) {
return false;
}
@@ -50,12 +46,10 @@ export const isValidDrop = (
return currentBoard !== destinationBoard;
}
- if (payloadType === 'IMAGE_DTOS') {
+ if (payloadType === 'GALLERY_SELECTION') {
// Assume all images are on the same board - this is true for the moment
- const { imageDTOs } = active.data.current.payload;
- const currentBoard = imageDTOs[0]?.board_id ?? 'none';
+ const currentBoard = active.data.current.payload.boardId;
const destinationBoard = overData.context.boardId;
-
return currentBoard !== destinationBoard;
}
@@ -65,7 +59,9 @@ export const isValidDrop = (
// If the board is the same, don't allow the drop
// Check the payload types
- const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
+ const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
+ payloadType
+ );
if (!isPayloadValid) {
return false;
}
@@ -78,11 +74,8 @@ export const isValidDrop = (
return currentBoard !== 'none';
}
- if (payloadType === 'IMAGE_DTOS') {
- // Assume all images are on the same board - this is true for the moment
- const { imageDTOs } = active.data.current.payload;
- const currentBoard = imageDTOs[0]?.board_id ?? 'none';
-
+ if (payloadType === 'GALLERY_SELECTION') {
+ const currentBoard = active.data.current.payload.boardId;
return currentBoard !== 'none';
}
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
index 3e0d206607..dceedaff27 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
@@ -2,15 +2,15 @@ import type { SystemStyleObject } from '@chakra-ui/react';
import { Box, Flex } from '@chakra-ui/react';
import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
-import { useAppDispatch } from 'app/store/storeHooks';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import { $shift } from 'common/hooks/useGlobalModifiers';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import type {
+ GallerySelectionDraggableData,
ImageDraggableData,
- ImageDTOsDraggableData,
TypesafeDraggableData,
} from 'features/dnd/types';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
@@ -42,8 +42,8 @@ const GalleryImage = (props: HoverableImageProps) => {
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const shift = useStore($shift);
const { t } = useTranslation();
-
- const { handleClick, isSelected, selection, selectionCount } =
+ const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
+ const { handleClick, isSelected, areMultiplesSelected } =
useMultiselect(imageDTO);
const customStarUi = useStore($customStarUI);
@@ -51,7 +51,7 @@ const GalleryImage = (props: HoverableImageProps) => {
const imageContainerRef = useScrollIntoView(
isSelected,
props.index,
- selectionCount
+ areMultiplesSelected
);
const handleDelete = useCallback(
@@ -66,11 +66,11 @@ const GalleryImage = (props: HoverableImageProps) => {
);
const draggableData = useMemo(() => {
- if (selectionCount > 1) {
- const data: ImageDTOsDraggableData = {
+ if (areMultiplesSelected) {
+ const data: GallerySelectionDraggableData = {
id: 'gallery-image',
- payloadType: 'IMAGE_DTOS',
- payload: { imageDTOs: selection },
+ payloadType: 'GALLERY_SELECTION',
+ payload: { boardId: selectedBoardId },
};
return data;
}
@@ -83,7 +83,7 @@ const GalleryImage = (props: HoverableImageProps) => {
};
return data;
}
- }, [imageDTO, selection, selectionCount]);
+ }, [imageDTO, selectedBoardId, areMultiplesSelected]);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts
index b6a22befc0..773fba9013 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts
@@ -1,46 +1,28 @@
-import { useStore } from '@nanostores/react';
-import { useAppSelector } from 'app/store/storeHooks';
-import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
-import { IMAGE_LIMIT } from 'features/gallery/store/types';
-import { atom } from 'nanostores';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
+import { moreImagesLoaded } from 'features/gallery/store/gallerySlice';
import { useCallback, useMemo } from 'react';
import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
} from 'services/api/endpoints/boards';
import { useListImagesQuery } from 'services/api/endpoints/images';
-import type { ListImagesArgs } from 'services/api/types';
-
-// The gallery is a singleton but multiple components need access to its query data.
-// If we don't define the query args outside of the hook, then each component will
-// have its own query args and trigger multiple requests. We use an atom to store
-// the query args outside of the hook so that all consumers use the same query args.
-const $queryArgs = atom(null);
/**
* Provides access to the gallery images and a way to imperatively fetch more.
- *
- * This hook is a singleton.
*/
export const useGalleryImages = () => {
+ const dispatch = useAppDispatch();
const galleryView = useAppSelector((s) => s.gallery.galleryView);
- const baseQueryArgs = useAppSelector(selectListImagesBaseQueryArgs);
- const queryArgs = useStore($queryArgs);
- const queryResult = useListImagesQuery(queryArgs ?? baseQueryArgs);
- const boardId = useMemo(
- () => baseQueryArgs.board_id ?? 'none',
- [baseQueryArgs.board_id]
- );
- const { data: assetsTotal } = useGetBoardAssetsTotalQuery(boardId);
- const { data: imagesTotal } = useGetBoardImagesTotalQuery(boardId);
+ const queryArgs = useAppSelector(selectListImagesQueryArgs);
+ const queryResult = useListImagesQuery(queryArgs);
+ const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
+ const { data: assetsTotal } = useGetBoardAssetsTotalQuery(selectedBoardId);
+ const { data: imagesTotal } = useGetBoardImagesTotalQuery(selectedBoardId);
const currentViewTotal = useMemo(
() => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total),
[assetsTotal?.total, galleryView, imagesTotal?.total]
);
- const loadedImagesCount = useMemo(
- () => queryResult.data?.ids.length ?? 0,
- [queryResult.data?.ids.length]
- );
const areMoreImagesAvailable = useMemo(() => {
if (!currentViewTotal || !queryResult.data) {
return false;
@@ -48,16 +30,8 @@ export const useGalleryImages = () => {
return queryResult.data.ids.length < currentViewTotal;
}, [queryResult.data, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
- // To load more images, we update the query args with an offset and limit.
- const _queryArgs: ListImagesArgs = loadedImagesCount
- ? {
- ...baseQueryArgs,
- offset: loadedImagesCount,
- limit: IMAGE_LIMIT,
- }
- : baseQueryArgs;
- $queryArgs.set(_queryArgs);
- }, [baseQueryArgs, loadedImagesCount]);
+ dispatch(moreImagesLoaded());
+ }, [dispatch]);
return {
areMoreImagesAvailable,
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts b/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts
index d8ee86fad4..b32fec6e02 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts
@@ -1,6 +1,6 @@
-import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
+import { createSelector } from '@reduxjs/toolkit';
+import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import {
selectGallerySlice,
selectionChanged,
@@ -9,24 +9,20 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import type { MouseEvent } from 'react';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
-import { imagesSelectors } from 'services/api/util';
-
-const selectGallerySelection = createMemoizedSelector(
- selectGallerySlice,
- (gallery) => gallery.selection
-);
-
-const EMPTY_ARRAY: ImageDTO[] = [];
export const useMultiselect = (imageDTO?: ImageDTO) => {
const dispatch = useAppDispatch();
- const selection = useAppSelector(selectGallerySelection);
- const { data } = useGalleryImages().queryResult;
- const imageDTOs = useMemo(
- () => (data ? imagesSelectors.selectAll(data) : EMPTY_ARRAY),
- [data]
+ const areMultiplesSelected = useAppSelector(
+ (s) => s.gallery.selection.length > 1
);
-
+ const selectIsSelected = useMemo(
+ () =>
+ createSelector(selectGallerySlice, (gallery) =>
+ gallery.selection.some((i) => i.image_name === imageDTO?.image_name)
+ ),
+ [imageDTO?.image_name]
+ );
+ const isSelected = useAppSelector(selectIsSelected);
const isMultiSelectEnabled = useFeatureStatus('multiselect').isFeatureEnabled;
const handleClick = useCallback(
@@ -39,55 +35,20 @@ export const useMultiselect = (imageDTO?: ImageDTO) => {
return;
}
- if (e.shiftKey) {
- const rangeEndImageName = imageDTO.image_name;
- const lastSelectedImage = selection[selection.length - 1]?.image_name;
- const lastClickedIndex = imageDTOs.findIndex(
- (n) => n.image_name === lastSelectedImage
- );
- const currentClickedIndex = imageDTOs.findIndex(
- (n) => n.image_name === rangeEndImageName
- );
- if (lastClickedIndex > -1 && currentClickedIndex > -1) {
- // We have a valid range!
- const start = Math.min(lastClickedIndex, currentClickedIndex);
- const end = Math.max(lastClickedIndex, currentClickedIndex);
- const imagesToSelect = imageDTOs.slice(start, end + 1);
- dispatch(selectionChanged(selection.concat(imagesToSelect)));
- }
- } else if (e.ctrlKey || e.metaKey) {
- if (
- selection.some((i) => i.image_name === imageDTO.image_name) &&
- selection.length > 1
- ) {
- dispatch(
- selectionChanged(
- selection.filter((n) => n.image_name !== imageDTO.image_name)
- )
- );
- } else {
- dispatch(selectionChanged(selection.concat(imageDTO)));
- }
- } else {
- dispatch(selectionChanged([imageDTO]));
- }
+ dispatch(
+ galleryImageClicked({
+ imageDTO,
+ shiftKey: e.shiftKey,
+ ctrlKey: e.ctrlKey,
+ metaKey: e.metaKey,
+ })
+ );
},
- [dispatch, imageDTO, imageDTOs, selection, isMultiSelectEnabled]
+ [dispatch, imageDTO, isMultiSelectEnabled]
);
- const isSelected = useMemo(
- () =>
- imageDTO
- ? selection.some((i) => i.image_name === imageDTO.image_name)
- : false,
- [imageDTO, selection]
- );
-
- const selectionCount = useMemo(() => selection.length, [selection.length]);
-
return {
- selection,
- selectionCount,
+ areMultiplesSelected,
isSelected,
handleClick,
};
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts b/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts
index 6bcafba073..01579cfe79 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts
@@ -19,12 +19,12 @@ import { useEffect, useRef } from 'react';
export const useScrollIntoView = (
isSelected: boolean,
index: number,
- selectionCount: number
+ areMultiplesSelected: boolean
) => {
const imageContainerRef = useRef(null);
useEffect(() => {
- if (!isSelected || selectionCount !== 1) {
+ if (!isSelected || areMultiplesSelected) {
return;
}
@@ -46,7 +46,7 @@ export const useScrollIntoView = (
align: getScrollToIndexAlign(index, range),
});
}
- }, [isSelected, index, selectionCount]);
+ }, [isSelected, index, areMultiplesSelected]);
return imageContainerRef;
};
diff --git a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts
index 4610a73e9a..e39ace1577 100644
--- a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts
@@ -7,4 +7,6 @@ export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
'selection',
'selectedBoardId',
'galleryView',
+ 'offset',
+ 'limit',
];
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
index 03c84e8096..ceea56b2da 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
@@ -1,33 +1,24 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
-import type { ListImagesArgs } from 'services/api/types';
-
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
- INITIAL_IMAGE_LIMIT,
-} from './types';
+} from 'features/gallery/store/types';
+import type { ListImagesArgs } from 'services/api/types';
export const selectLastSelectedImage = createMemoizedSelector(
selectGallerySlice,
(gallery) => gallery.selection[gallery.selection.length - 1]
);
-export const selectListImagesBaseQueryArgs = createMemoizedSelector(
+export const selectListImagesQueryArgs = createMemoizedSelector(
selectGallerySlice,
- (gallery) => {
- const { selectedBoardId, galleryView } = gallery;
- const categories =
- galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
-
- const listImagesBaseQueryArgs: ListImagesArgs = {
- board_id: selectedBoardId,
- categories,
- offset: 0,
- limit: INITIAL_IMAGE_LIMIT,
- is_intermediate: false,
- };
-
- return listImagesBaseQueryArgs;
- }
+ (gallery): ListImagesArgs => ({
+ board_id: gallery.selectedBoardId,
+ categories:
+ gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
+ offset: gallery.offset,
+ limit: gallery.limit,
+ is_intermediate: false,
+ })
);
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
index f54b083170..d1158173ed 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -7,6 +7,7 @@ import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { BoardId, GalleryState, GalleryView } from './types';
+import { IMAGE_LIMIT, INITIAL_IMAGE_LIMIT } from './types';
export const initialGalleryState: GalleryState = {
selection: [],
@@ -17,6 +18,8 @@ export const initialGalleryState: GalleryState = {
selectedBoardId: 'none',
galleryView: 'images',
boardSearchText: '',
+ limit: INITIAL_IMAGE_LIMIT,
+ offset: 0,
};
export const gallerySlice = createSlice({
@@ -44,6 +47,8 @@ export const gallerySlice = createSlice({
) => {
state.selectedBoardId = action.payload.boardId;
state.galleryView = 'images';
+ state.offset = 0;
+ state.limit = INITIAL_IMAGE_LIMIT;
},
autoAddBoardIdChanged: (state, action: PayloadAction) => {
if (!action.payload) {
@@ -54,10 +59,21 @@ export const gallerySlice = createSlice({
},
galleryViewChanged: (state, action: PayloadAction) => {
state.galleryView = action.payload;
+ state.offset = 0;
+ state.limit = INITIAL_IMAGE_LIMIT;
},
boardSearchTextChanged: (state, action: PayloadAction) => {
state.boardSearchText = action.payload;
},
+ moreImagesLoaded: (state) => {
+ if (state.offset === 0 && state.limit === INITIAL_IMAGE_LIMIT) {
+ state.offset = INITIAL_IMAGE_LIMIT;
+ state.limit = IMAGE_LIMIT;
+ } else {
+ state.offset += IMAGE_LIMIT;
+ state.limit += IMAGE_LIMIT;
+ }
+ },
},
extraReducers: (builder) => {
builder.addMatcher(isAnyBoardDeleted, (state, action) => {
@@ -96,6 +112,7 @@ export const {
galleryViewChanged,
selectionChanged,
boardSearchTextChanged,
+ moreImagesLoaded,
} = gallerySlice.actions;
export default gallerySlice.reducer;
diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts
index 959d87ae36..9f7ceedc6a 100644
--- a/invokeai/frontend/web/src/features/gallery/store/types.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/types.ts
@@ -22,4 +22,6 @@ export type GalleryState = {
selectedBoardId: BoardId;
galleryView: GalleryView;
boardSearchText: string;
+ offset: number;
+ limit: number;
};