perf(ui): more gallery perf improvements

This commit is contained in:
psychedelicious
2024-10-27 20:30:37 +10:00
parent 8c8e7102c2
commit d4a95af14f
16 changed files with 304 additions and 221 deletions

View File

@@ -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<HTMLImageElement | null>(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<MouseEventHandler<HTMLDivElement>>(() => {
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 (
<Box
className={GALLERY_IMAGE_CONTAINER_CLASS_NAME}
data-testid={dataTestId}
sx={galleryImageContainerSX}
opacity={isDragging ? 0.3 : 1}
data-testid={dataTestId}
data-is-dragging={isDragging}
>
<Flex
role="button"
@@ -196,157 +195,17 @@ export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => {
<Image
ref={ref}
src={imageDTO.thumbnail_url}
fallback={<SizedSkeleton width={imageDTO.width} height={imageDTO.height} />}
fallback={<SizedSkeletonLoader width={imageDTO.width} height={imageDTO.height} />}
w={imageDTO.width}
objectFit="contain"
maxW="full"
maxH="full"
borderRadius="base"
/>
<HoverIcons imageDTO={imageDTO} isHovered={isHovered.isTrue} />
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered.isTrue} />
</Flex>
</Box>
);
});
GalleryImage.displayName = 'GalleryImage';
const HoverIcons = memo(({ imageDTO, isHovered }: { imageDTO: ImageDTO; isHovered: boolean }) => {
const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
return (
<>
{(isHovered || alwaysShowImageSizeBadge) && <SizeBadge imageDTO={imageDTO} />}
{(isHovered || imageDTO.starred) && <StarIcon imageDTO={imageDTO} />}
{isHovered && <DeleteIcon imageDTO={imageDTO} />}
{isHovered && <OpenInViewerIconButton imageDTO={imageDTO} />}
</>
);
});
HoverIcons.displayName = 'HoverIcons';
const DeleteIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const shift = useShiftModifier();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!imageDTO) {
return;
}
dispatch(imagesToDeleteSelected([imageDTO]));
},
[dispatch, imageDTO]
);
if (!shift) {
return null;
}
return (
<IAIDndImageIcon
onClick={onClick}
icon={<PiTrashSimpleFill />}
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 (
<IAIDndImageIcon
onClick={onClick}
icon={<PiArrowsOutBold />}
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 : <PiStarFill />;
} else {
return customStarUi ? customStarUi.off.icon : <PiStarBold />;
}
}, [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 (
<IAIDndImageIcon
onClick={toggleStarredState}
icon={starIcon}
tooltip={starTooltip}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
});
StarIcon.displayName = 'StarIcon';
const SizeBadge = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
return (
<Text
className="gallery-image-size-badge"
position="absolute"
background="base.900"
color="base.50"
fontSize="sm"
fontWeight="semibold"
bottom={1}
left={1}
opacity={0.7}
px={2}
lineHeight={1.25}
borderTopEndRadius="base"
pointerEvents="none"
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
);
});
SizeBadge.displayName = 'SizeBadge';
const SizedSkeleton = memo(({ width, height }: { width: number; height: number }) => {
return <Skeleton w={`${width}px`} h="auto" objectFit="contain" aspectRatio={`${width}/${height}`} />;
});
SizedSkeleton.displayName = 'SizedSkeleton';

View File

@@ -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<HTMLButtonElement>) => {
e.stopPropagation();
if (!imageDTO) {
return;
}
dispatch(imagesToDeleteSelected([imageDTO]));
},
[dispatch, imageDTO]
);
if (!shift) {
return null;
}
return (
<IAIDndImageIcon
onClick={onClick}
icon={<PiTrashSimpleFill />}
tooltip={t('gallery.deleteImage_one')}
position="absolute"
bottom={2}
insetInlineEnd={2}
/>
);
});
GalleryImageDeleteIconButton.displayName = 'GalleryImageDeleteIconButton';

View File

@@ -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<HTMLDivElement | null>(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) => (
<GalleryImage key={imageDTO.image_name} imageDTO={imageDTO} />
))}
<GalleryImageGridImages />
</Grid>
</Box>
<GallerySelectionCountTag />
@@ -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) => (
<GalleryImage key={imageDTO.image_name} imageDTO={imageDTO} />
))}
</>
);
});
GalleryImageGridImages.displayName = 'GalleryImageGridImages';

View File

@@ -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) && <GalleryImageSizeBadge imageDTO={imageDTO} />}
{(isHovered || imageDTO.starred) && <GalleryImageStarIconButton imageDTO={imageDTO} />}
{isHovered && <GalleryImageDeleteIconButton imageDTO={imageDTO} />}
{isHovered && <GalleryImageOpenInViewerIconButton imageDTO={imageDTO} />}
</>
);
});
GalleryImageHoverIcons.displayName = 'GalleryImageHoverIcons';

View File

@@ -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 (
<IAIDndImageIcon
onClick={onClick}
icon={<PiArrowsOutBold />}
tooltip={t('gallery.openInViewer')}
position="absolute"
insetBlockStart={2}
insetInlineStart={2}
/>
);
});
GalleryImageOpenInViewerIconButton.displayName = 'GalleryImageOpenInViewerIconButton';

View File

@@ -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 (
<Text
className="gallery-image-size-badge"
position="absolute"
background="base.900"
color="base.50"
fontSize="sm"
fontWeight="semibold"
bottom={1}
left={1}
opacity={0.7}
px={2}
lineHeight={1.25}
borderTopEndRadius="base"
pointerEvents="none"
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
);
});
GalleryImageSizeBadge.displayName = 'GalleryImageSizeBadge';

View File

@@ -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 (
<IAIDndImageIcon
onClick={toggleStarredState}
icon={imageDTO.starred ? customStarUi.on.icon : customStarUi.off.icon}
tooltip={imageDTO.starred ? customStarUi.on.text : customStarUi.off.text}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
}
return (
<IAIDndImageIcon
onClick={toggleStarredState}
icon={imageDTO.starred ? <PiStarFill /> : <PiStarBold />}
tooltip={imageDTO.starred ? 'Unstar' : 'Star'}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
});
GalleryImageStarIconButton.displayName = 'GalleryImageStarIconButton';

View File

@@ -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 (
<Button size="sm" variant="link" isDisabled>
@@ -66,7 +73,7 @@ const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => {
);
}
return (
<Button size="sm" onClick={goToPage.bind(null, page - 1)} variant={currentPage === page - 1 ? 'solid' : 'outline'}>
<Button size="sm" onClick={onClick} variant={currentPage === page - 1 ? 'solid' : 'outline'}>
{page}
</Button>
);

View File

@@ -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
)}
</InputGroup>
);
};
});
GallerySearch.displayName = 'GallerySearch';

View File

@@ -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 <GallerySelectionCountTagContent selection={selection} />;
return <GallerySelectionCountTagContent />;
});
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"
>
<TagLabel>
{selection.length} {t('common.selected')}
{selectionCount} {t('common.selected')}
</TagLabel>
<TagCloseButton onClick={onClearSelection} />
</Tag>

View File

@@ -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 <Skeleton w={`${width}px`} h="auto" objectFit="contain" aspectRatio={`${width}/${height}`} />;
});
SizedSkeletonLoader.displayName = 'SizedSkeletonLoader';

View File

@@ -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]

View File

@@ -56,7 +56,6 @@ export const useImageViewer = () => {
open: imageViewerState.setTrue,
close,
toggle: imageViewerState.toggle,
$state: $imageViewer,
openImageInViewer,
};
};

View File

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

View File

@@ -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"
/>
<PopoverBody>
<TooltipContent sendToCanvas={sendToCanvas} isStaging={isStaging} />
<TooltipContent />
</PopoverBody>
</PopoverContent>
</Portal>
@@ -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 (
<Button
onClick={onClick}
@@ -199,4 +200,6 @@ const ActivateCanvasButton = (props: PropsWithChildren) => {
{props.children}
</Button>
);
};
});
ActivateCanvasButton.displayName = 'ActivateCanvasButton';