diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index 62d54be40f..a2682a2ee1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -1,9 +1,9 @@ -import { Box, Button, ButtonGroup, Flex, Tab, TabList, Tabs, useDisclosure, VStack } from '@invoke-ai/ui-library'; +import { Box, Button, ButtonGroup, Flex, Tab, TabList, Tabs, useDisclosure } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $galleryHeader } from 'app/store/nanostores/galleryHeader'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { galleryViewChanged } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useRef } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiImagesBold } from 'react-icons/pi'; import { RiServerLine } from 'react-icons/ri'; @@ -12,13 +12,10 @@ import BoardsList from './Boards/BoardsList/BoardsList'; import GalleryBoardName from './GalleryBoardName'; import GallerySettingsPopover from './GallerySettingsPopover'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; -import { GalleryImageGridContainer } from './ImageGrid/GalleryImageGridContainer'; import { GalleryPagination } from './ImageGrid/GalleryPagination'; const ImageGalleryContent = () => { const { t } = useTranslation(); - const resizeObserverRef = useRef(null); - const galleryGridRef = useRef(null); const galleryView = useAppSelector((s) => s.gallery.galleryView); const dispatch = useAppDispatch(); const galleryHeader = useStore($galleryHeader); @@ -33,10 +30,10 @@ const ImageGalleryContent = () => { }, [dispatch]); return ( - + {galleryHeader} - - + + @@ -44,39 +41,37 @@ const ImageGalleryContent = () => { - - - - - - } - data-testid="images-tab" - > - {t('parameters.images')} - - } - data-testid="assets-tab" - > - {t('gallery.assets')} - - - - - - + + + + + } + data-testid="images-tab" + > + {t('parameters.images')} + + } + data-testid="assets-tab" + > + {t('gallery.assets')} + + + + + ); 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 b11dd179bf..bd71007402 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -17,8 +17,10 @@ import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi'; import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; -import { ImageDTO } from '../../../../services/api/types'; -import { imageItemContainerTestId } from './ImageGridItemContainer'; +import type { ImageDTO } from 'services/api/types'; + +// This class name is used to calculate the number of images that fit in the gallery +export const GALLERY_IMAGE_CLASS_NAME = 'gallery-image'; const imageSx: SystemStyleObject = { w: 'full', h: 'full' }; const boxSx: SystemStyleObject = { @@ -135,7 +137,7 @@ const GalleryImage = ({ index, imageDTO }: HoverableImageProps) => { } return ( - + { useGalleryHotkeys(); const { t } = useTranslation(); - - const { galleryImageMinimumWidth, limit } = useAppSelector((s) => s.gallery); const queryArgs = useAppSelector(selectListImagesQueryArgs); const { imageDTOs, isLoading, isSuccess, isError } = useListImagesQuery(queryArgs, { selectFromResult: ({ data, isLoading, isSuccess, isError }) => ({ @@ -38,7 +36,7 @@ const GalleryImageGrid = () => { ); } - if (isLoading || !limit) { + if (isLoading) { return ( @@ -54,22 +52,99 @@ const GalleryImageGrid = () => { ); } + return ; +}; + +export default memo(GalleryImageGrid); + +const Content = () => { + const dispatch = useAppDispatch(); + const galleryImageMinimumWidth = useAppSelector((s) => s.gallery.galleryImageMinimumWidth); + + const queryArgs = useAppSelector(selectListImagesQueryArgs); + const { imageDTOs } = useListImagesQuery(queryArgs, { + selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }), + }); + // Use a callback ref to get reactivity on the container element because it is conditionally rendered + const [container, containerRef] = useState(null); + + const calculateNewLimit = useMemo(() => { + // Debounce this to not thrash the API + return debounce(() => { + if (!container) { + // Container not rendered yet + return; + } + // Managing refs for dynamically rendered components is a bit tedious: + // - https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback + // As a easy workaround, we can just grab the first gallery image element directly. + const galleryImageEl = document.querySelector(`.${GALLERY_IMAGE_CLASS_NAME}`); + if (!galleryImageEl) { + // No images in gallery? + return; + } + + const galleryImageRect = galleryImageEl.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (!galleryImageRect.width || !galleryImageRect.height || !containerRect.width || !containerRect.height) { + // Gallery is too small to fit images or not rendered yet + return; + } + + // Floating-point precision requires we round to get the correct number of images per row + const imagesPerRow = Math.round(containerRect.width / galleryImageRect.width); + // However, when calculating the number of images per column, we want to floor the value to not overflow the container + const imagesPerColumn = Math.floor(containerRect.height / galleryImageRect.height); + // Always load at least 1 row of images + const limit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn); + dispatch(limitChanged(limit)); + }, 300); + }, [container, dispatch]); + + useEffect(() => { + // We want to recalculate the limit when image size changes + calculateNewLimit(); + }, [calculateNewLimit, galleryImageMinimumWidth]); + + useEffect(() => { + if (!container) { + return; + } + + const resizeObserver = new ResizeObserver(calculateNewLimit); + resizeObserver.observe(container); + + // First render + calculateNewLimit(); + + return () => { + resizeObserver.disconnect(); + }; + }, [calculateNewLimit, container, dispatch]); + return ( - <> - + + {imageDTOs.map((imageDTO, index) => ( ))} - {/* */} - + ); }; - -export default memo(GalleryImageGrid); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGridContainer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGridContainer.tsx deleted file mode 100644 index 7b9670b8e0..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGridContainer.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Box, Flex } from '@invoke-ai/ui-library'; -import GalleryImageGrid from './GalleryImageGrid'; -import { useAppSelector, useAppDispatch } from '../../../../app/store/storeHooks'; -import { limitChanged } from '../../store/gallerySlice'; - -export const GalleryImageGridContainer = () => { - const { galleryImageMinimumWidth, limit } = useAppSelector((s) => s.gallery); - const dispatch = useAppDispatch(); - const containerRef = useRef(null); - - const calculateItemsPerPage = useCallback(() => { - const containerWidth = containerRef.current?.clientWidth; - const containerHeight = containerRef.current?.clientHeight; - console.log({ containerWidth, containerHeight, galleryImageMinimumWidth }); - if (containerHeight && containerWidth) { - const numberHorizontal = Math.floor(containerWidth / galleryImageMinimumWidth); - const imageWidth = containerWidth / numberHorizontal; - const numberAllowedVertical = Math.floor(containerHeight / imageWidth); - console.log({ numberAllowedVertical, numberHorizontal }); - dispatch(limitChanged(numberAllowedVertical * numberHorizontal)); - } - }, [containerRef, galleryImageMinimumWidth]); - - useEffect(() => { - dispatch(limitChanged(undefined)); - calculateItemsPerPage(); - }, [galleryImageMinimumWidth]); - - useEffect(() => { - if (!containerRef.current) { - return; - } - - const resizeObserver = new ResizeObserver(() => { - console.log('resize'); - if (!containerRef.current) { - return; - } - dispatch(limitChanged(undefined)); - calculateItemsPerPage(); - }); - - resizeObserver.observe(containerRef.current); - dispatch(limitChanged(undefined)); - calculateItemsPerPage(); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( - - {limit && } - - ); -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridItemContainer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridItemContainer.tsx deleted file mode 100644 index 16e851c0eb..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridItemContainer.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { FlexProps } from '@invoke-ai/ui-library'; -import { Box, forwardRef } from '@invoke-ai/ui-library'; -import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; - -export const imageItemContainerTestId = 'image-item-container'; - -type ItemContainerProps = PropsWithChildren & FlexProps; -const ItemContainer = forwardRef((props: ItemContainerProps, ref) => ( - - {props.children} - -)); - -export default memo(ItemContainer); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridListContainer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridListContainer.tsx deleted file mode 100644 index 1dbf7d95a6..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/ImageGridListContainer.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { FlexProps } from '@invoke-ai/ui-library'; -import { forwardRef, Grid } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; - -export const imageListContainerTestId = 'image-list-container'; - -type ListContainerProps = PropsWithChildren & FlexProps; -const ListContainer = forwardRef((props: ListContainerProps, ref) => { - const galleryImageMinimumWidth = useAppSelector((s) => s.gallery.galleryImageMinimumWidth); - - return ( - - {props.children} - - ); -}); - -export default memo(ListContainer); diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts index 531d5aa1a2..ea7b896c82 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts @@ -1,8 +1,8 @@ import { useAltModifier } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { GALLERY_IMAGE_CLASS_NAME } from 'features/gallery/components/ImageGrid/GalleryImage'; +import { GALLERY_GRID_CLASS_NAME } from 'features/gallery/components/ImageGrid/GalleryImageGrid'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; -import { imageItemContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridItemContainer'; -import { imageListContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridListContainer'; import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types'; import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; @@ -11,7 +11,6 @@ import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAli import { clamp } from 'lodash-es'; import { useCallback, useMemo } from 'react'; import type { ImageDTO } from 'services/api/types'; -import { imagesSelectors } from 'services/api/util'; /** * This hook is used to navigate the gallery using the arrow keys. @@ -29,10 +28,9 @@ import { imagesSelectors } from 'services/api/util'; */ const getImagesPerRow = (): number => { const widthOfGalleryImage = - document.querySelector(`.${imageItemContainerTestId}`)?.getBoundingClientRect().width ?? 1; + document.querySelector(`.${GALLERY_IMAGE_CLASS_NAME}`)?.getBoundingClientRect().width ?? 1; - const widthOfGalleryGrid = - document.querySelector(`[data-testid="${imageListContainerTestId}"]`)?.getBoundingClientRect().width ?? 0; + const widthOfGalleryGrid = document.querySelector(`.${GALLERY_GRID_CLASS_NAME}`)?.getBoundingClientRect().width ?? 0; const imagesPerRow = Math.round(widthOfGalleryGrid / widthOfGalleryImage); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 7ed948cbb0..810a63ae49 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -19,7 +19,7 @@ const initialGalleryState: GalleryState = { selectedBoardId: 'none', galleryView: 'images', boardSearchText: '', - limit: undefined, + limit: 20, offset: 0, isImageViewerOpen: true, imageToCompare: null, @@ -153,7 +153,7 @@ export const { comparisonFitChanged, comparisonModeCycled, offsetChanged, - limitChanged + limitChanged, } = gallerySlice.actions; const isAnyBoardDeleted = isAnyOf( diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index 9a4bc58db4..94db115ff3 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -19,7 +19,7 @@ export type GalleryState = { galleryView: GalleryView; boardSearchText: string; offset: number; - limit: number | undefined; + limit: number; alwaysShowImageSizeBadge: boolean; imageToCompare: ImageDTO | null; comparisonMode: ComparisonMode;