diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx index 2161fe879c..0019a0b570 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx @@ -643,15 +643,15 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { /> - {/* + } tooltip={`${t('parameters.deleteImage')} (Del)`} aria-label={`${t('parameters.deleteImage')} (Del)`} - isDisabled={!selectedImage || !isConnected || isProcessing} + isDisabled={!selectedImage || !isConnected} colorScheme="error" /> - */} + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx index a1276df6d9..bbcaf4ef13 100644 --- a/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx @@ -31,6 +31,7 @@ import { useRef, } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; +import { imageDeleted } from 'services/thunks/image'; const deleteImageModalSelector = createSelector( systemSelector, @@ -52,7 +53,7 @@ interface DeleteImageModalProps { /** * The image to delete. */ - image?: InvokeAI._Image; + image?: InvokeAI.Image; } /** @@ -77,7 +78,9 @@ const DeleteImageModal = forwardRef( const handleDelete = () => { if (isConnected && !isProcessing && image) { - dispatch(deleteImage(image)); + dispatch( + imageDeleted({ imageType: image.type, imageName: image.name }) + ); } onClose(); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx index 89432df6e3..f7d7a10b29 100644 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx @@ -5,6 +5,7 @@ import { Image, MenuItem, MenuList, + Text, useTheme, useToast, } from '@chakra-ui/react'; @@ -20,7 +21,14 @@ import { setSeed, } from 'features/parameters/store/generationSlice'; import { DragEvent, memo, useState } from 'react'; -import { FaCheck, FaTrashAlt } from 'react-icons/fa'; +import { + FaCheck, + FaExpand, + FaLink, + FaShare, + FaTrash, + FaTrashAlt, +} from 'react-icons/fa'; import DeleteImageModal from './DeleteImageModal'; import { ContextMenu } from 'chakra-ui-contextmenu'; import * as InvokeAI from 'app/invokeai'; @@ -35,6 +43,9 @@ import useSetBothPrompts from 'features/parameters/hooks/usePrompt'; import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice'; import IAIIconButton from 'common/components/IAIIconButton'; import { useGetUrl } from 'common/util/getUrl'; +import { ExternalLinkIcon } from '@chakra-ui/icons'; +import { BiZoomIn } from 'react-icons/bi'; +import { IoArrowUndoCircleOutline } from 'react-icons/io5'; interface HoverableImageProps { image: InvokeAI.Image; @@ -177,15 +188,19 @@ const HoverableImage = memo((props: HoverableImageProps) => { menuProps={{ size: 'sm', isLazy: true }} renderMenu={() => ( - + } + onClickCapture={handleOpenInNewTab} + > {t('common.openInNewTab')} {!disabledFeatures.includes('lightbox') && ( - + } onClickCapture={handleLightBox}> {t('parameters.openInViewer')} )} } onClickCapture={handleUsePrompt} isDisabled={image?.metadata?.sd_metadata?.prompt === undefined} > @@ -193,12 +208,21 @@ const HoverableImage = memo((props: HoverableImageProps) => { } onClickCapture={handleUseSeed} isDisabled={image?.metadata?.sd_metadata?.seed === undefined} > {t('parameters.useSeed')} } + onClickCapture={handleUseInitialImage} + isDisabled={image?.metadata?.sd_metadata?.type !== 'img2img'} + > + {t('parameters.useInitImg')} + + } onClickCapture={handleUseAllParameters} isDisabled={ !['txt2img', 'img2img'].includes( @@ -209,21 +233,18 @@ const HoverableImage = memo((props: HoverableImageProps) => { {t('parameters.useAll')} } + onClickCapture={handleSendToImageToImage} > - {t('parameters.useInitImg')} - - {t('parameters.sendToImg2Img')} - + } onClickCapture={handleSendToCanvas}> {t('parameters.sendToUnifiedCanvas')} - - {/* -

{t('parameters.deleteImage')}

-
*/} + }> + + {t('parameters.deleteImage')} +
)} @@ -302,15 +323,15 @@ const HoverableImage = memo((props: HoverableImageProps) => { insetInlineEnd: 1, }} > - {/* + } + icon={} size="xs" fontSize={14} isDisabled={!mayDeleteImage} /> - */} + )} diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 6600e14d87..d0336379fc 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -6,8 +6,8 @@ import { InvokeTabName } from 'features/ui/store/tabMap'; import { IRect } from 'konva/lib/types'; import { clamp } from 'lodash'; import { isImageOutput } from 'services/types/guards'; -import { imageUploaded } from 'services/thunks/image'; import { deserializeImageResponse } from 'services/util/deserializeImageResponse'; +import { imageUploaded } from 'services/thunks/image'; export type GalleryCategory = 'user' | 'result'; diff --git a/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts b/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts index 6cabed11af..e4b89144f4 100644 --- a/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/resultsSlice.ts @@ -13,7 +13,11 @@ import { extractTimestampFromImageName, } from 'services/util/deserializeImageField'; import { deserializeImageResponse } from 'services/util/deserializeImageResponse'; -import { imageReceived, thumbnailReceived } from 'services/thunks/image'; +import { + imageDeleted, + imageReceived, + thumbnailReceived, +} from 'services/thunks/image'; export const resultsAdapter = createEntityAdapter({ selectId: (image) => image.name, @@ -107,6 +111,9 @@ const resultsSlice = createSlice({ } }); + /** + * Image Received - FULFILLED + */ builder.addCase(imageReceived.fulfilled, (state, action) => { const { imagePath } = action.payload; const { imageName } = action.meta.arg; @@ -119,17 +126,31 @@ const resultsSlice = createSlice({ }); }); + /** + * Thumbnail Received - FULFILLED + */ builder.addCase(thumbnailReceived.fulfilled, (state, action) => { const { thumbnailPath } = action.payload; - const { imageName } = action.meta.arg; + const { thumbnailName } = action.meta.arg; resultsAdapter.updateOne(state, { - id: imageName, + id: thumbnailName, changes: { thumbnail: thumbnailPath, }, }); }); + + /** + * Delete Image - FULFILLED + */ + builder.addCase(imageDeleted.fulfilled, (state, action) => { + const { imageType, imageName } = action.meta.arg; + + if (imageType === 'results') { + resultsAdapter.removeOne(state, imageName); + } + }); }, }); diff --git a/invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts b/invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts index 224d4c2335..e2d21d3afd 100644 --- a/invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/uploadsSlice.ts @@ -6,7 +6,7 @@ import { receivedUploadImagesPage, IMAGES_PER_PAGE, } from 'services/thunks/gallery'; -import { imageUploaded } from 'services/thunks/image'; +import { imageDeleted, imageUploaded } from 'services/thunks/image'; import { deserializeImageResponse } from 'services/util/deserializeImageResponse'; export const uploadsAdapter = createEntityAdapter({ @@ -71,6 +71,17 @@ const uploadsSlice = createSlice({ uploadsAdapter.addOne(state, uploadedImage); }); + + /** + * Delete Image - FULFILLED + */ + builder.addCase(imageDeleted.fulfilled, (state, action) => { + const { imageType, imageName } = action.meta.arg; + + if (imageType === 'uploads') { + uploadsAdapter.removeOne(state, imageName); + } + }); }, }); diff --git a/invokeai/frontend/web/src/services/thunks/image.ts b/invokeai/frontend/web/src/services/thunks/image.ts index d7318f318c..7288429ca0 100644 --- a/invokeai/frontend/web/src/services/thunks/image.ts +++ b/invokeai/frontend/web/src/services/thunks/image.ts @@ -1,5 +1,7 @@ -import { isFulfilled } from '@reduxjs/toolkit'; +import { isFulfilled, isRejected } from '@reduxjs/toolkit'; import { createAppAsyncThunk } from 'app/storeUtils'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { clamp } from 'lodash'; import { ImagesService } from 'services/api'; import { getHeaders } from 'services/util/getHeaders'; @@ -49,3 +51,51 @@ export const imageUploaded = createAppAsyncThunk( * Function to check if an action is a fulfilled `ImagesService.uploadImage()` thunk */ export const isFulfilledImageUploadedAction = isFulfilled(imageUploaded); + +type ImageDeletedArg = Parameters<(typeof ImagesService)['deleteImage']>[0]; + +/** + * `ImagesService.deleteImage()` thunk + */ +export const imageDeleted = createAppAsyncThunk( + 'api/imageDeleted', + async (arg: ImageDeletedArg, { getState, dispatch }) => { + const { imageType, imageName } = arg; + + if (imageType !== 'uploads' && imageType !== 'results') { + return; + } + + // TODO: move this logic to another thunk? + // Determine which image should replace the deleted image, if the deleted image is the selected image. + // Unfortunately, we have to do this here, because the resultsSlice and uploadsSlice cannot change + // the selected image. + const selectedImageName = getState().gallery.selectedImageName; + + if (selectedImageName === imageName) { + const allIds = getState()[imageType].ids; + + const deletedImageIndex = allIds.findIndex( + (result) => result.toString() === imageName + ); + + const filteredIds = allIds.filter((id) => id.toString() !== imageName); + + const newSelectedImageIndex = clamp( + deletedImageIndex, + 0, + filteredIds.length - 1 + ); + + const newSelectedImageId = filteredIds[newSelectedImageIndex]; + + dispatch( + imageSelected(newSelectedImageId ? newSelectedImageId.toString() : '') + ); + } + + const response = await ImagesService.deleteImage(arg); + + return response; + } +);