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 77c9854084..bfaebb805a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -21,7 +21,6 @@ import { addImageAddedToBoardFulfilledListener, addImageAddedToBoardRejectedListener, } from './listeners/imageAddedToBoard'; -import { addImageCategoriesChangedListener } from './listeners/imageCategoriesChanged'; import { addImageDeletedFulfilledListener, addImageDeletedPendingListener, @@ -197,9 +196,6 @@ addSessionCanceledRejectedListener(); addReceivedPageOfImagesFulfilledListener(); addReceivedPageOfImagesRejectedListener(); -// Gallery -addImageCategoriesChangedListener(); - // ControlNet addControlNetImageProcessedListener(); addControlNetAutoProcessListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts index f3ee76a916..dc38ba911a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -1,5 +1,8 @@ import { createAction } from '@reduxjs/toolkit'; -import { isInitializedChanged } from 'features/gallery/store/gallerySlice'; +import { + INITIAL_IMAGE_LIMIT, + isLoadingChanged, +} from 'features/gallery/store/gallerySlice'; import { receivedPageOfImages } from 'services/api/thunks/image'; import { startAppListening } from '..'; @@ -20,22 +23,21 @@ export const addAppStartedListener = () => { categories: ['general'], is_intermediate: false, offset: 0, - limit: 100, + limit: INITIAL_IMAGE_LIMIT, }) ); // fill up the assets tab with images - dispatch( + await dispatch( receivedPageOfImages({ categories: ['control', 'mask', 'user', 'other'], is_intermediate: false, offset: 0, - limit: 100, + limit: INITIAL_IMAGE_LIMIT, }) ); - // tell the gallery it has made its initial fetches - dispatch(isInitializedChanged(true)); + dispatch(isLoadingChanged(false)); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts deleted file mode 100644 index 8214ae3bc8..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { log } from 'app/logging/useLogger'; -import { - imageCategoriesChanged, - selectFilteredImages, -} from 'features/gallery/store/gallerySlice'; -import { receivedPageOfImages } from 'services/api/thunks/image'; -import { startAppListening } from '..'; - -const moduleLog = log.child({ namespace: 'gallery' }); - -export const addImageCategoriesChangedListener = () => { - startAppListening({ - actionCreator: imageCategoriesChanged, - effect: (action, { getState, dispatch }) => { - const state = getState(); - const filteredImagesCount = selectFilteredImages(state).length; - - if (!filteredImagesCount) { - dispatch( - receivedPageOfImages({ - categories: action.payload, - board_id: state.gallery.selectedBoardId, - is_intermediate: false, - }) - ); - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index f7a2c81888..19d48ea910 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -3,90 +3,55 @@ import { Button, ButtonGroup, Flex, - FlexProps, - Grid, - Skeleton, Text, VStack, - forwardRef, useColorMode, useDisclosure, } from '@chakra-ui/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIButton from 'common/components/IAIButton'; import IAIIconButton from 'common/components/IAIIconButton'; import IAIPopover from 'common/components/IAIPopover'; import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox'; import IAISlider from 'common/components/IAISlider'; import { - IMAGE_LIMIT, - INITIAL_IMAGE_LIMIT, setGalleryImageMinimumWidth, setGalleryView, } from 'features/gallery/store/gallerySlice'; import { togglePinGalleryPanel } from 'features/ui/store/uiSlice'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { - ChangeEvent, - PropsWithChildren, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { ChangeEvent, memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs'; import { FaImage, FaServer, FaWrench } from 'react-icons/fa'; -import GalleryImage from './GalleryImage'; import { ChevronUpIcon } from '@chakra-ui/icons'; import { createSelector } from '@reduxjs/toolkit'; -import { RootState, stateSelector } from 'app/store/store'; +import { stateSelector } from 'app/store/store'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES, imageCategoriesChanged, - selectFilteredImages, shouldAutoSwitchChanged, } from 'features/gallery/store/gallerySlice'; -import { VirtuosoGrid } from 'react-virtuoso'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; -import { receivedPageOfImages } from 'services/api/thunks/image'; -import { ImageDTO } from 'services/api/types'; import { mode } from 'theme/util/mode'; import BoardsList from './Boards/BoardsList'; +import ImageGalleryGrid from './ImageGalleryGrid'; const selector = createSelector( - [stateSelector, selectFilteredImages], - (state, filteredImages) => { + [stateSelector], + (state) => { const { - categories, - total: allImagesTotal, - isLoading, selectedBoardId, galleryImageMinimumWidth, galleryView, shouldAutoSwitch, - isInitialized, } = state.gallery; const { shouldPinGallery } = state.ui; - const images = filteredImages as (ImageDTO | string)[]; - const skeletonCount = !isInitialized ? INITIAL_IMAGE_LIMIT : IMAGE_LIMIT; - return { - images: isLoading - ? images.concat(Array(skeletonCount).fill('loading')) - : images, - allImagesTotal, - isLoading, - categories, selectedBoardId, shouldPinGallery, galleryImageMinimumWidth, @@ -101,28 +66,10 @@ const ImageGalleryContent = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); const resizeObserverRef = useRef(null); - const rootRef = useRef(null); - const [scroller, setScroller] = useState(null); - const [initialize, osInstance] = useOverlayScrollbars({ - defer: true, - options: { - scrollbars: { - visibility: 'auto', - autoHide: 'leave', - autoHideDelay: 1300, - theme: 'os-theme-dark', - }, - overflow: { x: 'hidden' }, - }, - }); const { colorMode } = useColorMode(); const { - images, - isLoading, - allImagesTotal, - categories, selectedBoardId, shouldPinGallery, galleryImageMinimumWidth, @@ -136,32 +83,6 @@ const ImageGalleryContent = () => { }), }); - const filteredImagesTotal = useMemo( - () => selectedBoard?.image_count ?? allImagesTotal, - [allImagesTotal, selectedBoard?.image_count] - ); - - const areMoreAvailable = useMemo(() => { - return images.length < filteredImagesTotal; - }, [images.length, filteredImagesTotal]); - - const handleLoadMoreImages = useCallback(() => { - dispatch( - receivedPageOfImages({ - categories, - board_id: selectedBoardId, - is_intermediate: false, - }) - ); - }, [categories, dispatch, selectedBoardId]); - - const handleEndReached = useMemo(() => { - if (areMoreAvailable && !isLoading) { - return handleLoadMoreImages; - } - return undefined; - }, [areMoreAvailable, handleLoadMoreImages, isLoading]); - const { isOpen: isBoardListOpen, onToggle } = useDisclosure(); const handleChangeGalleryImageMinimumWidth = (v: number) => { @@ -173,19 +94,6 @@ const ImageGalleryContent = () => { dispatch(requestCanvasRescale()); }; - useEffect(() => { - const { current: root } = rootRef; - if (scroller && root) { - initialize({ - target: root, - elements: { - viewport: scroller, - }, - }); - } - return () => osInstance()?.destroy(); - }, [scroller, initialize, osInstance]); - const handleClickImagesCategory = useCallback(() => { dispatch(imageCategoriesChanged(IMAGE_CATEGORIES)); dispatch(setGalleryView('images')); @@ -309,80 +217,10 @@ const ImageGalleryContent = () => { - {images.length || areMoreAvailable ? ( - <> - - - typeof item === 'string' ? ( - - ) : ( - - ) - } - /> - - - {areMoreAvailable - ? t('gallery.loadMore') - : t('gallery.allImagesLoaded')} - - - ) : ( - - )} + ); }; -type ItemContainerProps = PropsWithChildren & FlexProps; -const ItemContainer = forwardRef((props: ItemContainerProps, ref) => ( - - {props.children} - -)); - -type ListContainerProps = PropsWithChildren & FlexProps; -const ListContainer = forwardRef((props: ListContainerProps, ref) => { - const galleryImageMinimumWidth = useAppSelector( - (state: RootState) => state.gallery.galleryImageMinimumWidth - ); - - return ( - - {props.children} - - ); -}); - export default memo(ImageGalleryContent); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryGrid.tsx new file mode 100644 index 0000000000..3578b57364 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryGrid.tsx @@ -0,0 +1,226 @@ +import { + Box, + Flex, + FlexProps, + Grid, + Skeleton, + Spinner, + forwardRef, +} from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import IAIButton from 'common/components/IAIButton'; +import { IMAGE_LIMIT } from 'features/gallery/store/gallerySlice'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; + +import { + PropsWithChildren, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaImage } from 'react-icons/fa'; +import GalleryImage from './GalleryImage'; + +import { createSelector } from '@reduxjs/toolkit'; +import { RootState, stateSelector } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { selectFilteredImages } from 'features/gallery/store/gallerySlice'; +import { VirtuosoGrid } from 'react-virtuoso'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import { receivedPageOfImages } from 'services/api/thunks/image'; +import { ImageDTO } from 'services/api/types'; + +const selector = createSelector( + [stateSelector, selectFilteredImages], + (state, filteredImages) => { + const { + categories, + total: allImagesTotal, + isLoading, + isFetching, + selectedBoardId, + } = state.gallery; + + let images = filteredImages as (ImageDTO | 'loading')[]; + + if (!isLoading && isFetching) { + // loading, not not the initial load + images = images.concat(Array(IMAGE_LIMIT).fill('loading')); + } + + return { + images, + allImagesTotal, + isLoading, + categories, + selectedBoardId, + }; + }, + defaultSelectorOptions +); + +const ImageGalleryGrid = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const rootRef = useRef(null); + const [scroller, setScroller] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + options: { + scrollbars: { + visibility: 'auto', + autoHide: 'leave', + autoHideDelay: 1300, + theme: 'os-theme-dark', + }, + overflow: { x: 'hidden' }, + }, + }); + + const { images, isLoading, allImagesTotal, categories, selectedBoardId } = + useAppSelector(selector); + + const { selectedBoard } = useListAllBoardsQuery(undefined, { + selectFromResult: ({ data }) => ({ + selectedBoard: data?.find((b) => b.board_id === selectedBoardId), + }), + }); + + const filteredImagesTotal = useMemo( + () => selectedBoard?.image_count ?? allImagesTotal, + [allImagesTotal, selectedBoard?.image_count] + ); + + const areMoreAvailable = useMemo(() => { + return images.length < filteredImagesTotal; + }, [images.length, filteredImagesTotal]); + + const handleLoadMoreImages = useCallback(() => { + dispatch( + receivedPageOfImages({ + categories, + board_id: selectedBoardId, + is_intermediate: false, + }) + ); + }, [categories, dispatch, selectedBoardId]); + + const handleEndReached = useMemo(() => { + if (areMoreAvailable && !isLoading) { + return handleLoadMoreImages; + } + return undefined; + }, [areMoreAvailable, handleLoadMoreImages, isLoading]); + + useEffect(() => { + const { current: root } = rootRef; + if (scroller && root) { + initialize({ + target: root, + elements: { + viewport: scroller, + }, + }); + } + return () => osInstance()?.destroy(); + }, [scroller, initialize, osInstance]); + + if (isLoading) { + return ( + + + + ); + } + + if (images.length) { + return ( + <> + + + typeof item === 'string' ? ( + + ) : ( + + ) + } + /> + + + {areMoreAvailable + ? t('gallery.loadMore') + : t('gallery.allImagesLoaded')} + + + ); + } + + return ( + + ); +}; + +type ItemContainerProps = PropsWithChildren & FlexProps; +const ItemContainer = forwardRef((props: ItemContainerProps, ref) => ( + + {props.children} + +)); + +type ListContainerProps = PropsWithChildren & FlexProps; +const ListContainer = forwardRef((props: ListContainerProps, ref) => { + const galleryImageMinimumWidth = useAppSelector( + (state: RootState) => state.gallery.galleryImageMinimumWidth + ); + + return ( + + {props.children} + + ); +}); + +export default memo(ImageGalleryGrid); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index b6c736ee4c..63fd9625a0 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -36,6 +36,7 @@ type AdditionaGalleryState = { limit: number; total: number; isLoading: boolean; + isFetching: boolean; categories: ImageCategory[]; selectedBoardId?: string; selection: string[]; @@ -51,6 +52,7 @@ export const initialGalleryState = limit: 0, total: 0, isLoading: true, + isFetching: true, categories: IMAGE_CATEGORIES, selection: [], shouldAutoSwitch: true, @@ -141,19 +143,19 @@ export const gallerySlice = createSlice({ boardIdSelected: (state, action: PayloadAction) => { state.selectedBoardId = action.payload; }, - isInitializedChanged: (state, action: PayloadAction) => { - state.isInitialized = action.payload; + isLoadingChanged: (state, action: PayloadAction) => { + state.isLoading = action.payload; }, }, extraReducers: (builder) => { builder.addCase(receivedPageOfImages.pending, (state) => { - state.isLoading = true; + state.isFetching = true; }); builder.addCase(receivedPageOfImages.rejected, (state) => { - state.isLoading = false; + state.isFetching = false; }); builder.addCase(receivedPageOfImages.fulfilled, (state, action) => { - state.isLoading = false; + state.isFetching = false; const { board_id, categories, image_origin, is_intermediate } = action.meta.arg; @@ -214,7 +216,7 @@ export const { setGalleryImageMinimumWidth, setGalleryView, boardIdSelected, - isInitializedChanged, + isLoadingChanged, } = gallerySlice.actions; export default gallerySlice.reducer;