refactor(ui): async modal pattern; use for deleting images

This was needed for a canvas flow change which is currently paused, but the new API is much much nicer to use, so I am keeping it.
This commit is contained in:
psychedelicious
2025-06-11 12:29:38 +10:00
parent 8c17bde4ea
commit c7ed351bab
20 changed files with 376 additions and 498 deletions

View File

@@ -6,7 +6,7 @@ import {
NewGallerySessionDialog,
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';

View File

@@ -9,15 +9,14 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
import { addEnsureImageIsSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged';
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
import { addImageDeletionListeners } from 'app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners';
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred';
import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred';
import { addImageToDeleteSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected';
import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded';
import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
@@ -46,9 +45,7 @@ export const addAppListener = addListener.withTypes<RootState, AppDispatch>();
addImageUploadedFulfilledListener(startAppListening);
// Image deleted
addImageDeletionListeners(startAppListening);
addDeleteBoardAndImagesFulfilledListener(startAppListening);
addImageToDeleteSelectedListener(startAppListening);
// Image starred
addImagesStarredListener(startAppListening);
@@ -91,3 +88,5 @@ addAppConfigReceivedListener(startAppListening);
addAdHocPostProcessingRequestedListener(startAppListening);
addSetDefaultSettingsListener(startAppListening);
addEnsureImageIsSelectedListener(startAppListening);

View File

@@ -1,6 +1,6 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { getImageUsage } from 'features/deleteImageModal/store/selectors';
import { getImageUsage } from 'features/deleteImageModal/store/state';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';

View File

@@ -0,0 +1,16 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
export const addEnsureImageIsSelectedListener = (startAppListening: AppStartListening) => {
// When we list images, if no images is selected, select the first one.
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const selection = getState().gallery.selection;
if (selection.length === 0) {
dispatch(imageSelected(action.payload.items[0] ?? null));
}
},
});
};

View File

@@ -1,221 +0,0 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { AppDispatch, RootState } from 'app/store/store';
import { entityDeleted, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { forEach, intersectionBy } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
const log = logger('gallery');
//TODO(psyche): handle image deletion (canvas staging area?)
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const actions: Param0<typeof dispatch>[] = [];
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
return;
}
forEach(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
actions.push(
fieldImageValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: undefined,
})
);
return;
}
if (isImageFieldCollectionInputInstance(input)) {
actions.push(
fieldImageCollectionValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
})
);
}
});
});
actions.forEach(dispatch);
};
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => {
let shouldDelete = false;
for (const obj of objects) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}
}
if (shouldDelete) {
dispatch(entityDeleted({ entityIdentifier: { id, type: 'control_layer' } }));
}
});
};
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectCanvasSlice(state).referenceImages.entities.forEach((entity) => {
if (entity.ipAdapter.image?.image_name === imageDTO.image_name) {
dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: getEntityIdentifier(entity), imageDTO: null }));
}
});
};
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => {
let shouldDelete = false;
for (const obj of objects) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}
}
if (shouldDelete) {
dispatch(entityDeleted({ entityIdentifier: { id, type: 'raster_layer' } }));
}
});
};
export const addImageDeletionListeners = (startAppListening: AppStartListening) => {
// Handle single image deletion
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState }) => {
const { imageDTOs, imagesUsage } = action.payload;
if (imageDTOs.length !== 1 || imagesUsage.length !== 1) {
// handle multiples in separate listener
return;
}
const imageDTO = imageDTOs[0];
const imageUsage = imagesUsage[0];
if (!imageDTO || !imageUsage) {
// satisfy noUncheckedIndexedAccess
return;
}
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO)).unwrap();
if (state.gallery.selection.some((i) => i.image_name === imageDTO.image_name)) {
// The deleted image was a selected image, we need to select the next image
const newSelection = state.gallery.selection.filter((i) => i.image_name !== imageDTO.image_name);
if (newSelection.length > 0) {
return;
}
// Get the current list of images and select the same index
const baseQueryArgs = selectListImagesQueryArgs(state);
const data = imagesApi.endpoints.listImages.select(baseQueryArgs)(state).data;
if (data) {
const deletedImageIndex = data.items.findIndex((i) => i.image_name === imageDTO.image_name);
const nextImage = data.items[deletedImageIndex + 1] ?? data.items[0] ?? null;
if (nextImage?.image_name === imageDTO.image_name) {
// If the next image is the same as the deleted one, it means it was the last image, reset selection
dispatch(imageSelected(null));
} else {
dispatch(imageSelected(nextImage));
}
}
}
deleteNodesImages(state, dispatch, imageDTO);
deleteReferenceImages(state, dispatch, imageDTO);
deleteRasterLayerImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
} catch {
// no-op
} finally {
dispatch(isModalOpenChanged(false));
}
},
});
// Handle multiple image deletion
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState }) => {
const { imageDTOs, imagesUsage } = action.payload;
if (imageDTOs.length <= 1 || imagesUsage.length <= 1) {
// handle singles in separate listener
return;
}
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) {
// Some selected images were deleted, need to select the next image
const queryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
if (data) {
// When we delete multiple images, we clear the selection. Then, the the next time we load images, we will
// select the first one. This is handled below in the listener for `imagesApi.endpoints.listImages.matchFulfilled`.
dispatch(imageSelected(null));
}
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
imageDTOs.forEach((imageDTO) => {
deleteNodesImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
deleteReferenceImages(state, dispatch, imageDTO);
deleteRasterLayerImages(state, dispatch, imageDTO);
});
} catch {
// no-op
} finally {
dispatch(isModalOpenChanged(false));
}
},
});
// When we list images, if no images is selected, select the first one.
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const selection = getState().gallery.selection;
if (selection.length === 0) {
dispatch(imageSelected(action.payload.items[0] ?? null));
}
},
});
startAppListening({
matcher: imagesApi.endpoints.deleteImage.matchFulfilled,
effect: (action) => {
log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Image deleted');
},
});
startAppListening({
matcher: imagesApi.endpoints.deleteImage.matchRejected,
effect: (action) => {
log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Unable to delete image');
},
});
};

View File

@@ -1,32 +0,0 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { selectImageUsage } from 'features/deleteImageModal/store/selectors';
import { imagesToDeleteSelected, isModalOpenChanged } from 'features/deleteImageModal/store/slice';
export const addImageToDeleteSelectedListener = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: imagesToDeleteSelected,
effect: (action, { dispatch, getState }) => {
const imageDTOs = action.payload;
const state = getState();
const { shouldConfirmOnDelete } = state.system;
const imagesUsage = selectImageUsage(getState());
const isImageInUse =
imagesUsage.some((i) => i.isRasterLayerImage) ||
imagesUsage.some((i) => i.isControlLayerImage) ||
imagesUsage.some((i) => i.isReferenceImage) ||
imagesUsage.some((i) => i.isInpaintMaskImage) ||
imagesUsage.some((i) => i.isUpscaleImage) ||
imagesUsage.some((i) => i.isNodesImage) ||
imagesUsage.some((i) => i.isRegionalGuidanceImage);
if (shouldConfirmOnDelete || isImageInUse) {
dispatch(isModalOpenChanged(true));
return;
}
dispatch(imageDeletionConfirmed({ imageDTOs, imagesUsage }));
},
});
};

View File

@@ -13,7 +13,6 @@ import {
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice';
import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice';
import { deleteImageModalSlice } from 'features/deleteImageModal/store/slice';
import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice';
import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice';
@@ -54,7 +53,6 @@ const allReducers = {
[configSlice.name]: configSlice.reducer,
[uiSlice.name]: uiSlice.reducer,
[dynamicPromptsSlice.name]: dynamicPromptsSlice.reducer,
[deleteImageModalSlice.name]: deleteImageModalSlice.reducer,
[changeBoardModalSlice.name]: changeBoardModalSlice.reducer,
[modelManagerV2Slice.name]: modelManagerV2Slice.reducer,
[queueSlice.name]: queueSlice.reducer,

View File

@@ -1,98 +1,38 @@
import { ConfirmationAlertDialog, Divider, Flex, FormControl, FormLabel, Switch, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { getImageUsage, selectImageUsage } from 'features/deleteImageModal/store/selectors';
import {
imageDeletionCanceled,
isModalOpenChanged,
selectDeleteImageModalSlice,
} from 'features/deleteImageModal/store/slice';
import type { ImageUsage } from 'features/deleteImageModal/store/types';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';
import { selectSystemSlice, setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { some } from 'lodash-es';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage';
import { useDeleteImageModalApi, useDeleteImageModalState } from 'features/deleteImageModal/store/state';
import { selectSystemShouldConfirmOnDelete, setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import ImageUsageMessage from './ImageUsageMessage';
const selectImageUsages = createMemoizedSelector(
[selectDeleteImageModalSlice, selectNodesSlice, selectCanvasSlice, selectImageUsage, selectUpscaleSlice],
(deleteImageModal, nodes, canvas, imagesUsage, upscale) => {
const { imagesToDelete } = deleteImageModal;
const allImageUsage = (imagesToDelete ?? []).map(({ image_name }) =>
getImageUsage(nodes, canvas, upscale, image_name)
);
const imageUsageSummary: ImageUsage = {
isUpscaleImage: some(allImageUsage, (i) => i.isUpscaleImage),
isRasterLayerImage: some(allImageUsage, (i) => i.isRasterLayerImage),
isInpaintMaskImage: some(allImageUsage, (i) => i.isInpaintMaskImage),
isRegionalGuidanceImage: some(allImageUsage, (i) => i.isRegionalGuidanceImage),
isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
isControlLayerImage: some(allImageUsage, (i) => i.isControlLayerImage),
isReferenceImage: some(allImageUsage, (i) => i.isReferenceImage),
};
return {
imagesToDelete,
imagesUsage,
imageUsageSummary,
};
}
);
const selectShouldConfirmOnDelete = createSelector(selectSystemSlice, (system) => system.shouldConfirmOnDelete);
const selectIsModalOpen = createSelector(
selectDeleteImageModalSlice,
(deleteImageModal) => deleteImageModal.isModalOpen
);
const DeleteImageModal = () => {
useAssertSingleton('DeleteImageModal');
const dispatch = useAppDispatch();
export const DeleteImageModal = memo(() => {
const state = useDeleteImageModalState();
const api = useDeleteImageModalApi();
const { dispatch } = useAppStore();
const { t } = useTranslation();
const shouldConfirmOnDelete = useAppSelector(selectShouldConfirmOnDelete);
const isModalOpen = useAppSelector(selectIsModalOpen);
const { imagesToDelete, imagesUsage, imageUsageSummary } = useAppSelector(selectImageUsages);
const shouldConfirmOnDelete = useAppSelector(selectSystemShouldConfirmOnDelete);
const handleChangeShouldConfirmOnDelete = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldConfirmOnDelete(!e.target.checked)),
[dispatch]
);
const handleClose = useCallback(() => {
dispatch(imageDeletionCanceled());
dispatch(isModalOpenChanged(false));
}, [dispatch]);
const handleDelete = useCallback(() => {
if (!imagesToDelete.length || !imagesUsage.length) {
return;
}
dispatch(imageDeletionCanceled());
dispatch(imageDeletionConfirmed({ imageDTOs: imagesToDelete, imagesUsage }));
}, [dispatch, imagesToDelete, imagesUsage]);
return (
<ConfirmationAlertDialog
title={t('gallery.deleteImage', { count: imagesToDelete.length })}
isOpen={isModalOpen}
onClose={handleClose}
title={`${t('gallery.deleteImage', { count: state.imageDTOs.length })}2`}
isOpen={state.isOpen}
onClose={api.close}
cancelButtonText={t('common.cancel')}
acceptButtonText={t('common.delete')}
acceptCallback={handleDelete}
acceptCallback={api.confirm}
cancelCallback={api.cancel}
useInert={false}
>
<Flex direction="column" gap={3}>
<ImageUsageMessage imageUsage={imageUsageSummary} />
<ImageUsageMessage imageUsage={state.usageSummary} />
<Divider />
<Text>{t('gallery.deleteImagePermanent')}</Text>
<Text>{t('common.areYouSure')}</Text>
@@ -103,6 +43,5 @@ const DeleteImageModal = () => {
</Flex>
</ConfirmationAlertDialog>
);
};
export default memo(DeleteImageModal);
});
DeleteImageModal.displayName = 'DeleteImageModal';

View File

@@ -1,9 +0,0 @@
import { createAction } from '@reduxjs/toolkit';
import type { ImageDTO } from 'services/api/types';
import type { ImageUsage } from './types';
export const imageDeletionConfirmed = createAction<{
imageDTOs: ImageDTO[];
imagesUsage: ImageUsage[];
}>('deleteImageModal/imageDeletionConfirmed');

View File

@@ -1,6 +0,0 @@
import type { DeleteImageState } from './types';
export const initialDeleteImageState: DeleteImageState = {
imagesToDelete: [],
isModalOpen: false,
};

View File

@@ -1,85 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasState } from 'features/controlLayers/store/types';
import { selectDeleteImageModalSlice } from 'features/deleteImageModal/store/slice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type { UpscaleState } from 'features/parameters/store/upscaleSlice';
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';
import { some } from 'lodash-es';
import type { ImageUsage } from './types';
// TODO(psyche): handle image deletion (canvas staging area?)
export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: UpscaleState, image_name: string) => {
const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) =>
some(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input)) {
if (input.value?.image_name === image_name) {
return true;
}
}
if (isImageFieldCollectionInputInstance(input)) {
if (input.value?.some((value) => value?.image_name === image_name)) {
return true;
}
}
return false;
})
);
const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name;
const isReferenceImage = canvas.referenceImages.entities.some(
({ ipAdapter }) => ipAdapter.image?.image_name === image_name
);
const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isControlLayerImage = canvas.controlLayers.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isInpaintMaskImage = canvas.inpaintMasks.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isRegionalGuidanceImage = canvas.regionalGuidance.entities.some(({ referenceImages }) =>
referenceImages.some(({ ipAdapter }) => ipAdapter.image?.image_name === image_name)
);
const imageUsage: ImageUsage = {
isUpscaleImage,
isRasterLayerImage,
isInpaintMaskImage,
isRegionalGuidanceImage,
isNodesImage,
isControlLayerImage,
isReferenceImage,
};
return imageUsage;
};
export const selectImageUsage = createMemoizedSelector(
selectDeleteImageModalSlice,
selectNodesSlice,
selectCanvasSlice,
selectUpscaleSlice,
(deleteImageModal, nodes, canvas, upscale) => {
const { imagesToDelete } = deleteImageModal;
if (!imagesToDelete.length) {
return [];
}
const imagesUsage = imagesToDelete.map((i) => getImageUsage(nodes, canvas, upscale, i.image_name));
return imagesUsage;
}
);

View File

@@ -1,27 +0,0 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import type { ImageDTO } from 'services/api/types';
import { initialDeleteImageState } from './initialState';
export const deleteImageModalSlice = createSlice({
name: 'deleteImageModal',
initialState: initialDeleteImageState,
reducers: {
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isModalOpen = action.payload;
},
imagesToDeleteSelected: (state, action: PayloadAction<ImageDTO[]>) => {
state.imagesToDelete = action.payload;
},
imageDeletionCanceled: (state) => {
state.imagesToDelete = [];
state.isModalOpen = false;
},
},
});
export const { isModalOpenChanged, imagesToDeleteSelected, imageDeletionCanceled } = deleteImageModalSlice.actions;
export const selectDeleteImageModalSlice = (state: RootState) => state.deleteImageModal;

View File

@@ -0,0 +1,298 @@
import { useStore } from '@nanostores/react';
import { getStore, useAppStore } from 'app/store/nanostores/store';
import type { AppDispatch, AppGetState, RootState } from 'app/store/store';
import { entityDeleted, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { type CanvasState, getEntityIdentifier } from 'features/controlLayers/store/types';
import type { ImageUsage } from 'features/deleteImageModal/store/types';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { selectUpscaleSlice, type UpscaleState } from 'features/parameters/store/upscaleSlice';
import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { forEach, intersectionBy, some } from 'lodash-es';
import { atom } from 'nanostores';
import { useMemo } from 'react';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
// Implements an awaitable modal dialog for deleting images
type DeleteImagesModalState = {
imageDTOs: ImageDTO[];
usagePerImage: ImageUsage[];
usageSummary: ImageUsage;
isOpen: boolean;
resolve?: () => void;
reject?: (reason?: string) => void;
};
const getInitialState = (): DeleteImagesModalState => ({
imageDTOs: [],
usagePerImage: [],
usageSummary: {
isControlLayerImage: false,
isInpaintMaskImage: false,
isNodesImage: false,
isRasterLayerImage: false,
isRegionalGuidanceImage: false,
isReferenceImage: false,
isUpscaleImage: false,
},
isOpen: false,
});
const $deleteModalState = atom<DeleteImagesModalState>(getInitialState());
const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
const { getState, dispatch } = getStore();
const imageUsage = getImageUsageFromImageDTOs(imageDTOs, getState());
const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState());
if (!shouldConfirmOnDelete && !isAnyImageInUse(imageUsage)) {
// If we don't need to confirm and the images are not in use, delete them directly
await handleDeletions(imageDTOs, dispatch, getState);
}
return new Promise<void>((resolve, reject) => {
$deleteModalState.set({
usagePerImage: imageUsage,
usageSummary: getImageUsageSummary(imageUsage),
imageDTOs,
isOpen: true,
resolve,
reject,
});
});
};
const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, getState: AppGetState) => {
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) {
// Some selected images were deleted, need to select the next image
const queryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
if (data) {
// When we delete multiple images, we clear the selection. Then, the the next time we load images, we will
// select the first one. This is handled below in the listener for `imagesApi.endpoints.listImages.matchFulfilled`.
dispatch(imageSelected(null));
}
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
for (const imageDTO of imageDTOs) {
deleteNodesImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
deleteReferenceImages(state, dispatch, imageDTO);
deleteRasterLayerImages(state, dispatch, imageDTO);
}
} catch {
// no-op
}
};
const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => {
const state = $deleteModalState.get();
await handleDeletions(state.imageDTOs, dispatch, getState);
state.resolve?.();
closeSilently();
};
const cancelDeletion = () => {
const state = $deleteModalState.get();
state.reject?.('User canceled');
closeSilently();
};
const closeSilently = () => {
$deleteModalState.set(getInitialState());
};
export const useDeleteImageModalState = () => {
const state = useStore($deleteModalState);
return state;
};
export const useDeleteImageModalApi = () => {
const { dispatch, getState } = useAppStore();
const api = useMemo(
() => ({
delete: deleteImagesWithDialog,
confirm: () => confirmDeletion(dispatch, getState),
cancel: cancelDeletion,
close: closeSilently,
getUsageSummary: getImageUsageSummary,
}),
[dispatch, getState]
);
return api;
};
const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): ImageUsage[] => {
if (imageDTOs.length === 0) {
return [];
}
const nodes = selectNodesSlice(state);
const canvas = selectCanvasSlice(state);
const upscale = selectUpscaleSlice(state);
return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, image_name));
};
const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({
isUpscaleImage: some(imageUsage, (i) => i.isUpscaleImage),
isRasterLayerImage: some(imageUsage, (i) => i.isRasterLayerImage),
isInpaintMaskImage: some(imageUsage, (i) => i.isInpaintMaskImage),
isRegionalGuidanceImage: some(imageUsage, (i) => i.isRegionalGuidanceImage),
isNodesImage: some(imageUsage, (i) => i.isNodesImage),
isControlLayerImage: some(imageUsage, (i) => i.isControlLayerImage),
isReferenceImage: some(imageUsage, (i) => i.isReferenceImage),
});
const isAnyImageInUse = (imageUsage: ImageUsage[]): boolean =>
imageUsage.some(
(i) =>
i.isRasterLayerImage ||
i.isControlLayerImage ||
i.isReferenceImage ||
i.isInpaintMaskImage ||
i.isUpscaleImage ||
i.isNodesImage ||
i.isRegionalGuidanceImage
);
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const actions: Param0<typeof dispatch>[] = [];
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
return;
}
forEach(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
actions.push(
fieldImageValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: undefined,
})
);
return;
}
if (isImageFieldCollectionInputInstance(input)) {
actions.push(
fieldImageCollectionValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
})
);
}
});
});
actions.forEach(dispatch);
};
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => {
let shouldDelete = false;
for (const obj of objects) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}
}
if (shouldDelete) {
dispatch(entityDeleted({ entityIdentifier: { id, type: 'control_layer' } }));
}
});
};
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectCanvasSlice(state).referenceImages.entities.forEach((entity) => {
if (entity.ipAdapter.image?.image_name === imageDTO.image_name) {
dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: getEntityIdentifier(entity), imageDTO: null }));
}
});
};
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => {
let shouldDelete = false;
for (const obj of objects) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}
}
if (shouldDelete) {
dispatch(entityDeleted({ entityIdentifier: { id, type: 'raster_layer' } }));
}
});
};
export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: UpscaleState, image_name: string) => {
const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) =>
some(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input)) {
if (input.value?.image_name === image_name) {
return true;
}
}
if (isImageFieldCollectionInputInstance(input)) {
if (input.value?.some((value) => value?.image_name === image_name)) {
return true;
}
}
return false;
})
);
const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name;
const isReferenceImage = canvas.referenceImages.entities.some(
({ ipAdapter }) => ipAdapter.image?.image_name === image_name
);
const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isControlLayerImage = canvas.controlLayers.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isInpaintMaskImage = canvas.inpaintMasks.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)
);
const isRegionalGuidanceImage = canvas.regionalGuidance.entities.some(({ referenceImages }) =>
referenceImages.some(({ ipAdapter }) => ipAdapter.image?.image_name === image_name)
);
const imageUsage: ImageUsage = {
isUpscaleImage,
isRasterLayerImage,
isInpaintMaskImage,
isRegionalGuidanceImage,
isNodesImage,
isControlLayerImage,
isReferenceImage,
};
return imageUsage;
};

View File

@@ -17,7 +17,7 @@ import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage';
import { getImageUsage } from 'features/deleteImageModal/store/selectors';
import { getImageUsage } from 'features/deleteImageModal/store/state';
import type { ImageUsage } from 'features/deleteImageModal/store/types';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';

View File

@@ -1,6 +1,5 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,12 +7,16 @@ import { PiTrashSimpleBold } from 'react-icons/pi';
export const ImageMenuItemDelete = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const deleteImageModal = useDeleteImageModalApi();
const imageDTO = useImageDTOContext();
const onClick = useCallback(() => {
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
const onClick = useCallback(async () => {
try {
await deleteImageModal.delete([imageDTO]);
} catch {
// noop;
}
}, [deleteImageModal, imageDTO]);
return (
<IconMenuItem

View File

@@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -19,6 +19,7 @@ const MultipleSelectionMenuItems = () => {
const dispatch = useAppDispatch();
const selection = useAppSelector((s) => s.gallery.selection);
const customStarUi = useStore($customStarUI);
const deleteImageModal = useDeleteImageModalApi();
const isBulkDownloadEnabled = useFeatureStatus('bulkDownload');
@@ -32,8 +33,8 @@ const MultipleSelectionMenuItems = () => {
}, [dispatch, selection]);
const handleDeleteSelection = useCallback(() => {
dispatch(imagesToDeleteSelected(selection));
}, [dispatch, selection]);
deleteImageModal.delete(selection);
}, [deleteImageModal, selection]);
const handleStarSelection = useCallback(() => {
starImages({ imageDTOs: selection });

View File

@@ -1,6 +1,5 @@
import { useShiftModifier } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import type { MouseEvent } from 'react';
import { memo, useCallback } from 'react';
@@ -15,16 +14,17 @@ type Props = {
export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
const shift = useShiftModifier();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const deleteImageModal = useDeleteImageModalApi();
const onClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!imageDTO) {
return;
}
dispatch(imagesToDeleteSelected([imageDTO]));
deleteImageModal.delete([imageDTO]);
},
[dispatch, imageDTO]
[deleteImageModal, imageDTO]
);
if (!shift) {

View File

@@ -1,7 +1,7 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
@@ -16,7 +16,6 @@ import { useListImagesQuery } from 'services/api/endpoints/images';
export const useGalleryHotkeys = () => {
useAssertSingleton('useGalleryHotkeys');
const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination();
const dispatch = useAppDispatch();
const selection = useAppSelector((s) => s.gallery.selection);
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const queryResult = useListImagesQuery(queryArgs);
@@ -25,6 +24,7 @@ export const useGalleryHotkeys = () => {
const isWorkflowsFocused = useIsRegionFocused('workflows');
const isGalleryFocused = useIsRegionFocused('gallery');
const isImageViewerFocused = useIsRegionFocused('viewer');
const deleteImageModal = useDeleteImageModalApi();
// When we are on the canvas tab, we need to disable the delete hotkey when the user is focused on the layers tab in
// the right hand panel, because the same hotkey is used to delete layers.
@@ -209,7 +209,7 @@ export const useGalleryHotkeys = () => {
if (!selection.length) {
return;
}
dispatch(imagesToDeleteSelected(selection));
deleteImageModal.delete(selection);
},
options: {
enabled: (isGalleryFocused || isImageViewerFocused) && isDeleteEnabledByTab && !isWorkflowsFocused,

View File

@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react';
import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import {
handlers,
parseAndRecallAllMetadata,
@@ -34,6 +34,7 @@ export const useImageActions = (imageDTO: ImageDTO) => {
const [hasSeed, setHasSeed] = useState(false);
const [hasPrompts, setHasPrompts] = useState(false);
const hasTemplates = useStore($hasTemplates);
const deleteImageModal = useDeleteImageModalApi();
useEffect(() => {
const parseMetadata = async () => {
@@ -169,8 +170,8 @@ export const useImageActions = (imageDTO: ImageDTO) => {
}, [dispatch, imageDTO]);
const _delete = useCallback(() => {
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
deleteImageModal.delete([imageDTO]);
}, [deleteImageModal, imageDTO]);
return {
hasMetadata,

View File

@@ -2,6 +2,7 @@ import { $authToken } from 'app/store/nanostores/authToken';
import { getStore } from 'app/store/nanostores/store';
import type { BoardId } from 'features/gallery/store/types';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { uniqBy } from 'lodash-es';
import type { components, paths } from 'services/api/schema';
import type {
DeleteBoardResult,
@@ -14,6 +15,7 @@ import type {
UploadImageArg,
} from 'services/api/types';
import { getCategories, getListImagesUrl } from 'services/api/util';
import stableHash from 'stable-hash';
import type { Param0 } from 'tsafe';
import type { JsonObject } from 'type-fest';
@@ -133,11 +135,12 @@ export const imagesApi = api.injectEndpoints({
};
},
invalidatesTags: (result, error, { imageDTOs }) => {
if (imageDTOs[0]) {
const categories = getCategories(imageDTOs[0]);
const boardId = imageDTOs[0].board_id ?? 'none';
const tags: ApiTagDescription[] = [];
for (const imageDTO of imageDTOs) {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
const tags: ApiTagDescription[] = [
tags.push(
{
type: 'ImageList',
id: getListImagesUrl({
@@ -152,12 +155,12 @@ export const imagesApi = api.injectEndpoints({
{
type: 'BoardImagesTotal',
id: boardId,
},
];
return tags;
}
);
}
return [];
const dedupedTags = uniqBy(tags, stableHash);
return dedupedTags;
},
}),
deleteUncategorizedImages: build.mutation<components['schemas']['DeleteImagesFromListResult'], void>({