From 23c8a893e1294d94fc7d1d0b5603c11b700a7b44 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Jan 2024 00:07:09 +1100 Subject: [PATCH] fix(ui): fix gallery display bug, major lag - Fixed a bug where after you load more, changing boards doesn't work. The offset and limit for the list image query had some wonky logic, now resolved. - Addressed major lag in gallery when selecting an image. Both issues were related to the useMultiselect and useGalleryImages hooks, which caused every image in the gallery to re-render on whenever the selection changed. There's no way to memoize away this - we need to know when the selection changes. This is a longstanding issue. The selection is only used in a callback, though - the onClick handler for an image to select it (or add it to the existing selection). We don't really need the reactivity for a callback, so we don't need to listen for changes to the selection. The logic to handle multiple selection is moved to a new `galleryImageClicked` listener, which does all the selection right when it is needed. The result is that gallery images no long need to do heavy re-renders on any selection change. Besides the multiselect click handler, there was also inefficient use of DND payloads. Previously, the `IMAGE_DTOS` type had a payload of image DTO objects. This was only used to drag gallery selection into a board. There is no need to hold onto image DTOs when we have the selection state already in redux. We were recalculating this payload for every image, on every tick. This payload is now just the board id (the only piece of information we need for this particular DND event). - I also removed some unused DND types while making this change. --- .../middleware/listenerMiddleware/index.ts | 18 ++-- .../listeners/galleryImageClicked.ts | 80 ++++++++++++++++++ .../listeners/imageDeleted.ts | 8 +- .../listeners/imageDropped.ts | 28 ++----- .../features/dnd/components/DragPreview.tsx | 6 +- .../web/src/features/dnd/types/index.ts | 27 ++---- .../web/src/features/dnd/util/isValidDrop.ts | 27 +++--- .../components/ImageGrid/GalleryImage.tsx | 20 ++--- .../gallery/hooks/useGalleryImages.ts | 48 +++-------- .../features/gallery/hooks/useMultiselect.ts | 83 +++++-------------- .../gallery/hooks/useScrollIntoView.ts | 6 +- .../gallery/store/galleryPersistDenylist.ts | 2 + .../gallery/store/gallerySelectors.ts | 31 +++---- .../features/gallery/store/gallerySlice.ts | 17 ++++ .../web/src/features/gallery/store/types.ts | 2 + 15 files changed, 193 insertions(+), 210 deletions(-) create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts 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; };