mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-13 04:25:24 -05:00
perf(ui): more gallery perf improvements
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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]
|
||||
|
||||
@@ -56,7 +56,6 @@ export const useImageViewer = () => {
|
||||
open: imageViewerState.setTrue,
|
||||
close,
|
||||
toggle: imageViewerState.toggle,
|
||||
$state: $imageViewer,
|
||||
openImageInViewer,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user