fix(ui): improve initial gallery loading logic

- `isLoading` - now `true` *only* on first load
- added `isFetching` - `true` whenever gallery images are fetching
- on first load, show a spinner instead of skeletons. this prevents an awkward flash of skeletons into empty gallery when the gallery doesn't have enough images to fill it.
- removed `imageCategoriesChanged` listener, bc now on app start, both images and assets will be populated. leaving this in caused jank flashes of skeletons when switching gallery tabs when gallery doesn't have images to load
This commit is contained in:
psychedelicious 2023-07-08 16:55:24 +10:00
parent d418e763ce
commit 0c528f22a7
6 changed files with 248 additions and 213 deletions

View File

@ -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();

View File

@ -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));
},
});
};

View File

@ -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,
})
);
}
},
});
};

View File

@ -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<HTMLDivElement>(null);
const rootRef = useRef(null);
const [scroller, setScroller] = useState<HTMLElement | null>(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 = () => {
</Box>
</Box>
<Flex direction="column" gap={2} h="full" w="full">
{images.length || areMoreAvailable ? (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={images}
endReached={handleEndReached}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, item) =>
typeof item === 'string' ? (
<Skeleton
sx={{ w: 'full', h: 'full', aspectRatio: '1/1' }}
/>
) : (
<GalleryImage
key={`${item.image_name}-${item.thumbnail_url}`}
imageDTO={item}
/>
)
}
/>
</Box>
<IAIButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isLoading={isLoading}
loadingText="Loading"
flexShrink={0}
>
{areMoreAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>
</>
) : (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
)}
<ImageGalleryGrid />
</Flex>
</VStack>
);
};
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default memo(ImageGalleryContent);

View File

@ -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<HTMLElement | null>(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 (
<Flex
sx={{
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spinner
size="xl"
sx={{ color: 'base.300', _dark: { color: 'base.700' } }}
/>
</Flex>
);
}
if (images.length) {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={images}
endReached={handleEndReached}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, item) =>
typeof item === 'string' ? (
<Skeleton sx={{ w: 'full', h: 'full', aspectRatio: '1/1' }} />
) : (
<GalleryImage
key={`${item.image_name}-${item.thumbnail_url}`}
imageDTO={item}
/>
)
}
/>
</Box>
<IAIButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isLoading={isLoading}
loadingText="Loading"
flexShrink={0}
>
{areMoreAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>
</>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default memo(ImageGalleryGrid);

View File

@ -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<string | undefined>) => {
state.selectedBoardId = action.payload;
},
isInitializedChanged: (state, action: PayloadAction<boolean>) => {
state.isInitialized = action.payload;
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
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;