From 3d249c4fa3bebafe744c4e9e52b3159bc3450d5a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 6 Jun 2023 00:40:40 +1000 Subject: [PATCH] feat(ui): refactor image deletion Add `DeleteImageContext`: - provide a single function to delete an image - opens the modal or immediately deletes, if confirm is off --- .../frontend/web/src/app/components/App.tsx | 2 + .../web/src/app/components/InvokeAIUI.tsx | 16 ++- .../src/app/contexts/DeleteImageContext.tsx | 107 ++++++++++++++++++ .../components/CurrentImageButtons.tsx | 61 ++-------- .../components/CurrentImagePreview.tsx | 3 + .../{DeleteImageModal.tsx => DeleteModal.tsx} | 59 +++++++--- .../gallery/components/HoverableImage.tsx | 66 +++-------- .../ImageActionButtons/DeleteImageButton.tsx | 92 --------------- .../ImageToImage/InitialImagePreview.tsx | 1 + 9 files changed, 191 insertions(+), 216 deletions(-) create mode 100644 invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx rename invokeai/frontend/web/src/features/gallery/components/{DeleteImageModal.tsx => DeleteModal.tsx} (70%) delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageActionButtons/DeleteImageButton.tsx diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 21b3945490..67d0091261 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -21,6 +21,7 @@ import { ReactNode, memo, useCallback, useEffect, useState } from 'react'; import { APP_HEIGHT, APP_WIDTH } from 'theme/util/constants'; import GlobalHotkeys from './GlobalHotkeys'; import Toaster from './Toaster'; +import DeleteModal from 'features/gallery/components/DeleteModal'; const DEFAULT_CONFIG = {}; @@ -133,6 +134,7 @@ const App = ({ + diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index c94f7624b2..0537d1de2a 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -17,6 +17,10 @@ import '../../i18n'; import { socketMiddleware } from 'services/events/middleware'; import { Middleware } from '@reduxjs/toolkit'; import ImageDndContext from './ImageDnd/ImageDndContext'; +import { + DeleteImageContext, + DeleteImageContextProvider, +} from 'app/contexts/DeleteImageContext'; const App = lazy(() => import('./App')); const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); @@ -71,11 +75,13 @@ const InvokeAIUI = ({ }> - + + + diff --git a/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx new file mode 100644 index 0000000000..1d129d4e00 --- /dev/null +++ b/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx @@ -0,0 +1,107 @@ +import { useDisclosure } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { requestedImageDeletion } from 'features/gallery/store/actions'; +import { systemSelector } from 'features/system/store/systemSelectors'; +import { PropsWithChildren, createContext, useCallback, useState } from 'react'; +import { ImageDTO } from 'services/api'; + +type DeleteImageContextValue = { + /** + * Whether the delete image dialog is open. + */ + isOpen: boolean; + /** + * Closes the delete image dialog. + */ + onClose: () => void; + /** + * Immediately deletes an image. + * + * You probably don't want to use this - use `onDelete` instead. + */ + onImmediatelyDelete: () => void; + /** + * Opens the delete image dialog and handles all deletion-related checks. + */ + onDelete: (image?: ImageDTO) => void; +}; + +export const DeleteImageContext = createContext({ + isOpen: false, + onClose: () => undefined, + onImmediatelyDelete: () => undefined, + onDelete: () => undefined, +}); + +const selector = createSelector( + [systemSelector], + (system) => { + const { isProcessing, isConnected, shouldConfirmOnDelete } = system; + + return { + canDeleteImage: isConnected && !isProcessing, + shouldConfirmOnDelete, + isProcessing, + isConnected, + }; + }, + defaultSelectorOptions +); + +type Props = PropsWithChildren; + +export const DeleteImageContextProvider = (props: Props) => { + const { canDeleteImage, shouldConfirmOnDelete } = useAppSelector(selector); + const [imageToDelete, setImageToDelete] = useState(); + const dispatch = useAppDispatch(); + const { isOpen, onOpen, onClose } = useDisclosure(); + + const closeAndClearImageToDelete = useCallback(() => { + setImageToDelete(undefined); + onClose(); + }, [onClose]); + + const onImmediatelyDelete = useCallback(() => { + if (canDeleteImage && imageToDelete) { + dispatch(requestedImageDeletion(imageToDelete)); + } + closeAndClearImageToDelete(); + }, [canDeleteImage, imageToDelete, closeAndClearImageToDelete, dispatch]); + + const handleDelete = useCallback( + (image: ImageDTO) => { + if (shouldConfirmOnDelete) { + onOpen(); + } else { + dispatch(requestedImageDeletion(image)); + } + }, + [shouldConfirmOnDelete, onOpen, dispatch] + ); + + const onDelete = useCallback( + (image?: ImageDTO) => { + if (!image) { + return; + } + setImageToDelete(image); + handleDelete(image); + }, + [handleDelete] + ); + + return ( + + {props.children} + + ); +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx index 6862b35fb8..333ad516ef 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx @@ -1,13 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { isEqual } from 'lodash-es'; -import { - ButtonGroup, - Flex, - FlexProps, - Link, - useDisclosure, -} from '@chakra-ui/react'; +import { ButtonGroup, Flex, FlexProps, Link } from '@chakra-ui/react'; // import { runESRGAN, runFacetool } from 'app/socketio/actions'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIButton from 'common/components/IAIButton'; @@ -45,21 +39,18 @@ import { FaShareAlt, } from 'react-icons/fa'; import { gallerySelector } from '../store/gallerySelectors'; -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; -import { - requestedImageDeletion, - sentImageToCanvas, - sentImageToImg2Img, -} from '../store/actions'; +import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceRestore/FaceRestoreSettings'; import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings'; -import DeleteImageButton from './ImageActionButtons/DeleteImageButton'; import { useAppToaster } from 'app/components/Toaster'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; +import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; +import { DeleteImageButton } from './DeleteModal'; const currentImageButtonsSelector = createSelector( [ @@ -122,10 +113,6 @@ const currentImageButtonsSelector = createSelector( type CurrentImageButtonsProps = FlexProps; -/** - * Row of buttons for common actions: - * Use as init image, use all params, use seed, upscale, fix faces, details, delete. - */ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const dispatch = useAppDispatch(); const { @@ -137,13 +124,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { facetoolStrength, shouldDisableToolbarButtons, shouldShowImageDetails, - // currentImage, isLightboxOpen, activeTabName, shouldHidePreview, image, - canDeleteImage, - shouldConfirmOnDelete, shouldShowProgressInViewer, } = useAppSelector(currentImageButtonsSelector); @@ -152,18 +136,14 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled; const isFaceRestoreEnabled = useFeatureStatus('faceRestore').isFeatureEnabled; - const { - isOpen: isDeleteDialogOpen, - onOpen: onDeleteDialogOpen, - onClose: onDeleteDialogClose, - } = useDisclosure(); - const toaster = useAppToaster(); const { t } = useTranslation(); const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); + const { onDelete } = useContext(DeleteImageContext); + // const handleCopyImage = useCallback(async () => { // if (!image?.url) { // return; @@ -262,6 +242,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { // selectedImage && dispatch(runESRGAN(selectedImage)); }, []); + const handleDelete = useCallback(() => { + onDelete(image); + }, [image, onDelete]); + useHotkeys( 'Shift+U', () => { @@ -363,31 +347,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { [image, shouldShowImageDetails, toaster] ); - const handleDelete = useCallback(() => { - if (canDeleteImage && image) { - dispatch(requestedImageDeletion(image)); - } - }, [image, canDeleteImage, dispatch]); - - const handleInitiateDelete = useCallback(() => { - if (shouldConfirmOnDelete) { - onDeleteDialogOpen(); - } else { - handleDelete(); - } - }, [shouldConfirmOnDelete, onDeleteDialogOpen, handleDelete]); - const handleClickProgressImagesToggle = useCallback(() => { dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer)); }, [dispatch, shouldShowProgressInViewer]); - useHotkeys('delete', handleInitiateDelete, [ - image, - shouldConfirmOnDelete, - isConnected, - isProcessing, - ]); - const handleLightBox = useCallback(() => { dispatch(setIsLightboxOpen(!isLightboxOpen)); }, [dispatch, isLightboxOpen]); @@ -596,7 +559,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { - + diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx index 5e210bf4b7..b8d9d6220a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx @@ -15,6 +15,7 @@ import { imageSelected } from '../store/gallerySlice'; import IAIDndImage from 'common/components/IAIDndImage'; import { ImageDTO } from 'services/api'; import { IAIImageFallback } from 'common/components/IAIImageFallback'; +import { useGetIsImageInUse } from 'common/hooks/useGetIsImageInUse'; export const imagesSelector = createSelector( [uiSelector, gallerySelector, systemSelector], @@ -54,6 +55,8 @@ const CurrentImagePreview = () => { const toaster = useAppToaster(); const dispatch = useAppDispatch(); + const isImageInUse = useGetIsImageInUse(image?.image_name); + console.log(isImageInUse); const handleError = useCallback(() => { dispatch(imageSelected()); if (shouldFetchImages) { diff --git a/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/gallery/components/DeleteModal.tsx similarity index 70% rename from invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx rename to invokeai/frontend/web/src/features/gallery/components/DeleteModal.tsx index 12038f4179..ca06aa7953 100644 --- a/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/DeleteModal.tsx @@ -9,16 +9,19 @@ import { Text, } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; +import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIButton from 'common/components/IAIButton'; +import IAIIconButton from 'common/components/IAIIconButton'; import IAISwitch from 'common/components/IAISwitch'; import { configSelector } from 'features/system/store/configSelectors'; import { systemSelector } from 'features/system/store/systemSelectors'; import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice'; import { isEqual } from 'lodash-es'; -import { ChangeEvent, memo, useCallback, useRef } from 'react'; +import { ChangeEvent, memo, useCallback, useContext, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { FaTrash } from 'react-icons/fa'; const selector = createSelector( [systemSelector, configSelector], @@ -34,22 +37,12 @@ const selector = createSelector( } ); -interface DeleteImageModalProps { - isOpen: boolean; - onClose: () => void; - handleDelete: () => void; -} - -const DeleteImageModal = ({ - isOpen, - onClose, - handleDelete, -}: DeleteImageModalProps) => { +const DeleteImageModal = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); + const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin } = useAppSelector(selector); - const cancelRef = useRef(null); const handleChangeShouldConfirmOnDelete = useCallback( (e: ChangeEvent) => @@ -57,10 +50,10 @@ const DeleteImageModal = ({ [dispatch] ); - const handleClickDelete = useCallback(() => { - handleDelete(); - onClose(); - }, [handleDelete, onClose]); + const { isOpen, onClose, onImmediatelyDelete } = + useContext(DeleteImageContext); + + const cancelRef = useRef(null); return ( Cancel - + Delete @@ -107,3 +100,33 @@ const DeleteImageModal = ({ }; export default memo(DeleteImageModal); + +const deleteImageButtonsSelector = createSelector( + [systemSelector], + (system) => { + const { isProcessing, isConnected } = system; + + return isConnected && !isProcessing; + } +); + +type DeleteImageButtonProps = { + onClick: () => void; +}; + +export const DeleteImageButton = (props: DeleteImageButtonProps) => { + const { onClick } = props; + const { t } = useTranslation(); + const canDeleteImage = useAppSelector(deleteImageButtonsSelector); + + return ( + } + tooltip={`${t('gallery.deleteImage')} (Del)`} + aria-label={`${t('gallery.deleteImage')} (Del)`} + isDisabled={!canDeleteImage} + colorScheme="error" + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx index ef4ed5be1c..2b8f72101d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx @@ -1,17 +1,8 @@ -import { - Box, - Flex, - Icon, - Image, - MenuItem, - MenuList, - useDisclosure, -} from '@chakra-ui/react'; +import { Box, Flex, Icon, Image, MenuItem, MenuList } from '@chakra-ui/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { DragEvent, MouseEvent, memo, useCallback, useState } from 'react'; +import { memo, useCallback, useContext, useState } from 'react'; import { FaCheck, FaExpand, FaImage, FaShare, FaTrash } from 'react-icons/fa'; -import DeleteImageModal from './DeleteImageModal'; import { ContextMenu } from 'chakra-ui-contextmenu'; import { resizeAndScaleCanvas, @@ -31,14 +22,11 @@ import { isEqual } from 'lodash-es'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; -import { - requestedImageDeletion, - sentImageToCanvas, - sentImageToImg2Img, -} from '../store/actions'; +import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; import { useAppToaster } from 'app/components/Toaster'; import { ImageDTO } from 'services/api'; import { useDraggable } from '@dnd-kit/core'; +import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; export const selector = createSelector( [gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector], @@ -92,27 +80,22 @@ const HoverableImage = memo((props: HoverableImageProps) => { galleryImageMinimumWidth, canDeleteImage, shouldUseSingleGalleryColumn, - shouldConfirmOnDelete, } = useAppSelector(selector); - const { - isOpen: isDeleteDialogOpen, - onOpen: onDeleteDialogOpen, - onClose: onDeleteDialogClose, - } = useDisclosure(); - const { image, isSelected } = props; const { image_url, thumbnail_url, image_name } = image; const [isHovered, setIsHovered] = useState(false); - const toaster = useAppToaster(); const { t } = useTranslation(); - const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled; + const { onDelete } = useContext(DeleteImageContext); + const handleDelete = useCallback(() => { + onDelete(image); + }, [image, onDelete]); const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); @@ -126,26 +109,6 @@ const HoverableImage = memo((props: HoverableImageProps) => { const handleMouseOver = () => setIsHovered(true); const handleMouseOut = () => setIsHovered(false); - // Immediately deletes an image - const handleDelete = useCallback(() => { - if (canDeleteImage && image) { - dispatch(requestedImageDeletion(image)); - } - }, [dispatch, image, canDeleteImage]); - - // Opens the alert dialog to check if user is sure they want to delete - const handleInitiateDelete = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - if (shouldConfirmOnDelete) { - onDeleteDialogOpen(); - } else { - handleDelete(); - } - }, - [handleDelete, onDeleteDialogOpen, shouldConfirmOnDelete] - ); - const handleSelectImage = useCallback(() => { dispatch(imageSelected(image)); }, [image, dispatch]); @@ -281,7 +244,11 @@ const HoverableImage = memo((props: HoverableImageProps) => { {t('parameters.sendToUnifiedCanvas')} )} - } onClickCapture={onDeleteDialogOpen}> + } + onClickCapture={handleDelete} + > {t('gallery.deleteImage')} @@ -357,7 +324,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { }} > } size="xs" @@ -369,11 +336,6 @@ const HoverableImage = memo((props: HoverableImageProps) => { )} - ); }, memoEqualityCheck); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageActionButtons/DeleteImageButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageActionButtons/DeleteImageButton.tsx deleted file mode 100644 index 4b0f6e60dd..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageActionButtons/DeleteImageButton.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; - -import { useDisclosure } from '@chakra-ui/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { systemSelector } from 'features/system/store/systemSelectors'; - -import { useHotkeys } from 'react-hotkeys-hook'; -import { useTranslation } from 'react-i18next'; -import { FaTrash } from 'react-icons/fa'; -import { memo, useCallback } from 'react'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import DeleteImageModal from '../DeleteImageModal'; -import { requestedImageDeletion } from 'features/gallery/store/actions'; -import { ImageDTO } from 'services/api'; - -const selector = createSelector( - [systemSelector], - (system) => { - const { isProcessing, isConnected, shouldConfirmOnDelete } = system; - - return { - canDeleteImage: isConnected && !isProcessing, - shouldConfirmOnDelete, - isProcessing, - isConnected, - }; - }, - defaultSelectorOptions -); - -type DeleteImageButtonProps = { - image: ImageDTO | undefined; -}; - -const DeleteImageButton = (props: DeleteImageButtonProps) => { - const { image } = props; - const dispatch = useAppDispatch(); - const { isProcessing, isConnected, canDeleteImage, shouldConfirmOnDelete } = - useAppSelector(selector); - - const { - isOpen: isDeleteDialogOpen, - onOpen: onDeleteDialogOpen, - onClose: onDeleteDialogClose, - } = useDisclosure(); - - const { t } = useTranslation(); - - const handleDelete = useCallback(() => { - if (canDeleteImage && image) { - dispatch(requestedImageDeletion(image)); - } - }, [image, canDeleteImage, dispatch]); - - const handleInitiateDelete = useCallback(() => { - if (shouldConfirmOnDelete) { - onDeleteDialogOpen(); - } else { - handleDelete(); - } - }, [shouldConfirmOnDelete, onDeleteDialogOpen, handleDelete]); - - useHotkeys('delete', handleInitiateDelete, [ - image, - shouldConfirmOnDelete, - isConnected, - isProcessing, - ]); - - return ( - <> - } - tooltip={`${t('gallery.deleteImage')} (Del)`} - aria-label={`${t('gallery.deleteImage')} (Del)`} - isDisabled={!image || !isConnected} - colorScheme="error" - /> - {image && ( - - )} - - ); -}; - -export default memo(DeleteImageButton); diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx index c006215256..73efb69728 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx @@ -14,6 +14,7 @@ import { useAppToaster } from 'app/components/Toaster'; import IAIDndImage from 'common/components/IAIDndImage'; import { ImageDTO } from 'services/api'; import { IAIImageFallback } from 'common/components/IAIImageFallback'; +import { useGetIsImageInUse } from 'common/hooks/useGetIsImageInUse'; const selector = createSelector( [generationSelector],