From 7c01b69c12d6e7162d940925e354d656fb8eaee8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 3 Jul 2024 12:52:44 +1000 Subject: [PATCH] fix(ui): revise image selection after deletion - For single image deletion, select the image in the same slot as the deleted image - For multiple image deletion, empty selection - On list images, if no images are currently selected, select the first image --- .../middleware/listenerMiddleware/index.ts | 6 +- .../addFirstListImagesListener.ts.ts | 27 ----- ...geDeleted.ts => imageDeletionListeners.ts} | 102 +++++++++--------- 3 files changed, 54 insertions(+), 81 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts rename invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/{imageDeleted.ts => imageDeletionListeners.ts} (70%) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 2d3db05bf7f..9698f85219a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -1,7 +1,6 @@ import type { TypedStartListening } from '@reduxjs/toolkit'; import { createListenerMiddleware } from '@reduxjs/toolkit'; import { addCommitStagingAreaImageListener } from 'app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener'; -import { addFirstListImagesListener } from 'app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts'; import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued'; import { addAppConfigReceivedListener } from 'app/store/middleware/listenerMiddleware/listeners/appConfigReceived'; import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted'; @@ -26,7 +25,7 @@ import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMid 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 { addRequestedSingleImageDeletionListener } from 'app/store/middleware/listenerMiddleware/listeners/imageDeleted'; +import { addImageDeletionListeners } from 'app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners'; import { addImageDroppedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped'; import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard'; import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred'; @@ -70,7 +69,7 @@ const startAppListening = listenerMiddleware.startListening as AppStartListening addImageUploadedFulfilledListener(startAppListening); // Image deleted -addRequestedSingleImageDeletionListener(startAppListening); +addImageDeletionListeners(startAppListening); addDeleteBoardAndImagesFulfilledListener(startAppListening); addImageToDeleteSelectedListener(startAppListening); @@ -139,7 +138,6 @@ addModelSelectedListener(startAppListening); addAppStartedListener(startAppListening); addModelsLoadedListener(startAppListening); addAppConfigReceivedListener(startAppListening); -addFirstListImagesListener(startAppListening); // Ad-hoc upscale workflwo addUpscaleRequestedListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts deleted file mode 100644 index 5db5f687a13..00000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import { imagesApi } from 'services/api/endpoints/images'; -import { getListImagesUrl } from 'services/api/util'; - -export const addFirstListImagesListener = (startAppListening: AppStartListening) => { - startAppListening({ - matcher: imagesApi.endpoints.listImages.matchFulfilled, - effect: async (action, { dispatch, unsubscribe, cancelActiveListeners }) => { - // Only run this listener on the first listImages request for no-board images - if (action.meta.arg.queryCacheKey !== getListImagesUrl({ board_id: 'none', categories: IMAGE_CATEGORIES })) { - return; - } - - // this should only run once - cancelActiveListeners(); - unsubscribe(); - - const data = action.payload; - - if (data.items.length > 0) { - dispatch(imageSelected(data.items[0] ?? null)); - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts similarity index 70% rename from invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts rename to invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts index 916ec2c47fd..056346cb68b 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts @@ -22,11 +22,11 @@ import { imageSelected } from 'features/gallery/store/gallerySlice'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { isImageFieldInputInstance } from 'features/nodes/types/field'; import { isInvocationNode } from 'features/nodes/types/invocation'; -import { forEach } from 'lodash-es'; -import { api } from 'services/api'; +import { forEach, intersectionBy } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; +// Some utils to delete images from different parts of the app const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { state.nodes.present.nodes.forEach((node) => { if (!isInvocationNode(node)) { @@ -97,10 +97,11 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image }); }; -export const addRequestedSingleImageDeletionListener = (startAppListening: AppStartListening) => { +export const addImageDeletionListeners = (startAppListening: AppStartListening) => { + // Handle single image deletion startAppListening({ actionCreator: imageDeletionConfirmed, - effect: async (action, { dispatch, getState, condition }) => { + effect: async (action, { dispatch, getState }) => { const { imageDTOs, imagesUsage } = action.payload; if (imageDTOs.length !== 1 || imagesUsage.length !== 1) { @@ -116,49 +117,46 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt return; } - dispatch(isModalOpenChanged(false)); - const state = getState(); - - // We need to reset the features where the image is in use - none of these work if their image(s) don't exist - if (imageUsage.isCanvasImage) { - dispatch(resetCanvas()); - } - - imageDTOs.forEach((imageDTO) => { - deleteControlAdapterImages(state, dispatch, imageDTO); - deleteNodesImages(state, dispatch, imageDTO); - deleteControlLayerImages(state, dispatch, imageDTO); - }); - - // Delete from server - const { requestId } = dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO)); + try { + const state = getState(); + await dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO)).unwrap(); - // Wait for successful deletion, then trigger boards to re-fetch - const wasImageDeleted = await condition( - (action) => imagesApi.endpoints.deleteImage.matchFulfilled(action) && action.meta.requestId === requestId, - 30000 - ); + 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 (wasImageDeleted) { - dispatch(api.util.invalidateTags([{ type: 'Board', id: imageDTO.board_id ?? 'none' }])); - } + if (newSelection.length > 0) { + return; + } - const lastSelectedImage = state.gallery.selection[state.gallery.selection.length - 1]?.image_name; + // 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 (imageDTO && imageDTO?.image_name === lastSelectedImage) { - const baseQueryArgs = selectListImagesQueryArgs(state); - const { data } = imagesApi.endpoints.listImages.select(baseQueryArgs)(state); + if (data) { + const deletedImageIndex = data.items.findIndex((i) => i.image_name === imageDTO.image_name); + const nextImage = data.items[deletedImageIndex + 1] ?? data.items[0] ?? null; + dispatch(imageSelected(nextImage)); + } + } - if (data && data.items) { - const newlySelectedImage = data?.items.find((img) => img.image_name !== imageDTO?.image_name); - dispatch(imageSelected(newlySelectedImage || null)); - } else { - 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 + if (imageUsage.isCanvasImage) { + dispatch(resetCanvas()); } + + deleteControlAdapterImages(state, dispatch, imageDTO); + deleteNodesImages(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 }) => { @@ -170,20 +168,18 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt } try { - // Delete from server - await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap(); const state = getState(); - const queryArgs = selectListImagesQueryArgs(state); - const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state); + await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap(); - if (data && data.items[0]) { - dispatch(imageSelected(data.items[0])); - } else { - dispatch(imageSelected(null)); + 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) { + dispatch(imageSelected(null)); + } } - dispatch(isModalOpenChanged(false)); - // We need to reset the features where the image is in use - none of these work if their image(s) don't exist if (imagesUsage.some((i) => i.isCanvasImage)) { @@ -197,14 +193,20 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt }); } catch { // no-op + } finally { + dispatch(isModalOpenChanged(false)); } }, }); + // When we list images, if no images is selected, select the first one. startAppListening({ - matcher: imagesApi.endpoints.deleteImage.matchPending, - effect: () => { - // + 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)); + } }, });