feat(ui): wip canvas migration 4

This commit is contained in:
psychedelicious 2023-05-03 23:38:00 +10:00
parent 6ab5d28cf3
commit ed1f096a6f
20 changed files with 336 additions and 313 deletions

View File

@ -7,14 +7,20 @@ import {
import type { TypedStartListening, TypedAddListener } from '@reduxjs/toolkit';
import type { RootState, AppDispatch } from '../../store';
import { initialImageSelected } from 'features/parameters/store/actions';
import { initialImageListener } from './listeners/initialImageListener';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addImageResultReceivedListener } from './listeners/invocationComplete';
import { addImageUploadedListener } from './listeners/imageUploaded';
import { addRequestedImageDeletionListener } from './listeners/imageDeleted';
import {
imageResultReceivedListener,
imageResultReceivedPrediate,
} from './listeners/invocationCompleteListener';
import { imageUploaded } from 'services/thunks/image';
import { imageUploadedListener } from './listeners/imageUploadedListener';
canvasGraphBuilt,
sessionCreated,
sessionInvoked,
} from 'services/thunks/session';
import { tabMap } from 'features/ui/store/tabMap';
import {
canvasSessionIdChanged,
stagingAreaInitialized,
} from 'features/canvas/store/canvasSlice';
export const listenerMiddleware = createListenerMiddleware();
@ -34,26 +40,30 @@ export type AppListenerEffect = ListenerEffect<
AppDispatch
>;
/**
* Initial image selected
*/
startAppListening({
actionCreator: initialImageSelected,
effect: initialImageListener,
});
addImageUploadedListener();
addInitialImageSelectedListener();
addImageResultReceivedListener();
addRequestedImageDeletionListener();
/**
* Image Result received
*/
startAppListening({
predicate: imageResultReceivedPrediate,
effect: imageResultReceivedListener,
});
actionCreator: canvasGraphBuilt.fulfilled,
effect: async (action, { dispatch, getState, condition, fork, take }) => {
const [{ meta }] = await take(sessionInvoked.fulfilled.match);
const { sessionId } = meta.arg;
const state = getState();
/**
* Image Uploaded
*/
startAppListening({
actionCreator: imageUploaded.fulfilled,
effect: imageUploadedListener,
if (!state.canvas.layerState.stagingArea.boundingBox) {
dispatch(
stagingAreaInitialized({
sessionId,
boundingBox: {
...state.canvas.boundingBoxCoordinates,
...state.canvas.boundingBoxDimensions,
},
})
);
}
dispatch(canvasSessionIdChanged(sessionId));
},
});

View File

@ -0,0 +1,59 @@
import { requestedImageDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import { imageDeleted } from 'services/thunks/image';
import { log } from 'app/logging/useLogger';
import { clamp } from 'lodash-es';
import { imageSelected } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'addRequestedImageDeletionListener' });
export const addRequestedImageDeletionListener = () => {
startAppListening({
actionCreator: requestedImageDeletion,
effect: (action, { dispatch, getState }) => {
const image = action.payload;
if (!image) {
moduleLog.warn('No image provided');
return;
}
const { name, type } = image;
if (type !== 'uploads' && type !== 'results') {
moduleLog.warn({ data: image }, `Invalid image type ${type}`);
return;
}
const selectedImageName = getState().gallery.selectedImage?.name;
if (selectedImageName === name) {
const allIds = getState()[type].ids;
const allEntities = getState()[type].entities;
const deletedImageIndex = allIds.findIndex(
(result) => result.toString() === name
);
const filteredIds = allIds.filter((id) => id.toString() !== name);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
const newSelectedImage = allEntities[newSelectedImageId];
if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImage));
} else {
dispatch(imageSelected());
}
}
dispatch(imageDeleted({ imageName: name, imageType: type }));
},
});
};

View File

@ -0,0 +1,22 @@
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import { startAppListening } from '..';
import { uploadAdded } from 'features/gallery/store/uploadsSlice';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageUploaded } from 'services/thunks/image';
export const addImageUploadedListener = () => {
startAppListening({
actionCreator: imageUploaded.fulfilled,
effect: (action, { dispatch, getState }) => {
const { response } = action.payload;
const state = getState();
const image = deserializeImageResponse(response);
dispatch(uploadAdded(image));
if (state.gallery.shouldAutoSwitchToNewImages) {
dispatch(imageSelected(image));
}
},
});
};

View File

@ -1,19 +0,0 @@
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import { AppListenerEffect } from '..';
import { uploadAdded } from 'features/gallery/store/uploadsSlice';
import { imageSelected } from 'features/gallery/store/gallerySlice';
export const imageUploadedListener: AppListenerEffect = (
action,
{ dispatch, getState }
) => {
const { response } = action.payload;
const state = getState();
const image = deserializeImageResponse(response);
dispatch(uploadAdded(image));
if (state.gallery.shouldAutoSwitchToNewImages) {
dispatch(imageSelected(image));
}
};

View File

@ -1,53 +0,0 @@
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { Image, isInvokeAIImage } from 'app/types/invokeai';
import { selectResultsById } from 'features/gallery/store/resultsSlice';
import { selectUploadsById } from 'features/gallery/store/uploadsSlice';
import { makeToast } from 'features/system/hooks/useToastWatcher';
import { t } from 'i18next';
import { addToast } from 'features/system/store/systemSlice';
import { AnyAction, ListenerEffect } from '@reduxjs/toolkit';
import { AppDispatch, RootState } from 'app/store/store';
export const initialImageListener: ListenerEffect<
AnyAction,
RootState,
AppDispatch
> = (action, { getState, dispatch }) => {
if (!action.payload) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
if (isInvokeAIImage(action.payload)) {
dispatch(initialImageChanged(action.payload));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
return;
}
const { name, type } = action.payload;
let image: Image | undefined;
const state = getState();
if (type === 'results') {
image = selectResultsById(state, name);
} else if (type === 'uploads') {
image = selectUploadsById(state, name);
}
if (!image) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
dispatch(initialImageChanged(image));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
};

View File

@ -0,0 +1,54 @@
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { Image, isInvokeAIImage } from 'app/types/invokeai';
import { selectResultsById } from 'features/gallery/store/resultsSlice';
import { selectUploadsById } from 'features/gallery/store/uploadsSlice';
import { makeToast } from 'features/system/hooks/useToastWatcher';
import { t } from 'i18next';
import { addToast } from 'features/system/store/systemSlice';
import { startAppListening } from '..';
import { initialImageSelected } from 'features/parameters/store/actions';
export const addInitialImageSelectedListener = () => {
startAppListening({
actionCreator: initialImageSelected,
effect: (action, { getState, dispatch }) => {
if (!action.payload) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
if (isInvokeAIImage(action.payload)) {
dispatch(initialImageChanged(action.payload));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
return;
}
const { name, type } = action.payload;
let image: Image | undefined;
const state = getState();
if (type === 'results') {
image = selectResultsById(state, name);
} else if (type === 'uploads') {
image = selectUploadsById(state, name);
}
if (!image) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
dispatch(initialImageChanged(image));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
},
});
};

View File

@ -0,0 +1,88 @@
import { invocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/types/guards';
import {
buildImageUrls,
extractTimestampFromImageName,
} from 'services/util/deserializeImageField';
import { Image } from 'app/types/invokeai';
import { resultAdded } from 'features/gallery/store/resultsSlice';
import { imageReceived, thumbnailReceived } from 'services/thunks/image';
import { startAppListening } from '..';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
const nodeDenylist = ['dataURL_image'];
export const addImageResultReceivedListener = () => {
startAppListening({
predicate: (action) => {
if (
invocationComplete.match(action) &&
isImageOutput(action.payload.data.result)
) {
return true;
}
return false;
},
effect: (action, { getState, dispatch }) => {
if (!invocationComplete.match(action)) {
return;
}
const { data, shouldFetchImages } = action.payload;
const { result, node, graph_execution_state_id } = data;
if (isImageOutput(result) && !nodeDenylist.includes(node.type)) {
const name = result.image.image_name;
const type = result.image.image_type;
const state = getState();
// if we need to refetch, set URLs to placeholder for now
const { url, thumbnail } = shouldFetchImages
? { url: '', thumbnail: '' }
: buildImageUrls(type, name);
const timestamp = extractTimestampFromImageName(name);
const image: Image = {
name,
type,
url,
thumbnail,
metadata: {
created: timestamp,
width: result.width,
height: result.height,
invokeai: {
session_id: graph_execution_state_id,
...(node ? { node } : {}),
},
},
};
dispatch(resultAdded(image));
if (state.gallery.shouldAutoSwitchToNewImages) {
dispatch(imageSelected(image));
}
if (state.config.shouldFetchImages) {
dispatch(imageReceived({ imageName: name, imageType: type }));
dispatch(
thumbnailReceived({
thumbnailName: name,
thumbnailType: type,
})
);
}
if (
graph_execution_state_id ===
state.canvas.layerState.stagingArea.sessionId
) {
dispatch(addImageToStagingArea(image));
}
}
},
});
};

View File

@ -1,75 +0,0 @@
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { invocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/types/guards';
import { RootState } from 'app/store/store';
import {
buildImageUrls,
extractTimestampFromImageName,
} from 'services/util/deserializeImageField';
import { Image } from 'app/types/invokeai';
import { resultAdded } from 'features/gallery/store/resultsSlice';
import { imageReceived, thumbnailReceived } from 'services/thunks/image';
import { AppListenerEffect } from '..';
export const imageResultReceivedPrediate: AnyListenerPredicate<RootState> = (
action,
_currentState,
_originalState
) => {
if (
invocationComplete.match(action) &&
isImageOutput(action.payload.data.result)
) {
return true;
}
return false;
};
export const imageResultReceivedListener: AppListenerEffect = (
action,
{ getState, dispatch }
) => {
const { data, shouldFetchImages } = action.payload;
const { result, node, graph_execution_state_id } = data;
if (isImageOutput(result)) {
const name = result.image.image_name;
const type = result.image.image_type;
const state = getState();
// if we need to refetch, set URLs to placeholder for now
const { url, thumbnail } = shouldFetchImages
? { url: '', thumbnail: '' }
: buildImageUrls(type, name);
const timestamp = extractTimestampFromImageName(name);
const image: Image = {
name,
type,
url,
thumbnail,
metadata: {
created: timestamp,
width: result.width,
height: result.height,
invokeai: {
session_id: graph_execution_state_id,
...(node ? { node } : {}),
},
},
};
dispatch(resultAdded(image));
if (state.config.shouldFetchImages) {
dispatch(imageReceived({ imageName: name, imageType: type }));
dispatch(
thumbnailReceived({
thumbnailName: name,
thumbnailType: type,
})
);
}
}
};

View File

@ -12,18 +12,20 @@ const selector = createSelector(
[canvasSelector],
(canvas) => {
const {
layerState: {
stagingArea: { images, selectedImageIndex },
},
layerState,
shouldShowStagingImage,
shouldShowStagingOutline,
boundingBoxCoordinates: { x, y },
boundingBoxDimensions: { width, height },
} = canvas;
const { selectedImageIndex, images } = layerState.stagingArea;
return {
currentStagingAreaImage:
images.length > 0 ? images[selectedImageIndex] : undefined,
images.length > 0 && selectedImageIndex !== undefined
? images[selectedImageIndex]
: undefined,
isOnFirstImage: selectedImageIndex === 0,
isOnLastImage: selectedImageIndex === images.length - 1,
shouldShowStagingImage,

View File

@ -22,6 +22,7 @@ import {
CanvasLayer,
CanvasLayerState,
CanvasMaskLine,
CanvasSession,
CanvasState,
CanvasTool,
Dimensions,
@ -29,6 +30,7 @@ import {
isCanvasBaseImage,
isCanvasMaskLine,
} from './canvasTypes';
import { stringToArray } from 'konva/lib/shapes/Text';
export const initialLayerState: CanvasLayerState = {
objects: [],
@ -285,16 +287,28 @@ export const canvasSlice = createSlice({
setIsMoveStageKeyHeld: (state, action: PayloadAction<boolean>) => {
state.isMoveStageKeyHeld = action.payload;
},
addImageToStagingArea: (
canvasSessionIdChanged: (state, action: PayloadAction<string>) => {
state.layerState.stagingArea.sessionId = action.payload;
},
stagingAreaInitialized: (
state,
action: PayloadAction<{
boundingBox: IRect;
image: InvokeAI.Image;
}>
action: PayloadAction<{ sessionId: string; boundingBox: IRect }>
) => {
const { boundingBox, image } = action.payload;
const { sessionId, boundingBox } = action.payload;
if (!boundingBox || !image) return;
state.layerState.stagingArea = {
boundingBox,
sessionId,
images: [],
selectedImageIndex: -1,
};
},
addImageToStagingArea: (state, action: PayloadAction<InvokeAI.Image>) => {
const image = action.payload;
if (!image || !state.layerState.stagingArea.boundingBox) {
return;
}
state.pastLayerStates.push(cloneDeep(state.layerState));
@ -305,7 +319,7 @@ export const canvasSlice = createSlice({
state.layerState.stagingArea.images.push({
kind: 'image',
layer: 'base',
...boundingBox,
...state.layerState.stagingArea.boundingBox,
image,
});
@ -321,9 +335,7 @@ export const canvasSlice = createSlice({
state.pastLayerStates.shift();
}
state.layerState.stagingArea = {
...initialLayerState.stagingArea,
};
state.layerState.stagingArea = { ...initialLayerState.stagingArea };
state.futureLayerStates = [];
state.shouldShowStagingOutline = true;
@ -661,6 +673,10 @@ export const canvasSlice = createSlice({
}
},
nextStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const currentIndex = state.layerState.stagingArea.selectedImageIndex;
const length = state.layerState.stagingArea.images.length;
@ -670,6 +686,10 @@ export const canvasSlice = createSlice({
);
},
prevStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const currentIndex = state.layerState.stagingArea.selectedImageIndex;
state.layerState.stagingArea.selectedImageIndex = Math.max(
@ -678,6 +698,10 @@ export const canvasSlice = createSlice({
);
},
commitStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const { images, selectedImageIndex } = state.layerState.stagingArea;
state.pastLayerStates.push(cloneDeep(state.layerState));
@ -883,6 +907,8 @@ export const {
undo,
setScaledBoundingBoxDimensions,
setShouldRestrictStrokesToBox,
stagingAreaInitialized,
canvasSessionIdChanged,
} = canvasSlice.actions;
export default canvasSlice.reducer;

View File

@ -90,9 +90,16 @@ export type CanvasLayerState = {
stagingArea: {
images: CanvasImage[];
selectedImageIndex: number;
sessionId?: string;
boundingBox?: IRect;
};
};
export type CanvasSession = {
sessionId: string;
boundingBox: IRect;
};
// type guards
export const isCanvasMaskLine = (obj: CanvasObject): obj is CanvasMaskLine =>
obj.kind === 'line' && obj.layer === 'mask';
@ -162,5 +169,4 @@ export interface CanvasState {
stageDimensions: Dimensions;
stageScale: number;
tool: CanvasTool;
pendingBoundingBox?: IRect;
}

View File

@ -66,9 +66,9 @@ import { useCallback } from 'react';
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';
import { useParameters } from 'features/parameters/hooks/useParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { requestedImageDeletion } from '../store/actions';
const currentImageButtonsSelector = createSelector(
[
@ -376,7 +376,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(imageDeleted({ imageType: image.type, imageName: image.name }));
dispatch(requestedImageDeletion(image));
}
}, [image, canDeleteImage, dispatch]);

View File

@ -28,7 +28,6 @@ import IAIIconButton from 'common/components/IAIIconButton';
import { useGetUrl } from 'common/util/getUrl';
import { ExternalLinkIcon } from '@chakra-ui/icons';
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 { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
@ -36,6 +35,8 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash-es';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useParameters } from 'features/parameters/hooks/useParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { requestedImageDeletion } from '../store/actions';
export const selector = createSelector(
[gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector],
@ -115,7 +116,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
// Immediately deletes an image
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(imageDeleted({ imageType: image.type, imageName: image.name }));
dispatch(requestedImageDeletion(image));
}
}, [dispatch, image, canDeleteImage]);
@ -151,8 +152,8 @@ const HoverableImage = memo((props: HoverableImageProps) => {
}, [image, recallSeed]);
const handleSendToImageToImage = useCallback(() => {
sendToImageToImage(image);
}, [image, sendToImageToImage]);
dispatch(initialImageSelected(image));
}, [dispatch, image]);
const handleRecallInitialImage = useCallback(() => {
recallInitialImage(image.metadata.invokeai?.node?.image);

View File

@ -0,0 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import { Image } from 'app/types/invokeai';
import { SelectedImage } from 'features/parameters/store/actions';
export const requestedImageDeletion = createAction<
Image | SelectedImage | undefined
>('gallery/requestedImageDeletion');

View File

@ -1,9 +1,5 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { invocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/types/guards';
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import { imageUploaded } from 'services/thunks/image';
import { Image } from 'app/types/invokeai';
type GalleryImageObjectFitType = 'contain' | 'cover';
@ -67,20 +63,6 @@ export const gallerySlice = createSlice({
state.shouldUseSingleGalleryColumn = action.payload;
},
},
extraReducers(builder) {
/**
* Invocation Complete
*/
builder.addCase(invocationComplete, (state, action) => {
const { data } = action.payload;
if (isImageOutput(data.result) && state.shouldAutoSwitchToNewImages) {
state.selectedImage = {
name: data.result.image.image_name,
type: 'results',
};
}
});
},
});
export const {

View File

@ -1,17 +1,11 @@
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { Image } from 'app/types/invokeai';
import { invocationComplete } from 'services/events/actions';
import { RootState } from 'app/store/store';
import {
receivedResultImagesPage,
IMAGES_PER_PAGE,
} from 'services/thunks/gallery';
import { isImageOutput } from 'services/types/guards';
import {
buildImageUrls,
extractTimestampFromImageName,
} from 'services/util/deserializeImageField';
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import {
imageDeleted,
@ -73,44 +67,6 @@ const resultsSlice = createSlice({
state.isLoading = false;
});
// /**
// * Invocation Complete
// */
// builder.addCase(invocationComplete, (state, action) => {
// const { data, shouldFetchImages } = action.payload;
// const { result, node, graph_execution_state_id } = data;
// if (isImageOutput(result)) {
// const name = result.image.image_name;
// const type = result.image.image_type;
// // if we need to refetch, set URLs to placeholder for now
// const { url, thumbnail } = shouldFetchImages
// ? { url: '', thumbnail: '' }
// : buildImageUrls(type, name);
// const timestamp = extractTimestampFromImageName(name);
// const image: Image = {
// name,
// type,
// url,
// thumbnail,
// metadata: {
// created: timestamp,
// width: result.width,
// height: result.height,
// invokeai: {
// session_id: graph_execution_state_id,
// ...(node ? { node } : {}),
// },
// },
// };
// resultsAdapter.setOne(state, image);
// }
// });
/**
* Image Received - FULFILLED
*/
@ -142,9 +98,10 @@ const resultsSlice = createSlice({
});
/**
* Delete Image - FULFILLED
* Delete Image - PENDING
* Pre-emptively remove the image from the gallery
*/
builder.addCase(imageDeleted.fulfilled, (state, action) => {
builder.addCase(imageDeleted.pending, (state, action) => {
const { imageType, imageName } = action.meta.arg;
if (imageType === 'results') {

View File

@ -62,9 +62,10 @@ const uploadsSlice = createSlice({
});
/**
* Delete Image - FULFILLED
* Delete Image - pending
* Pre-emptively remove the image from the gallery
*/
builder.addCase(imageDeleted.fulfilled, (state, action) => {
builder.addCase(imageDeleted.pending, (state, action) => {
const { imageType, imageName } = action.meta.arg;
if (imageType === 'uploads') {

View File

@ -88,7 +88,7 @@ export const useParameters = () => {
}
dispatch(
initialImageChanged({ name: image.image_name, type: image.image_type })
initialImageSelected({ name: image.image_name, type: image.image_type })
);
toast({
title: t('toast.initialImageSet'),

View File

@ -11,6 +11,7 @@ import { getTimestamp } from 'common/util/getTimestamp';
import {
sessionInvoked,
isFulfilledSessionCreatedAction,
sessionCreated,
} from 'services/thunks/session';
import { OpenAPI } from 'services/api';
import { setEventListeners } from 'services/events/util/setEventListeners';
@ -65,7 +66,7 @@ export const socketMiddleware = () => {
socket.connect();
}
if (isFulfilledSessionCreatedAction(action)) {
if (sessionCreated.fulfilled.match(action)) {
const sessionId = action.payload.id;
const sessionLog = socketioLog.child({ sessionId });
const oldSessionId = getState().system.sessionId;

View File

@ -1,8 +1,5 @@
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger';
import { createAppAsyncThunk } from 'app/store/storeUtils';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { clamp, isString } from 'lodash-es';
import { ImagesService } from 'services/api';
import { getHeaders } from 'services/util/getHeaders';
@ -15,7 +12,7 @@ type ImageReceivedArg = Parameters<(typeof ImagesService)['getImage']>[0];
*/
export const imageReceived = createAppAsyncThunk(
'api/imageReceived',
async (arg: ImageReceivedArg, _thunkApi) => {
async (arg: ImageReceivedArg) => {
const response = await ImagesService.getImage(arg);
imagesLog.info({ arg, response }, 'Received image');
@ -33,7 +30,7 @@ type ThumbnailReceivedArg = Parameters<
*/
export const thumbnailReceived = createAppAsyncThunk(
'api/thumbnailReceived',
async (arg: ThumbnailReceivedArg, _thunkApi) => {
async (arg: ThumbnailReceivedArg) => {
const response = await ImagesService.getThumbnail(arg);
imagesLog.info({ arg, response }, 'Received thumbnail');
@ -49,7 +46,7 @@ type ImageUploadedArg = Parameters<(typeof ImagesService)['uploadImage']>[0];
*/
export const imageUploaded = createAppAsyncThunk(
'api/imageUploaded',
async (arg: ImageUploadedArg, _thunkApi) => {
async (arg: ImageUploadedArg) => {
const response = await ImagesService.uploadImage(arg);
const { location } = getHeaders(response);
@ -62,11 +59,6 @@ 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];
/**
@ -74,45 +66,7 @@ type ImageDeletedArg = Parameters<(typeof ImagesService)['deleteImage']>[0];
*/
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.selectedImage?.name;
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];
if (newSelectedImageId) {
dispatch(
imageSelected({ name: newSelectedImageId as string, type: imageType })
);
} else {
dispatch(imageSelected());
}
}
async (arg: ImageDeletedArg) => {
const response = await ImagesService.deleteImage(arg);
imagesLog.info(