feat(ui): use windowing for gallery

vastly improves the gallery performance when many images are loaded.

- `react-virtuoso` to do the virtualized list
- `overlayscrollbars` for a scrollbar
This commit is contained in:
psychedelicious 2023-05-01 16:04:25 +10:00
parent d39de0ad38
commit 475b6bef53
6 changed files with 261 additions and 107 deletions

View File

@ -76,6 +76,8 @@
"i18next-http-backend": "^2.2.0",
"konva": "^9.0.1",
"lodash-es": "^4.17.21",
"overlayscrollbars": "^2.1.1",
"overlayscrollbars-react": "^0.5.0",
"patch-package": "^7.0.0",
"re-resizable": "^6.9.9",
"react": "^18.2.0",
@ -91,6 +93,7 @@
"react-rnd": "^10.4.1",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
"react-virtuoso": "^4.3.3",
"react-zoom-pan-pinch": "^3.0.7",
"reactflow": "^11.7.0",
"redux-deep-persist": "^1.0.7",

View File

@ -18,6 +18,8 @@ import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import '@fontsource/inter/900.css';
import 'overlayscrollbars/overlayscrollbars.css';
import 'theme/css/overlayscrollbars.css';
type ThemeLocaleProviderProps = {
children: ReactNode;

View File

@ -5,6 +5,7 @@ import {
Image,
MenuItem,
MenuList,
Skeleton,
useDisclosure,
useTheme,
useToast,
@ -12,7 +13,7 @@ import {
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { DragEvent, memo, useCallback, useState } from 'react';
import { FaCheck, FaExpand, FaShare, FaTrash } from 'react-icons/fa';
import { FaCheck, FaExpand, FaImage, FaShare, FaTrash } from 'react-icons/fa';
import DeleteImageModal from './DeleteImageModal';
import { ContextMenu } from 'chakra-ui-contextmenu';
import * as InvokeAI from 'app/types/invokeai';
@ -268,58 +269,48 @@ const HoverableImage = memo((props: HoverableImageProps) => {
userSelect="none"
draggable={true}
onDragStart={handleDragStart}
onClick={handleSelectImage}
ref={ref}
sx={{
padding: 2,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
w: 'full',
h: 'full',
transition: 'transform 0.2s ease-out',
_hover: {
cursor: 'pointer',
zIndex: 2,
},
_before: {
content: '""',
display: 'block',
paddingBottom: '100%',
},
aspectRatio: '1/1',
}}
>
<Image
loading="lazy"
objectFit={
shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
}
rounded="md"
src={getUrl(thumbnail || url)}
loading="lazy"
fallback={<FaImage />}
sx={{
position: 'absolute',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
top: '50%',
transform: 'translate(-50%,-50%)',
...(direction === 'rtl'
? { insetInlineEnd: '50%' }
: { insetInlineStart: '50%' }),
}}
/>
<Flex
onClick={handleSelectImage}
sx={{
position: 'absolute',
top: '0',
insetInlineStart: '0',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isSelected && (
{isSelected && (
<Flex
sx={{
position: 'absolute',
top: '0',
insetInlineStart: '0',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Icon
filter={'drop-shadow(0px 0px 1rem black)'}
as={FaCheck}
sx={{
width: '50%',
@ -327,9 +318,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
fill: 'ok.500',
}}
/>
)}
</Flex>
{isHovered && galleryImageMinimumWidth >= 64 && (
</Flex>
)}
{isHovered && galleryImageMinimumWidth >= 100 && (
<Box
sx={{
position: 'absolute',

View File

@ -1,5 +1,13 @@
import { ButtonGroup, Flex, Grid, Icon, Image, Text } from '@chakra-ui/react';
// import { requestImages } from 'app/socketio/actions';
import {
Box,
ButtonGroup,
Flex,
FlexProps,
Grid,
Icon,
Text,
forwardRef,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import IAICheckbox from 'common/components/IAICheckbox';
@ -15,28 +23,33 @@ import {
setShouldUseSingleGalleryColumn,
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import {
ChangeEvent,
PropsWithChildren,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaUser, FaWrench } from 'react-icons/fa';
import { MdPhotoLibrary } from 'react-icons/md';
import HoverableImage from './HoverableImage';
import Scrollable from 'features/ui/components/common/Scrollable';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import {
resultsAdapter,
selectResultsAll,
selectResultsTotal,
} from '../store/resultsSlice';
import { resultsAdapter } from '../store/resultsSlice';
import {
receivedResultImagesPage,
receivedUploadImagesPage,
} from 'services/thunks/gallery';
import { selectUploadsAll, uploadsAdapter } from '../store/uploadsSlice';
import { uploadsAdapter } from '../store/uploadsSlice';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { Virtuoso, VirtuosoGrid } from 'react-virtuoso';
const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 290;
@ -68,16 +81,28 @@ const ImageGalleryContent = () => {
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null);
const [shouldShouldIconButtons, setShouldShouldIconButtons] = useState(true);
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,
currentCategory,
shouldPinGallery,
galleryImageMinimumWidth,
galleryGridTemplateColumns,
galleryImageObjectFit,
shouldAutoSwitchToNewImages,
// areMoreImagesAvailable,
shouldUseSingleGalleryColumn,
selectedImage,
} = useAppSelector(imageGallerySelector);
@ -85,9 +110,6 @@ const ImageGalleryContent = () => {
const { images, areMoreImagesAvailable, isLoading } =
useAppSelector(gallerySelector);
// const handleClickLoadMore = () => {
// dispatch(requestImages(currentCategory));
// };
const handleClickLoadMore = () => {
if (currentCategory === 'results') {
dispatch(receivedResultImagesPage());
@ -129,6 +151,25 @@ const ImageGalleryContent = () => {
return () => resizeObserver.disconnect(); // clean up
}, []);
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
const setScrollerRef = useCallback((ref: HTMLElement | Window | null) => {
if (ref instanceof HTMLElement) {
setScroller(ref);
}
}, []);
return (
<Flex flexDirection="column" w="full" h="full" gap={4}>
<Flex
@ -241,65 +282,119 @@ const ImageGalleryContent = () => {
/>
</Flex>
</Flex>
<Scrollable>
<Flex direction="column" gap={2} h="full">
{images.length || areMoreImagesAvailable ? (
<>
<Grid
gap={2}
style={{ gridTemplateColumns: galleryGridTemplateColumns }}
>
{images.map((image) => {
const { name } = image;
const isSelected = selectedImage?.name === name;
return (
<HoverableImage
key={`${name}-${image.thumbnail}`}
image={image}
isSelected={isSelected}
/>
);
})}
</Grid>
<IAIButton
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
isLoading={isLoading}
flexShrink={0}
>
{areMoreImagesAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>
</>
) : (
<Flex
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
padding: 8,
h: '100%',
w: '100%',
color: 'base.500',
}}
<Flex direction="column" gap={2} h="full">
{images.length || areMoreImagesAvailable ? (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
{shouldUseSingleGalleryColumn ? (
<Virtuoso
style={{ height: '100%' }}
data={images}
scrollerRef={(ref) => setScrollerRef(ref)}
itemContent={(index, image) => {
const { name } = image;
const isSelected = selectedImage?.name === name;
return (
<Flex sx={{ pb: 2 }}>
<HoverableImage
key={`${name}-${image.thumbnail}`}
image={image}
isSelected={isSelected}
/>
</Flex>
);
}}
/>
) : (
<VirtuosoGrid
style={{ height: '100%' }}
data={images}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, image) => {
const { name } = image;
const isSelected = selectedImage?.name === name;
return (
<HoverableImage
key={`${name}-${image.thumbnail}`}
image={image}
isSelected={isSelected}
/>
);
}}
/>
)}
</Box>
<IAIButton
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
isLoading={isLoading}
flexShrink={0}
>
<Icon
as={MdPhotoLibrary}
sx={{
w: 16,
h: 16,
}}
/>
<Text textAlign="center">{t('gallery.noImagesInGallery')}</Text>
</Flex>
)}
</Flex>
</Scrollable>
{areMoreImagesAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>
</>
) : (
<Flex
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
padding: 8,
h: '100%',
w: '100%',
color: 'base.500',
}}
>
<Icon
as={MdPhotoLibrary}
sx={{
w: 16,
h: 16,
}}
/>
<Text textAlign="center">{t('gallery.noImagesInGallery')}</Text>
</Flex>
)}
</Flex>
</Flex>
);
};
ImageGalleryContent.displayName = 'ImageGalleryContent';
export default ImageGalleryContent;
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref}>
{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={{
gap: 2,
gridTemplateColumns: `repeat(auto-fit, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default memo(ImageGalleryContent);

View File

@ -0,0 +1,48 @@
.os-scrollbar {
/* The size of the scrollbar */
/* --os-size: 0; */
/* The axis-perpedicular padding of the scrollbar (horizontal: padding-y, vertical: padding-x) */
/* --os-padding-perpendicular: 0; */
/* The axis padding of the scrollbar (horizontal: padding-x, vertical: padding-y) */
/* --os-padding-axis: 0; */
/* The border radius of the scrollbar track */
/* --os-track-border-radius: 0; */
/* The background of the scrollbar track */
--os-track-bg: rgba(0, 0, 0, 0.3);
/* The :hover background of the scrollbar track */
--os-track-bg-hover: rgba(0, 0, 0, 0.3);
/* The :active background of the scrollbar track */
--os-track-bg-active: rgba(0, 0, 0, 0.3);
/* The border of the scrollbar track */
/* --os-track-border: none; */
/* The :hover background of the scrollbar track */
/* --os-track-border-hover: none; */
/* The :active background of the scrollbar track */
/* --os-track-border-active: none; */
/* The border radius of the scrollbar handle */
/* --os-handle-border-radius: 0; */
/* The background of the scrollbar handle */
--os-handle-bg: var(--invokeai-colors-accent-500);
/* The :hover background of the scrollbar handle */
--os-handle-bg-hover: var(--invokeai-colors-accent-450);
/* The :active background of the scrollbar handle */
--os-handle-bg-active: var(--invokeai-colors-accent-400);
/* The border of the scrollbar handle */
/* --os-handle-border: none; */
/* The :hover border of the scrollbar handle */
/* --os-handle-border-hover: none; */
/* The :active border of the scrollbar handle */
/* --os-handle-border-active: none; */
/* The min size of the scrollbar handle */
--os-handle-min-size: 50px;
/* The max size of the scrollbar handle */
/* --os-handle-max-size: none; */
/* The axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
/* --os-handle-perpendicular-size: 100%; */
/* The :hover axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
/* --os-handle-perpendicular-size-hover: 100%; */
/* The :active axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
/* --os-handle-perpendicular-size-active: 100%; */
/* Increases the interactive area of the scrollbar handle. */
/* --os-handle-interactive-area-offset: 0; */
}

View File

@ -5100,6 +5100,16 @@ os-tmpdir@~1.0.2:
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
overlayscrollbars-react@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/overlayscrollbars-react/-/overlayscrollbars-react-0.5.0.tgz#0272bdc6304c7228a58d30e5b678e97fd5c5d8dd"
integrity sha512-uCNTnkfWW74veoiEv3kSwoLelKt4e8gTNv65D771X3il0x5g5Yo0fUbro7SpQzR9yNgi23cvB2mQHTTdQH96pA==
overlayscrollbars@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/overlayscrollbars/-/overlayscrollbars-2.1.1.tgz#a7414fe9c96cf140dbe4975bbe9312861750388d"
integrity sha512-xvs2g8Tcq9+CZDpLEUchN3YUzjJhnTWw9kwqT/qcC53FIkOyP9mqnRMot5sW16tcsPT1KaMyzF0AMXw/7E4a8g==
p-cancelable@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
@ -5612,6 +5622,11 @@ react-use@^17.4.0:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-virtuoso@^4.3.3:
version "4.3.3"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.3.3.tgz#04b8d105b5d97365223fb13c9a594f7501f7e975"
integrity sha512-x0DeGmVAVOVaTXRMG7jzrHBwK7+dkt7n0G3tNmZXphQUBgkVBYuZoaJltQeZGFN42++3XvrgwStKCtmzgMJ0lA==
react-zoom-pan-pinch@^3.0.7:
version "3.0.7"
resolved "https://registry.yarnpkg.com/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.0.7.tgz#def52f6886bc11e1b160dedf4250aae95470b94d"