feat(ui): refactor DeleteImageModal

- refactor the component
- use translations
- add config for systems where deleted images are not sent to bin (only changes the messaging)
This commit is contained in:
psychedelicious 2023-04-27 12:37:53 +10:00
parent 0cc739afc8
commit 99392debe8
7 changed files with 573 additions and 528 deletions

View File

@ -63,7 +63,7 @@
"postProcessDesc3": "The Invoke AI Command Line Interface offers various other features including Embiggen.",
"training": "Training",
"trainingDesc1": "A dedicated workflow for training your own embeddings and checkpoints using Textual Inversion and Dreambooth from the web interface.",
"trainingDesc2": "InvokeAI already supports training custom embeddings using Textual Inversion using the main script.",
"trainingDesc2": "InvokeAI already supports training custom embeddourings using Textual Inversion using the main script.",
"upload": "Upload",
"close": "Close",
"cancel": "Cancel",
@ -100,7 +100,9 @@
"loadingInvokeAI": "Loading Invoke AI",
"random": "Random",
"generate": "Generate",
"openInNewTab": "Open in New Tab"
"openInNewTab": "Open in New Tab",
"dontAskMeAgain": "Don't ask me again",
"areYouSure": "Are you sure?"
},
"gallery": {
"generations": "Generations",
@ -116,7 +118,10 @@
"pinGallery": "Pin Gallery",
"allImagesLoaded": "All Images Loaded",
"loadMore": "Load More",
"noImagesInGallery": "No Images In Gallery"
"noImagesInGallery": "No Images In Gallery",
"deleteImage": "Delete Image",
"deleteImageBin": "Deleted images will be sent to your operating system's Bin.",
"deleteImagePermanent": "Deleted images cannot be restored."
},
"hotkeys": {
"keyboardShortcuts": "Keyboard Shortcuts",
@ -508,7 +513,6 @@
"useAll": "Use All",
"useInitImg": "Use Initial Image",
"info": "Info",
"deleteImage": "Delete Image",
"initialImage": "Initial Image",
"showOptionsPanel": "Show Options Panel",
"hidePreview": "Hide Preview",

View File

@ -375,6 +375,7 @@ export declare type AppConfig = {
shouldFetchImages: boolean;
disabledTabs: InvokeTabName[];
disabledFeatures: AppFeature[];
canRestoreDeletedImagesFromBin: boolean;
sd: {
iterations: {
initial: number;

View File

@ -7,6 +7,7 @@ import {
FlexProps,
FormControl,
Link,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { runESRGAN, runFacetool } from 'app/socketio/actions';
@ -66,6 +67,7 @@ import useSetBothPrompts from 'features/parameters/hooks/usePrompt';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { useGetUrl } from 'common/util/getUrl';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { imageDeleted } from 'services/thunks/image';
const currentImageButtonsSelector = createSelector(
[
@ -77,17 +79,14 @@ const currentImageButtonsSelector = createSelector(
activeTabNameSelector,
selectedImageSelector,
],
(
system,
gallery,
postprocessing,
ui,
lightbox,
activeTabName,
selectedImage
) => {
const { isProcessing, isConnected, isGFPGANAvailable, isESRGANAvailable } =
system;
(system, gallery, postprocessing, ui, lightbox, activeTabName, image) => {
const {
isProcessing,
isConnected,
isGFPGANAvailable,
isESRGANAvailable,
shouldConfirmOnDelete,
} = system;
const { upscalingLevel, facetoolStrength } = postprocessing;
@ -98,6 +97,8 @@ const currentImageButtonsSelector = createSelector(
const { intermediateImage, currentImage } = gallery;
return {
canDeleteImage: isConnected && !isProcessing,
shouldConfirmOnDelete,
isProcessing,
isConnected,
isGFPGANAvailable,
@ -110,7 +111,7 @@ const currentImageButtonsSelector = createSelector(
activeTabName,
isLightboxOpen,
shouldHidePreview,
selectedImage,
image,
};
},
{
@ -141,7 +142,9 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
isLightboxOpen,
activeTabName,
shouldHidePreview,
selectedImage,
image,
canDeleteImage,
shouldConfirmOnDelete,
} = useAppSelector(currentImageButtonsSelector);
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
@ -150,25 +153,31 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const { getUrl, shouldTransformUrls } = useGetUrl();
const {
isOpen: isDeleteDialogOpen,
onOpen: onDeleteDialogOpen,
onClose: onDeleteDialogClose,
} = useDisclosure();
const toast = useToast();
const { t } = useTranslation();
const setBothPrompts = useSetBothPrompts();
const handleClickUseAsInitialImage = () => {
if (!selectedImage) return;
if (!image) return;
if (isLightboxOpen) dispatch(setIsLightboxOpen(false));
dispatch(initialImageSelected(selectedImage.name));
dispatch(initialImageSelected(image.name));
// dispatch(setInitialImage(currentImage));
// dispatch(setActiveTab('img2img'));
};
const handleCopyImage = async () => {
if (!selectedImage?.url) {
if (!image?.url) {
return;
}
const url = getUrl(selectedImage.url);
const url = getUrl(image.url);
if (!url) {
return;
@ -188,10 +197,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
};
const handleCopyImageLink = () => {
const url = selectedImage
const url = image
? shouldTransformUrls
? getUrl(selectedImage.url)
: window.location.toString() + selectedImage.url
? getUrl(image.url)
: window.location.toString() + image.url
: '';
if (!url) {
@ -211,7 +220,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
useHotkeys(
'shift+i',
() => {
if (selectedImage) {
if (image) {
handleClickUseAsInitialImage();
toast({
title: t('toast.sentToImageToImage'),
@ -229,7 +238,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
});
}
},
[selectedImage]
[image]
);
const handlePreviewVisibility = () => {
@ -237,7 +246,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
};
const handleClickUseAllParameters = () => {
if (!selectedImage) return;
if (!image) return;
// selectedImage.metadata &&
// dispatch(setAllParameters(selectedImage.metadata));
// if (selectedImage.metadata?.image.type === 'img2img') {
@ -250,11 +259,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
useHotkeys(
'a',
() => {
if (
['txt2img', 'img2img'].includes(
selectedImage?.metadata?.sd_metadata?.type
)
) {
if (['txt2img', 'img2img'].includes(image?.metadata?.sd_metadata?.type)) {
handleClickUseAllParameters();
toast({
title: t('toast.parametersSet'),
@ -272,18 +277,17 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
});
}
},
[selectedImage]
[image]
);
const handleClickUseSeed = () => {
selectedImage?.metadata &&
dispatch(setSeed(selectedImage.metadata.sd_metadata.seed));
image?.metadata && dispatch(setSeed(image.metadata.sd_metadata.seed));
};
useHotkeys(
's',
() => {
if (selectedImage?.metadata?.sd_metadata?.seed) {
if (image?.metadata?.sd_metadata?.seed) {
handleClickUseSeed();
toast({
title: t('toast.seedSet'),
@ -301,19 +305,19 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
});
}
},
[selectedImage]
[image]
);
const handleClickUsePrompt = useCallback(() => {
if (selectedImage?.metadata?.sd_metadata?.prompt) {
setBothPrompts(selectedImage?.metadata?.sd_metadata?.prompt);
if (image?.metadata?.sd_metadata?.prompt) {
setBothPrompts(image?.metadata?.sd_metadata?.prompt);
}
}, [selectedImage?.metadata?.sd_metadata?.prompt, setBothPrompts]);
}, [image?.metadata?.sd_metadata?.prompt, setBothPrompts]);
useHotkeys(
'p',
() => {
if (selectedImage?.metadata?.sd_metadata?.prompt) {
if (image?.metadata?.sd_metadata?.prompt) {
handleClickUsePrompt();
toast({
title: t('toast.promptSet'),
@ -331,7 +335,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
});
}
},
[selectedImage]
[image]
);
const handleClickUpscale = () => {
@ -356,7 +360,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
},
[
isUpscalingEnabled,
selectedImage,
image,
isESRGANAvailable,
shouldDisableToolbarButtons,
isConnected,
@ -388,7 +392,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
[
isFaceRestoreEnabled,
selectedImage,
image,
isGFPGANAvailable,
shouldDisableToolbarButtons,
isConnected,
@ -401,7 +405,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
dispatch(setShouldShowImageDetails(!shouldShowImageDetails));
const handleSendToCanvas = () => {
if (!selectedImage) return;
if (!image) return;
if (isLightboxOpen) dispatch(setIsLightboxOpen(false));
// dispatch(setInitialCanvasImage(selectedImage));
@ -422,7 +426,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
useHotkeys(
'i',
() => {
if (selectedImage) {
if (image) {
handleClickShowImageDetails();
} else {
toast({
@ -433,226 +437,255 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
});
}
},
[selectedImage, shouldShowImageDetails]
[image, shouldShowImageDetails]
);
const handleInitiateDelete = () => {
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
};
const handleDelete = () => {
if (canDeleteImage && image) {
dispatch(imageDeleted({ imageType: image.type, imageName: image.name }));
}
};
useHotkeys('delete', handleInitiateDelete, [
image,
shouldConfirmOnDelete,
isConnected,
isProcessing,
]);
const handleLightBox = () => {
dispatch(setIsLightboxOpen(!isLightboxOpen));
};
return (
<Flex
sx={{
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: 2,
}}
{...props}
>
<ButtonGroup isAttached={true}>
<IAIPopover
triggerComponent={
<IAIIconButton
isDisabled={!selectedImage}
aria-label={`${t('parameters.sendTo')}...`}
icon={<FaShareAlt />}
/>
}
>
<Flex
sx={{
flexDirection: 'column',
rowGap: 2,
}}
<>
<Flex
sx={{
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: 2,
}}
{...props}
>
<ButtonGroup isAttached={true}>
<IAIPopover
triggerComponent={
<IAIIconButton
isDisabled={!image}
aria-label={`${t('parameters.sendTo')}...`}
icon={<FaShareAlt />}
/>
}
>
<IAIButton
size="sm"
onClick={handleClickUseAsInitialImage}
leftIcon={<FaShare />}
<Flex
sx={{
flexDirection: 'column',
rowGap: 2,
}}
>
{t('parameters.sendToImg2Img')}
</IAIButton>
<IAIButton
size="sm"
onClick={handleSendToCanvas}
leftIcon={<FaShare />}
>
{t('parameters.sendToUnifiedCanvas')}
</IAIButton>
<IAIButton
size="sm"
onClick={handleCopyImage}
leftIcon={<FaCopy />}
>
{t('parameters.copyImage')}
</IAIButton>
<IAIButton
size="sm"
onClick={handleCopyImageLink}
leftIcon={<FaCopy />}
>
{t('parameters.copyImageToLink')}
</IAIButton>
<Link download={true} href={getUrl(selectedImage?.url ?? '')}>
<IAIButton leftIcon={<FaDownload />} size="sm" w="100%">
{t('parameters.downloadImage')}
<IAIButton
size="sm"
onClick={handleClickUseAsInitialImage}
leftIcon={<FaShare />}
>
{t('parameters.sendToImg2Img')}
</IAIButton>
</Link>
</Flex>
</IAIPopover>
<IAIIconButton
icon={shouldHidePreview ? <FaEyeSlash /> : <FaEye />}
tooltip={
!shouldHidePreview
? t('parameters.hidePreview')
: t('parameters.showPreview')
}
aria-label={
!shouldHidePreview
? t('parameters.hidePreview')
: t('parameters.showPreview')
}
isChecked={shouldHidePreview}
onClick={handlePreviewVisibility}
/>
{isLightboxEnabled && (
<IAIButton
size="sm"
onClick={handleSendToCanvas}
leftIcon={<FaShare />}
>
{t('parameters.sendToUnifiedCanvas')}
</IAIButton>
<IAIButton
size="sm"
onClick={handleCopyImage}
leftIcon={<FaCopy />}
>
{t('parameters.copyImage')}
</IAIButton>
<IAIButton
size="sm"
onClick={handleCopyImageLink}
leftIcon={<FaCopy />}
>
{t('parameters.copyImageToLink')}
</IAIButton>
<Link download={true} href={getUrl(image?.url ?? '')}>
<IAIButton leftIcon={<FaDownload />} size="sm" w="100%">
{t('parameters.downloadImage')}
</IAIButton>
</Link>
</Flex>
</IAIPopover>
<IAIIconButton
icon={<FaExpand />}
icon={shouldHidePreview ? <FaEyeSlash /> : <FaEye />}
tooltip={
!isLightboxOpen
? `${t('parameters.openInViewer')} (Z)`
: `${t('parameters.closeViewer')} (Z)`
!shouldHidePreview
? t('parameters.hidePreview')
: t('parameters.showPreview')
}
aria-label={
!isLightboxOpen
? `${t('parameters.openInViewer')} (Z)`
: `${t('parameters.closeViewer')} (Z)`
!shouldHidePreview
? t('parameters.hidePreview')
: t('parameters.showPreview')
}
isChecked={isLightboxOpen}
onClick={handleLightBox}
isChecked={shouldHidePreview}
onClick={handlePreviewVisibility}
/>
)}
</ButtonGroup>
<ButtonGroup isAttached={true}>
<IAIIconButton
icon={<FaQuoteRight />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!selectedImage?.metadata?.sd_metadata?.prompt}
onClick={handleClickUsePrompt}
/>
<IAIIconButton
icon={<FaSeedling />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!selectedImage?.metadata?.sd_metadata?.seed}
onClick={handleClickUseSeed}
/>
<IAIIconButton
icon={<FaAsterisk />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={
!['txt2img', 'img2img'].includes(
selectedImage?.metadata?.sd_metadata?.type
)
}
onClick={handleClickUseAllParameters}
/>
</ButtonGroup>
{(isUpscalingEnabled || isFaceRestoreEnabled) && (
<ButtonGroup isAttached={true}>
{isFaceRestoreEnabled && (
<IAIPopover
triggerComponent={
<IAIIconButton
icon={<FaGrinStars />}
aria-label={t('parameters.restoreFaces')}
/>
{isLightboxEnabled && (
<IAIIconButton
icon={<FaExpand />}
tooltip={
!isLightboxOpen
? `${t('parameters.openInViewer')} (Z)`
: `${t('parameters.closeViewer')} (Z)`
}
>
<Flex
sx={{
flexDirection: 'column',
rowGap: 4,
}}
>
<FaceRestoreSettings />
<IAIButton
isDisabled={
!isGFPGANAvailable ||
!selectedImage ||
!(isConnected && !isProcessing) ||
!facetoolStrength
}
onClick={handleClickFixFaces}
>
{t('parameters.restoreFaces')}
</IAIButton>
</Flex>
</IAIPopover>
)}
{isUpscalingEnabled && (
<IAIPopover
triggerComponent={
<IAIIconButton
icon={<FaExpandArrowsAlt />}
aria-label={t('parameters.upscale')}
/>
aria-label={
!isLightboxOpen
? `${t('parameters.openInViewer')} (Z)`
: `${t('parameters.closeViewer')} (Z)`
}
>
<Flex
sx={{
flexDirection: 'column',
gap: 4,
}}
>
<UpscaleSettings />
<IAIButton
isDisabled={
!isESRGANAvailable ||
!selectedImage ||
!(isConnected && !isProcessing) ||
!upscalingLevel
}
onClick={handleClickUpscale}
>
{t('parameters.upscaleImage')}
</IAIButton>
</Flex>
</IAIPopover>
isChecked={isLightboxOpen}
onClick={handleLightBox}
/>
)}
</ButtonGroup>
)}
<ButtonGroup isAttached={true}>
<IAIIconButton
icon={<FaCode />}
tooltip={`${t('parameters.info')} (I)`}
aria-label={`${t('parameters.info')} (I)`}
isChecked={shouldShowImageDetails}
onClick={handleClickShowImageDetails}
/>
</ButtonGroup>
<ButtonGroup isAttached={true}>
<IAIIconButton
icon={<FaQuoteRight />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!image?.metadata?.sd_metadata?.prompt}
onClick={handleClickUsePrompt}
/>
<IAIIconButton
icon={<FaSeedling />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!image?.metadata?.sd_metadata?.seed}
onClick={handleClickUseSeed}
/>
<IAIIconButton
icon={<FaAsterisk />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={
!['txt2img', 'img2img'].includes(
image?.metadata?.sd_metadata?.type
)
}
onClick={handleClickUseAllParameters}
/>
</ButtonGroup>
{(isUpscalingEnabled || isFaceRestoreEnabled) && (
<ButtonGroup isAttached={true}>
{isFaceRestoreEnabled && (
<IAIPopover
triggerComponent={
<IAIIconButton
icon={<FaGrinStars />}
aria-label={t('parameters.restoreFaces')}
/>
}
>
<Flex
sx={{
flexDirection: 'column',
rowGap: 4,
}}
>
<FaceRestoreSettings />
<IAIButton
isDisabled={
!isGFPGANAvailable ||
!image ||
!(isConnected && !isProcessing) ||
!facetoolStrength
}
onClick={handleClickFixFaces}
>
{t('parameters.restoreFaces')}
</IAIButton>
</Flex>
</IAIPopover>
)}
{isUpscalingEnabled && (
<IAIPopover
triggerComponent={
<IAIIconButton
icon={<FaExpandArrowsAlt />}
aria-label={t('parameters.upscale')}
/>
}
>
<Flex
sx={{
flexDirection: 'column',
gap: 4,
}}
>
<UpscaleSettings />
<IAIButton
isDisabled={
!isESRGANAvailable ||
!image ||
!(isConnected && !isProcessing) ||
!upscalingLevel
}
onClick={handleClickUpscale}
>
{t('parameters.upscaleImage')}
</IAIButton>
</Flex>
</IAIPopover>
)}
</ButtonGroup>
)}
<ButtonGroup isAttached={true}>
<IAIIconButton
icon={<FaCode />}
tooltip={`${t('parameters.info')} (I)`}
aria-label={`${t('parameters.info')} (I)`}
isChecked={shouldShowImageDetails}
onClick={handleClickShowImageDetails}
/>
</ButtonGroup>
<DeleteImageModal image={selectedImage}>
<IAIIconButton
onClick={handleInitiateDelete}
icon={<FaTrash />}
tooltip={`${t('parameters.deleteImage')} (Del)`}
aria-label={`${t('parameters.deleteImage')} (Del)`}
isDisabled={!selectedImage || !isConnected}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={!image || !isConnected}
colorScheme="error"
/>
</DeleteImageModal>
</Flex>
</Flex>
{image && (
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
)}
</>
);
};

View File

@ -5,39 +5,27 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
forwardRef,
Flex,
Text,
useDisclosure,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/invokeai';
import { deleteImage } from 'app/socketio/actions';
import { useAppDispatch, useAppSelector } from 'app/storeHooks';
import IAIButton from 'common/components/IAIButton';
import IAISwitch from 'common/components/IAISwitch';
import { configSelector } from 'features/system/store/configSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import {
setShouldConfirmOnDelete,
SystemState,
} from 'features/system/store/systemSlice';
import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { isEqual } from 'lodash';
import {
ChangeEvent,
cloneElement,
ReactElement,
SyntheticEvent,
useRef,
} from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { imageDeleted } from 'services/thunks/image';
import { ChangeEvent, memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
const deleteImageModalSelector = createSelector(
systemSelector,
(system: SystemState) => {
const { shouldConfirmOnDelete, isConnected, isProcessing } = system;
return { shouldConfirmOnDelete, isConnected, isProcessing };
const selector = createSelector(
[systemSelector, configSelector],
(system, config) => {
const { shouldConfirmOnDelete } = system;
const { canRestoreDeletedImagesFromBin } = config;
return { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin };
},
{
memoizeOptions: {
@ -45,106 +33,77 @@ const deleteImageModalSelector = createSelector(
},
}
);
interface DeleteImageModalProps {
/**
* Component which, on click, should delete the image/open the modal.
*/
children: ReactElement;
/**
* The image to delete.
*/
image?: InvokeAI.Image;
isOpen: boolean;
onClose: () => void;
handleDelete: () => void;
}
/**
* Needs a child, which will act as the button to delete an image.
* If system.shouldConfirmOnDelete is true, a confirmation modal is displayed.
* If it is false, the image is deleted immediately.
* The confirmation modal has a "Don't ask me again" switch to set the boolean.
*/
const DeleteImageModal = forwardRef(
({ image, children }: DeleteImageModalProps, ref) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const { shouldConfirmOnDelete, isConnected, isProcessing } = useAppSelector(
deleteImageModalSelector
);
const cancelRef = useRef<HTMLButtonElement>(null);
const DeleteImageModal = ({
isOpen,
onClose,
handleDelete,
}: DeleteImageModalProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin } =
useAppSelector(selector);
const cancelRef = useRef<HTMLButtonElement>(null);
const handleClickDelete = (e: SyntheticEvent) => {
e.stopPropagation();
shouldConfirmOnDelete ? onOpen() : handleDelete();
};
const handleChangeShouldConfirmOnDelete = useCallback(
(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldConfirmOnDelete(!e.target.checked)),
[dispatch]
);
const handleDelete = () => {
if (isConnected && !isProcessing && image) {
dispatch(
imageDeleted({ imageType: image.type, imageName: image.name })
);
}
onClose();
};
const handleClickDelete = useCallback(() => {
handleDelete();
onClose();
}, [handleDelete, onClose]);
useHotkeys(
'delete',
() => {
shouldConfirmOnDelete ? onOpen() : handleDelete();
},
[image, shouldConfirmOnDelete, isConnected, isProcessing]
);
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('gallery.deleteImage')}
</AlertDialogHeader>
const handleChangeShouldConfirmOnDelete = (
e: ChangeEvent<HTMLInputElement>
) => dispatch(setShouldConfirmOnDelete(!e.target.checked));
<AlertDialogBody>
<Flex direction="column" gap={5}>
<Flex direction="column" gap={2}>
<Text>{t('common.areYouSure')}</Text>
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
</Flex>
<IAISwitch
label={t('common.dontAskMeAgain')}
isChecked={!shouldConfirmOnDelete}
onChange={handleChangeShouldConfirmOnDelete}
/>
</Flex>
</AlertDialogBody>
<AlertDialogFooter>
<IAIButton ref={cancelRef} onClick={onClose}>
Cancel
</IAIButton>
<IAIButton colorScheme="error" onClick={handleClickDelete} ml={3}>
Delete
</IAIButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};
return (
<>
{cloneElement(children, {
// TODO: This feels wrong.
onClick: image ? handleClickDelete : undefined,
ref: ref,
})}
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete image
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={5}>
<Text>
Are you sure? Deleted images will be sent to the Bin. You
can restore from there if you wish to.
</Text>
<IAISwitch
label="Don't ask me again"
isChecked={!shouldConfirmOnDelete}
onChange={handleChangeShouldConfirmOnDelete}
/>
</Flex>
</AlertDialogBody>
<AlertDialogFooter>
<IAIButton ref={cancelRef} onClick={onClose}>
Cancel
</IAIButton>
<IAIButton colorScheme="error" onClick={handleDelete} ml={3}>
Delete
</IAIButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
}
);
DeleteImageModal.displayName = 'DeleteImageModal';
export default DeleteImageModal;
export default memo(DeleteImageModal);

View File

@ -6,6 +6,7 @@ import {
MenuItem,
MenuList,
Text,
useDisclosure,
useTheme,
useToast,
} from '@chakra-ui/react';
@ -36,7 +37,7 @@ import {
resizeAndScaleCanvas,
setInitialCanvasImage,
} from 'features/canvas/store/canvasSlice';
import { hoverableImageSelector } from 'features/gallery/store/gallerySelectors';
import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useTranslation } from 'react-i18next';
import useSetBothPrompts from 'features/parameters/hooks/usePrompt';
@ -46,6 +47,50 @@ import { useGetUrl } from 'common/util/getUrl';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { BiZoomIn } from 'react-icons/bi';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { imageDeleted } from 'services/thunks/image';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
import { configSelector } from 'features/system/store/configSelectors';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash';
export const selector = createSelector(
[
gallerySelector,
systemSelector,
configSelector,
lightboxSelector,
activeTabNameSelector,
],
(gallery, system, config, lightbox, activeTabName) => {
const {
galleryImageObjectFit,
galleryImageMinimumWidth,
shouldUseSingleGalleryColumn,
} = gallery;
const { isLightboxOpen } = lightbox;
const { disabledFeatures } = config;
const { isConnected, isProcessing, shouldConfirmOnDelete } = system;
return {
canDeleteImage: isConnected && !isProcessing,
shouldConfirmOnDelete,
galleryImageObjectFit,
galleryImageMinimumWidth,
shouldUseSingleGalleryColumn,
activeTabName,
isLightboxOpen,
disabledFeatures,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
interface HoverableImageProps {
image: InvokeAI.Image;
@ -66,10 +111,16 @@ const HoverableImage = memo((props: HoverableImageProps) => {
activeTabName,
galleryImageObjectFit,
galleryImageMinimumWidth,
mayDeleteImage,
canDeleteImage,
shouldUseSingleGalleryColumn,
disabledFeatures,
} = useAppSelector(hoverableImageSelector);
shouldConfirmOnDelete,
} = useAppSelector(selector);
const {
isOpen: isDeleteDialogOpen,
onOpen: onDeleteDialogOpen,
onClose: onDeleteDialogClose,
} = useDisclosure();
const { image, isSelected } = props;
const { url, thumbnail, name, metadata } = image;
const { getUrl } = useGetUrl();
@ -85,6 +136,20 @@ const HoverableImage = memo((props: HoverableImageProps) => {
const handleMouseOut = () => setIsHovered(false);
const handleInitiateDelete = () => {
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
};
const handleDelete = () => {
if (canDeleteImage && image) {
dispatch(imageDeleted({ imageType: image.type, imageName: image.name }));
}
};
const handleUsePrompt = () => {
if (image.metadata?.sd_metadata?.prompt) {
setBothPrompts(image.metadata?.sd_metadata?.prompt);
@ -184,159 +249,167 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
return (
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList>
<MenuItem
icon={<ExternalLinkIcon />}
onClickCapture={handleOpenInNewTab}
>
{t('common.openInNewTab')}
</MenuItem>
{!disabledFeatures.includes('lightbox') && (
<MenuItem icon={<FaExpand />} onClickCapture={handleLightBox}>
{t('parameters.openInViewer')}
<>
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList>
<MenuItem
icon={<ExternalLinkIcon />}
onClickCapture={handleOpenInNewTab}
>
{t('common.openInNewTab')}
</MenuItem>
)}
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUsePrompt}
isDisabled={image?.metadata?.sd_metadata?.prompt === undefined}
>
{t('parameters.usePrompt')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseSeed}
isDisabled={image?.metadata?.sd_metadata?.seed === undefined}
>
{t('parameters.useSeed')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseInitialImage}
isDisabled={image?.metadata?.sd_metadata?.type !== 'img2img'}
>
{t('parameters.useInitImg')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseAllParameters}
isDisabled={
!['txt2img', 'img2img'].includes(
image?.metadata?.sd_metadata?.type
)
}
>
{t('parameters.useAll')}
</MenuItem>
<MenuItem
icon={<FaShare />}
onClickCapture={handleSendToImageToImage}
>
{t('parameters.sendToImg2Img')}
</MenuItem>
<MenuItem icon={<FaShare />} onClickCapture={handleSendToCanvas}>
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
<MenuItem icon={<FaTrash />}>
<DeleteImageModal image={image}>
<Text>{t('parameters.deleteImage')}</Text>
</DeleteImageModal>
</MenuItem>
</MenuList>
)}
>
{(ref) => (
<Box
position="relative"
key={name}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
userSelect="none"
draggable={true}
onDragStart={handleDragStart}
ref={ref}
sx={{
padding: 2,
display: 'flex',
justifyContent: 'center',
transition: 'transform 0.2s ease-out',
_hover: {
cursor: 'pointer',
zIndex: 2,
},
_before: { content: '""', display: 'block', paddingBottom: '100%' },
}}
>
<Image
objectFit={
shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
}
rounded="md"
src={getUrl(thumbnail || url)}
loading="lazy"
sx={{
position: 'absolute',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
top: '50%',
transform: 'translate(-50%,-50%)',
...(direction === 'rtl'
? { insetInlineEnd: '50%' }
: { insetInlineStart: '50%' }),
}}
/>
<Flex
onClick={handleSelectImage}
sx={{
position: 'absolute',
top: '0',
insetInlineStart: '0',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isSelected && (
<Icon
as={FaCheck}
sx={{
width: '50%',
height: '50%',
fill: 'ok.500',
}}
/>
{!disabledFeatures.includes('lightbox') && (
<MenuItem icon={<FaExpand />} onClickCapture={handleLightBox}>
{t('parameters.openInViewer')}
</MenuItem>
)}
</Flex>
{isHovered && galleryImageMinimumWidth >= 64 && (
<Box
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUsePrompt}
isDisabled={image?.metadata?.sd_metadata?.prompt === undefined}
>
{t('parameters.usePrompt')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseSeed}
isDisabled={image?.metadata?.sd_metadata?.seed === undefined}
>
{t('parameters.useSeed')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseInitialImage}
isDisabled={image?.metadata?.sd_metadata?.type !== 'img2img'}
>
{t('parameters.useInitImg')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseAllParameters}
isDisabled={
!['txt2img', 'img2img'].includes(
image?.metadata?.sd_metadata?.type
)
}
>
{t('parameters.useAll')}
</MenuItem>
<MenuItem
icon={<FaShare />}
onClickCapture={handleSendToImageToImage}
>
{t('parameters.sendToImg2Img')}
</MenuItem>
<MenuItem icon={<FaShare />} onClickCapture={handleSendToCanvas}>
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
<MenuItem icon={<FaTrash />} onClickCapture={onDeleteDialogOpen}>
{t('gallery.deleteImage')}
</MenuItem>
</MenuList>
)}
>
{(ref) => (
<Box
position="relative"
key={name}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
userSelect="none"
draggable={true}
onDragStart={handleDragStart}
ref={ref}
sx={{
padding: 2,
display: 'flex',
justifyContent: 'center',
transition: 'transform 0.2s ease-out',
_hover: {
cursor: 'pointer',
zIndex: 2,
},
_before: {
content: '""',
display: 'block',
paddingBottom: '100%',
},
}}
>
<Image
objectFit={
shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
}
rounded="md"
src={getUrl(thumbnail || url)}
loading="lazy"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
top: '50%',
transform: 'translate(-50%,-50%)',
...(direction === 'rtl'
? { insetInlineEnd: '50%' }
: { insetInlineStart: '50%' }),
}}
/>
<Flex
onClick={handleSelectImage}
sx={{
position: 'absolute',
top: '0',
insetInlineStart: '0',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DeleteImageModal image={image}>
{isSelected && (
<Icon
as={FaCheck}
sx={{
width: '50%',
height: '50%',
fill: 'ok.500',
}}
/>
)}
</Flex>
{isHovered && galleryImageMinimumWidth >= 64 && (
<Box
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
}}
>
<IAIIconButton
aria-label={t('parameters.deleteImage')}
onClickCapture={handleInitiateDelete}
aria-label={t('gallery.deleteImage')}
icon={<FaTrash />}
size="xs"
fontSize={14}
isDisabled={!mayDeleteImage}
isDisabled={!canDeleteImage}
/>
</DeleteImageModal>
</Box>
)}
</Box>
)}
</ContextMenu>
</Box>
)}
</Box>
)}
</ContextMenu>
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
</>
);
}, memoEqualityCheck);

View File

@ -68,32 +68,6 @@ export const imageGallerySelector = createSelector(
}
);
export const hoverableImageSelector = createSelector(
[
gallerySelector,
systemSelector,
configSelector,
lightboxSelector,
activeTabNameSelector,
],
(gallery, system, config, lightbox, activeTabName) => {
return {
mayDeleteImage: system.isConnected && !system.isProcessing,
galleryImageObjectFit: gallery.galleryImageObjectFit,
galleryImageMinimumWidth: gallery.galleryImageMinimumWidth,
shouldUseSingleGalleryColumn: gallery.shouldUseSingleGalleryColumn,
activeTabName,
isLightboxOpen: lightbox.isLightboxOpen,
disabledFeatures: config.disabledFeatures,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
export const selectedImageSelector = createSelector(
[gallerySelector, selectResultsEntities, selectUploadsEntities],
(gallery, allResults, allUploads) => {

View File

@ -8,6 +8,7 @@ const initialConfigState: AppConfig = {
shouldFetchImages: false,
disabledTabs: [],
disabledFeatures: [],
canRestoreDeletedImagesFromBin: true,
sd: {
iterations: {
initial: 1,