From d4a95af14fd5e5c0fcdb00a05c269d1a64eb1922 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 27 Oct 2024 20:30:37 +1000 Subject: [PATCH] perf(ui): more gallery perf improvements --- .../src/app/components/GlobalImageHotkeys.tsx | 5 +- .../components/ImageGrid/GalleryImage.tsx | 195 +++--------------- .../GalleryImageDeleteIconButton.tsx | 46 +++++ .../components/ImageGrid/GalleryImageGrid.tsx | 41 ++-- .../ImageGrid/GalleryImageHoverIcons.tsx | 28 +++ .../GalleryImageOpenInViewerIconButton.tsx | 32 +++ .../ImageGrid/GalleryImageSizeBadge.tsx | 29 +++ .../ImageGrid/GalleryImageStarIconButton.tsx | 51 +++++ .../ImageGrid/GalleryPagination.tsx | 9 +- .../components/ImageGrid/GallerySearch.tsx | 8 +- .../ImageGrid/GallerySelectionCountTag.tsx | 25 ++- .../ImageGrid/SizedSkeletonLoader.tsx | 13 ++ .../ToggleMetadataViewerButton.tsx | 6 +- .../components/ImageViewer/useImageViewer.ts | 1 - .../gallery/store/gallerySelectors.ts | 15 +- .../queue/components/SendToToggle.tsx | 21 +- 16 files changed, 304 insertions(+), 221 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx diff --git a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx index cc38766f4f..c4826a9441 100644 --- a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx @@ -1,4 +1,3 @@ -import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; @@ -8,13 +7,11 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; export const GlobalImageHotkeys = memo(() => { useAssertSingleton('GlobalImageHotkeys'); - const lastSelectedImage = useAppSelector(selectLastSelectedImage); - const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken); + const imageDTO = useAppSelector(selectLastSelectedImage); if (!imageDTO) { return null; 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 385fe92574..f60ae35fba 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,26 +1,21 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Flex, Image, Skeleton, Text, useShiftModifier } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; +import { Box, Flex, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; -import { $customStarUI } from 'app/store/nanostores/customStarUI'; import { useAppStore } from 'app/store/nanostores/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; -import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { multipleImageDndSource, singleImageDndSource } from 'features/dnd2/types'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader'; +import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import type { MouseEvent, MouseEventHandler } from 'react'; +import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiArrowsOutBold, PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi'; -import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; // This class name is used to calculate the number of images that fit in the gallery @@ -35,6 +30,9 @@ const galleryImageContainerSX = { '&': { display: 'none' }, }, }, + '&[data-is-dragging=true]': { + opacity: 0.3, + }, '.gallery-image': { touchAction: 'none', userSelect: 'none', @@ -77,20 +75,14 @@ const galleryImageContainerSX = { }, } satisfies SystemStyleObject; -interface HoverableImageProps { +interface Props { imageDTO: ImageDTO; } -const selectAlwaysShouldImageSizeBadge = createSelector( - selectGallerySlice, - (gallery) => gallery.alwaysShowImageSizeBadge -); - -export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { +export const GalleryImage = memo(({ imageDTO }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [element, ref] = useState(null); - const imageViewer = useImageViewer(); const selectIsSelectedForCompare = useMemo( () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name), [imageDTO.image_name] @@ -98,9 +90,14 @@ export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare); const selectIsSelected = useMemo( () => - createSelector(selectGallerySlice, (gallery) => - gallery.selection.some((i) => i.image_name === imageDTO.image_name) - ), + createSelector(selectGallerySlice, (gallery) => { + for (const selectedImage of gallery.selection) { + if (selectedImage.image_name === imageDTO.image_name) { + return true; + } + } + return false; + }), [imageDTO.image_name] ); const isSelected = useAppSelector(selectIsSelected); @@ -168,9 +165,11 @@ export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { ); const onDoubleClick = useCallback>(() => { - imageViewer.open(); + // Use the atom here directly instead of the `useImageViewer` to avoid re-rendering the gallery when the viewer + // opened state changes. + $imageViewer.set(true); store.dispatch(imageToCompareChanged(null)); - }, [imageViewer, store]); + }, [store]); const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]); @@ -179,9 +178,9 @@ export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { return ( { } + fallback={} w={imageDTO.width} objectFit="contain" maxW="full" maxH="full" borderRadius="base" /> - + ); }); GalleryImage.displayName = 'GalleryImage'; - -const HoverIcons = memo(({ imageDTO, isHovered }: { imageDTO: ImageDTO; isHovered: boolean }) => { - const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge); - - return ( - <> - {(isHovered || alwaysShowImageSizeBadge) && } - {(isHovered || imageDTO.starred) && } - {isHovered && } - {isHovered && } - - ); -}); -HoverIcons.displayName = 'HoverIcons'; - -const DeleteIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - const shift = useShiftModifier(); - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const onClick = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - if (!imageDTO) { - return; - } - dispatch(imagesToDeleteSelected([imageDTO])); - }, - [dispatch, imageDTO] - ); - - if (!shift) { - return null; - } - - return ( - } - tooltip={t('gallery.deleteImage_one')} - position="absolute" - bottom={2} - insetInlineEnd={2} - /> - ); -}); - -DeleteIcon.displayName = 'DeleteIcon'; - -const OpenInViewerIconButton = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - const imageViewer = useImageViewer(); - const { t } = useTranslation(); - - const onClick = useCallback(() => { - imageViewer.openImageInViewer(imageDTO); - }, [imageDTO, imageViewer]); - - return ( - } - tooltip={t('gallery.openInViewer')} - position="absolute" - insetBlockStart={2} - insetInlineStart={2} - /> - ); -}); - -OpenInViewerIconButton.displayName = 'OpenInViewerIconButton'; - -const StarIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - const customStarUi = useStore($customStarUI); - const [starImages] = useStarImagesMutation(); - const [unstarImages] = useUnstarImagesMutation(); - - const toggleStarredState = useCallback(() => { - if (imageDTO.starred) { - unstarImages({ imageDTOs: [imageDTO] }); - } else { - starImages({ imageDTOs: [imageDTO] }); - } - }, [starImages, unstarImages, imageDTO]); - - const starIcon = useMemo(() => { - if (imageDTO.starred) { - return customStarUi ? customStarUi.on.icon : ; - } else { - return customStarUi ? customStarUi.off.icon : ; - } - }, [imageDTO.starred, customStarUi]); - - const starTooltip = useMemo(() => { - if (imageDTO.starred) { - return customStarUi ? customStarUi.off.text : 'Unstar'; - } else { - return customStarUi ? customStarUi.on.text : 'Star'; - } - }, [imageDTO.starred, customStarUi]); - - return ( - - ); -}); - -StarIcon.displayName = 'StarIcon'; - -const SizeBadge = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - return ( - {`${imageDTO.width}x${imageDTO.height}`} - ); -}); - -SizeBadge.displayName = 'SizeBadge'; - -const SizedSkeleton = memo(({ width, height }: { width: number; height: number }) => { - return ; -}); - -SizedSkeleton.displayName = 'SizedSkeleton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx new file mode 100644 index 0000000000..600afbb1cf --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx @@ -0,0 +1,46 @@ +import { useShiftModifier } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import type { MouseEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleFill } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => { + const shift = useShiftModifier(); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const onClick = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + if (!imageDTO) { + return; + } + dispatch(imagesToDeleteSelected([imageDTO])); + }, + [dispatch, imageDTO] + ); + + if (!shift) { + return null; + } + + return ( + } + tooltip={t('gallery.deleteImage_one')} + position="absolute" + bottom={2} + insetInlineEnd={2} + /> + ); +}); + +GalleryImageDeleteIconButton.displayName = 'GalleryImageDeleteIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx index fba719520a..76ace602b6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx @@ -1,11 +1,14 @@ import { Box, Flex, Grid } from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { GallerySelectionCountTag } from 'features/gallery/components/ImageGrid/GallerySelectionCountTag'; import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys'; -import { selectGalleryImageMinimumWidth, selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { + selectGalleryImageMinimumWidth, + selectGalleryLimit, + selectListImagesQueryArgs, +} from 'features/gallery/store/gallerySelectors'; import { limitChanged } from 'features/gallery/store/gallerySlice'; import { debounce } from 'lodash-es'; import { memo, useEffect, useMemo, useState } from 'react'; @@ -61,11 +64,8 @@ export default memo(GalleryImageGrid); const GalleryImageGridContent = memo(() => { const dispatch = useAppDispatch(); const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); + const limit = useAppSelector(selectGalleryLimit); - 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); @@ -130,19 +130,19 @@ const GalleryImageGridContent = memo(() => { } // Always load at least 1 row of images - const limit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn); + const newLimit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn); - if (queryArgs === skipToken || queryArgs.limit === limit) { + if (limit === 0 || limit === newLimit) { return; } - dispatch(limitChanged(limit)); + dispatch(limitChanged(newLimit)); }, 300); - }, [container, dispatch, queryArgs]); + }, [container, dispatch, limit]); useEffect(() => { // We want to recalculate the limit when image size changes calculateNewLimit(); - }, [calculateNewLimit, galleryImageMinimumWidth, imageDTOs]); + }, [calculateNewLimit, galleryImageMinimumWidth]); useEffect(() => { if (!container) { @@ -178,9 +178,7 @@ const GalleryImageGridContent = memo(() => { gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`} gap={1} > - {imageDTOs.map((imageDTO) => ( - - ))} + @@ -189,3 +187,18 @@ const GalleryImageGridContent = memo(() => { }); GalleryImageGridContent.displayName = 'GalleryImageGridContent'; + +const GalleryImageGridImages = memo(() => { + const queryArgs = useAppSelector(selectListImagesQueryArgs); + const { imageDTOs } = useListImagesQuery(queryArgs, { + selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }), + }); + return ( + <> + {imageDTOs.map((imageDTO) => ( + + ))} + + ); +}); +GalleryImageGridImages.displayName = 'GalleryImageGridImages'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx new file mode 100644 index 0000000000..dcaa5729d1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx @@ -0,0 +1,28 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { GalleryImageDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryImageDeleteIconButton'; +import { GalleryImageOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton'; +import { GalleryImageSizeBadge } from 'features/gallery/components/ImageGrid/GalleryImageSizeBadge'; +import { GalleryImageStarIconButton } from 'features/gallery/components/ImageGrid/GalleryImageStarIconButton'; +import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors'; +import { memo } from 'react'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; + isHovered: boolean; +}; + +export const GalleryImageHoverIcons = memo(({ imageDTO, isHovered }: Props) => { + const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge); + + return ( + <> + {(isHovered || alwaysShowImageSizeBadge) && } + {(isHovered || imageDTO.starred) && } + {isHovered && } + {isHovered && } + + ); +}); + +GalleryImageHoverIcons.displayName = 'GalleryImageHoverIcons'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx new file mode 100644 index 0000000000..792248bce1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -0,0 +1,32 @@ +import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowsOutBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => { + const imageViewer = useImageViewer(); + const { t } = useTranslation(); + + const onClick = useCallback(() => { + imageViewer.openImageInViewer(imageDTO); + }, [imageDTO, imageViewer]); + + return ( + } + tooltip={t('gallery.openInViewer')} + position="absolute" + insetBlockStart={2} + insetInlineStart={2} + /> + ); +}); + +GalleryImageOpenInViewerIconButton.displayName = 'GalleryImageOpenInViewerIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx new file mode 100644 index 0000000000..e7e473d86a --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx @@ -0,0 +1,29 @@ +import { Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageSizeBadge = memo(({ imageDTO }: Props) => { + return ( + {`${imageDTO.width}x${imageDTO.height}`} + ); +}); + +GalleryImageSizeBadge.displayName = 'GalleryImageSizeBadge'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx new file mode 100644 index 0000000000..7ba787afab --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx @@ -0,0 +1,51 @@ +import { useStore } from '@nanostores/react'; +import { $customStarUI } from 'app/store/nanostores/customStarUI'; +import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { memo, useCallback } from 'react'; +import { PiStarBold, PiStarFill } from 'react-icons/pi'; +import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => { + const customStarUi = useStore($customStarUI); + const [starImages] = useStarImagesMutation(); + const [unstarImages] = useUnstarImagesMutation(); + + const toggleStarredState = useCallback(() => { + if (imageDTO.starred) { + unstarImages({ imageDTOs: [imageDTO] }); + } else { + starImages({ imageDTOs: [imageDTO] }); + } + }, [starImages, unstarImages, imageDTO]); + + if (customStarUi) { + return ( + + ); + } + + return ( + : } + tooltip={imageDTO.starred ? 'Unstar' : 'Star'} + position="absolute" + top={2} + insetInlineEnd={2} + /> + ); +}); + +GalleryImageStarIconButton.displayName = 'GalleryImageStarIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx index 3ccb475209..eaca653b17 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx @@ -58,6 +58,13 @@ type PageButtonProps = { }; const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => { + const onClick = useCallback(() => { + if (page === ELLIPSIS) { + return; + } + goToPage(page - 1); + }, [goToPage, page]); + if (page === ELLIPSIS) { return ( ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx index bb2fe8ff29..0ab0f17894 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx @@ -2,7 +2,7 @@ import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invo import { useAppSelector } from 'app/store/storeHooks'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import type { ChangeEvent, KeyboardEvent } from 'react'; -import { useCallback } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; import { useListImagesQuery } from 'services/api/endpoints/images'; @@ -13,7 +13,7 @@ type Props = { onResetSearchTerm: () => void; }; -export const GallerySearch = ({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { +export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { const { t } = useTranslation(); const queryArgs = useAppSelector(selectListImagesQueryArgs); const { isPending } = useListImagesQuery(queryArgs, { @@ -64,4 +64,6 @@ export const GallerySearch = ({ searchTerm, onChangeSearchTerm, onResetSearchTer )} ); -}; +}); + +GallerySearch.displayName = 'GallerySearch'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index 01fe12d520..1ee42cf5cb 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -2,15 +2,19 @@ import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; +import { + selectFirstSelectedImage, + selectSelection, + selectSelectionCount, +} from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ImageDTO } from 'services/api/types'; export const GallerySelectionCountTag = memo(() => { const dispatch = useAppDispatch(); - const { selection } = useAppSelector((s) => s.gallery); + const selection = useAppSelector(selectSelection); const { imageDTOs } = useGalleryImages(); const isGalleryFocused = useIsRegionFocused('gallery'); @@ -30,30 +34,29 @@ export const GallerySelectionCountTag = memo(() => { return null; } - return ; + return ; }); GallerySelectionCountTag.displayName = 'GallerySelectionCountTag'; -const GallerySelectionCountTagContent = memo(({ selection }: { selection: ImageDTO[] }) => { +const GallerySelectionCountTagContent = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const isGalleryFocused = useIsRegionFocused('gallery'); - + const firstImage = useAppSelector(selectFirstSelectedImage); + const selectionCount = useAppSelector(selectSelectionCount); const onClearSelection = useCallback(() => { - const firstImage = selection[0]; if (firstImage) { dispatch(selectionChanged([firstImage])); } - }, [dispatch, selection]); + }, [dispatch, firstImage]); useRegisteredHotkeys({ id: 'clearSelection', category: 'gallery', callback: onClearSelection, - options: { enabled: selection.length > 0 && isGalleryFocused }, - dependencies: [onClearSelection, selection, isGalleryFocused], + options: { enabled: selectionCount > 0 && isGalleryFocused }, + dependencies: [onClearSelection, selectionCount, isGalleryFocused], }); return ( @@ -71,7 +74,7 @@ const GallerySelectionCountTagContent = memo(({ selection }: { selection: ImageD borderColor="whiteAlpha.300" > - {selection.length} {t('common.selected')} + {selectionCount} {t('common.selected')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx new file mode 100644 index 0000000000..82c4a52d14 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@invoke-ai/ui-library'; +import { memo } from 'react'; + +type Props = { + width: number; + height: number; +}; + +export const SizedSkeletonLoader = memo(({ width, height }: Props) => { + return ; +}); + +SizedSkeletonLoader.displayName = 'SizedSkeletonLoader'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx index 31d454c2f4..5d552e57d9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx @@ -1,5 +1,4 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; @@ -8,16 +7,13 @@ import { setShouldShowImageDetails } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiInfoBold } from 'react-icons/pi'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; export const ToggleMetadataViewerButton = memo(() => { const dispatch = useAppDispatch(); const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); - const lastSelectedImage = useAppSelector(selectLastSelectedImage); + const imageDTO = useAppSelector(selectLastSelectedImage); const { t } = useTranslation(); - const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken); - const toggleMetadataViewer = useCallback( () => dispatch(setShouldShowImageDetails(!shouldShowImageDetails)), [dispatch, shouldShowImageDetails] diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts index 7a9ba71988..d3ec86025e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts @@ -56,7 +56,6 @@ export const useImageViewer = () => { open: imageViewerState.setTrue, close, toggle: imageViewerState.toggle, - $state: $imageViewer, openImageInViewer, }; }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index b4cebd5eab..c229374df9 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -6,12 +6,14 @@ import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types'; -export const selectLastSelectedImage = createSelector( +export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); +export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); +export const selectLastSelectedImageName = createSelector( selectGallerySlice, - (gallery) => gallery.selection[gallery.selection.length - 1] + (gallery) => gallery.selection.at(-1)?.image_name ); -export const selectLastSelectedImageName = createSelector(selectLastSelectedImage, (image) => image?.image_name); +export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit); export const selectListImagesQueryArgs = createMemoizedSelector( selectGallerySlice, (gallery): ListImagesArgs | SkipToken => @@ -50,6 +52,7 @@ export const selectBoardsListOrderBy = createSelector(selectGallerySlice, (galle export const selectBoardsListOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderDir); export const selectSelectionCount = createSelector(selectGallerySlice, (gallery) => gallery.selection.length); +export const selectSelection = createSelector(selectGallerySlice, (gallery) => gallery.selection); export const selectHasMultipleImagesSelected = createSelector(selectSelectionCount, (count) => count > 1); export const selectGalleryImageMinimumWidth = createSelector( selectGallerySlice, @@ -59,6 +62,8 @@ export const selectGalleryImageMinimumWidth = createSelector( export const selectComparisonMode = createSelector(selectGallerySlice, (gallery) => gallery.comparisonMode); export const selectComparisonFit = createSelector(selectGallerySlice, (gallery) => gallery.comparisonFit); export const selectImageToCompare = createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare); -export const selectHasImageToCompare = createSelector(selectImageToCompare, (imageToCompare) => - Boolean(imageToCompare) +export const selectHasImageToCompare = createSelector(selectGallerySlice, (gallery) => Boolean(gallery.imageToCompare)); +export const selectAlwaysShouldImageSizeBadge = createSelector( + selectGallerySlice, + (gallery) => gallery.alwaysShowImageSizeBadge ); diff --git a/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx b/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx index 702dca504b..f4de748c91 100644 --- a/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx +++ b/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx @@ -18,7 +18,7 @@ import { import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectSendToCanvas, settingsSendToCanvasChanged } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; import type { ChangeEvent, PropsWithChildren } from 'react'; import { memo, useCallback, useMemo } from 'react'; @@ -142,7 +142,7 @@ export const SendToToggle = memo(() => { transitionDuration="0.2s" /> - + @@ -150,10 +150,12 @@ export const SendToToggle = memo(() => { ); }); -SendToToggle.displayName = 'CanvasSendToToggle'; +SendToToggle.displayName = 'SendToToggle'; -const TooltipContent = memo(({ sendToCanvas, isStaging }: { sendToCanvas: boolean; isStaging: boolean }) => { +const TooltipContent = memo(() => { const { t } = useTranslation(); + const sendToCanvas = useAppSelector(selectSendToCanvas); + const isStaging = useAppSelector(selectIsStaging); if (isStaging) { return ( @@ -180,14 +182,13 @@ const TooltipContent = memo(({ sendToCanvas, isStaging }: { sendToCanvas: boolea TooltipContent.displayName = 'TooltipContent'; -const ActivateCanvasButton = (props: PropsWithChildren) => { +const ActivateCanvasButton = memo((props: PropsWithChildren) => { const dispatch = useAppDispatch(); - const imageViewer = useImageViewer(); const onClick = useCallback(() => { dispatch(setActiveTab('canvas')); dispatch(activeTabCanvasRightPanelChanged('layers')); - imageViewer.close(); - }, [dispatch, imageViewer]); + $imageViewer.set(false); + }, [dispatch]); return ( ); -}; +}); + +ActivateCanvasButton.displayName = 'ActivateCanvasButton';