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", "i18next-http-backend": "^2.2.0",
"konva": "^9.0.1", "konva": "^9.0.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"overlayscrollbars": "^2.1.1",
"overlayscrollbars-react": "^0.5.0",
"patch-package": "^7.0.0", "patch-package": "^7.0.0",
"re-resizable": "^6.9.9", "re-resizable": "^6.9.9",
"react": "^18.2.0", "react": "^18.2.0",
@ -91,6 +93,7 @@
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"react-virtuoso": "^4.3.3",
"react-zoom-pan-pinch": "^3.0.7", "react-zoom-pan-pinch": "^3.0.7",
"reactflow": "^11.7.0", "reactflow": "^11.7.0",
"redux-deep-persist": "^1.0.7", "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/700.css';
import '@fontsource/inter/800.css'; import '@fontsource/inter/800.css';
import '@fontsource/inter/900.css'; import '@fontsource/inter/900.css';
import 'overlayscrollbars/overlayscrollbars.css';
import 'theme/css/overlayscrollbars.css';
type ThemeLocaleProviderProps = { type ThemeLocaleProviderProps = {
children: ReactNode; children: ReactNode;

View File

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

View File

@ -1,5 +1,13 @@
import { ButtonGroup, Flex, Grid, Icon, Image, Text } from '@chakra-ui/react'; import {
// import { requestImages } from 'app/socketio/actions'; Box,
ButtonGroup,
Flex,
FlexProps,
Grid,
Icon,
Text,
forwardRef,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton'; import IAIButton from 'common/components/IAIButton';
import IAICheckbox from 'common/components/IAICheckbox'; import IAICheckbox from 'common/components/IAICheckbox';
@ -15,28 +23,33 @@ import {
setShouldUseSingleGalleryColumn, setShouldUseSingleGalleryColumn,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice'; 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 { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs'; import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaUser, FaWrench } from 'react-icons/fa'; import { FaImage, FaUser, FaWrench } from 'react-icons/fa';
import { MdPhotoLibrary } from 'react-icons/md'; import { MdPhotoLibrary } from 'react-icons/md';
import HoverableImage from './HoverableImage'; import HoverableImage from './HoverableImage';
import Scrollable from 'features/ui/components/common/Scrollable';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { import { resultsAdapter } from '../store/resultsSlice';
resultsAdapter,
selectResultsAll,
selectResultsTotal,
} from '../store/resultsSlice';
import { import {
receivedResultImagesPage, receivedResultImagesPage,
receivedUploadImagesPage, receivedUploadImagesPage,
} from 'services/thunks/gallery'; } from 'services/thunks/gallery';
import { selectUploadsAll, uploadsAdapter } from '../store/uploadsSlice'; import { uploadsAdapter } from '../store/uploadsSlice';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { Virtuoso, VirtuosoGrid } from 'react-virtuoso';
const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 290; const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 290;
@ -68,16 +81,28 @@ const ImageGalleryContent = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null); const resizeObserverRef = useRef<HTMLDivElement>(null);
const [shouldShouldIconButtons, setShouldShouldIconButtons] = useState(true); 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 { const {
// images, // images,
currentCategory, currentCategory,
shouldPinGallery, shouldPinGallery,
galleryImageMinimumWidth, galleryImageMinimumWidth,
galleryGridTemplateColumns,
galleryImageObjectFit, galleryImageObjectFit,
shouldAutoSwitchToNewImages, shouldAutoSwitchToNewImages,
// areMoreImagesAvailable,
shouldUseSingleGalleryColumn, shouldUseSingleGalleryColumn,
selectedImage, selectedImage,
} = useAppSelector(imageGallerySelector); } = useAppSelector(imageGallerySelector);
@ -85,9 +110,6 @@ const ImageGalleryContent = () => {
const { images, areMoreImagesAvailable, isLoading } = const { images, areMoreImagesAvailable, isLoading } =
useAppSelector(gallerySelector); useAppSelector(gallerySelector);
// const handleClickLoadMore = () => {
// dispatch(requestImages(currentCategory));
// };
const handleClickLoadMore = () => { const handleClickLoadMore = () => {
if (currentCategory === 'results') { if (currentCategory === 'results') {
dispatch(receivedResultImagesPage()); dispatch(receivedResultImagesPage());
@ -129,6 +151,25 @@ const ImageGalleryContent = () => {
return () => resizeObserver.disconnect(); // clean up 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 ( return (
<Flex flexDirection="column" w="full" h="full" gap={4}> <Flex flexDirection="column" w="full" h="full" gap={4}>
<Flex <Flex
@ -241,17 +282,43 @@ const ImageGalleryContent = () => {
/> />
</Flex> </Flex>
</Flex> </Flex>
<Scrollable>
<Flex direction="column" gap={2} h="full"> <Flex direction="column" gap={2} h="full">
{images.length || areMoreImagesAvailable ? ( {images.length || areMoreImagesAvailable ? (
<> <>
<Grid <Box ref={rootRef} data-overlayscrollbars="" h="100%">
gap={2} {shouldUseSingleGalleryColumn ? (
style={{ gridTemplateColumns: galleryGridTemplateColumns }} <Virtuoso
> style={{ height: '100%' }}
{images.map((image) => { data={images}
scrollerRef={(ref) => setScrollerRef(ref)}
itemContent={(index, image) => {
const { name } = image; const { name } = image;
const isSelected = selectedImage?.name === name; 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 ( return (
<HoverableImage <HoverableImage
key={`${name}-${image.thumbnail}`} key={`${name}-${image.thumbnail}`}
@ -259,8 +326,10 @@ const ImageGalleryContent = () => {
isSelected={isSelected} isSelected={isSelected}
/> />
); );
})} }}
</Grid> />
)}
</Box>
<IAIButton <IAIButton
onClick={handleClickLoadMore} onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable} isDisabled={!areMoreImagesAvailable}
@ -296,10 +365,36 @@ const ImageGalleryContent = () => {
</Flex> </Flex>
)} )}
</Flex> </Flex>
</Scrollable>
</Flex> </Flex>
); );
}; };
ImageGalleryContent.displayName = 'ImageGalleryContent'; type ItemContainerProps = PropsWithChildren & FlexProps;
export default ImageGalleryContent; 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" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== 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: p-cancelable@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" 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" ts-easing "^0.2.0"
tslib "^2.1.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: react-zoom-pan-pinch@^3.0.7:
version "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" resolved "https://registry.yarnpkg.com/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.0.7.tgz#def52f6886bc11e1b160dedf4250aae95470b94d"