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