refactor(ui): use image names for selection instead of dtos

Update the frontend to incorporate the previous changes to how image
selection and general image identification is handled in the frontend.
This commit is contained in:
psychedelicious
2025-06-23 22:10:58 +10:00
parent 70382294f5
commit 4665f0df40
51 changed files with 551 additions and 615 deletions

View File

@@ -7,11 +7,13 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const GlobalImageHotkeys = memo(() => {
useAssertSingleton('GlobalImageHotkeys');
const imageDTO = useAppSelector(selectLastSelectedImage);
const imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
if (!imageDTO) {
return null;

View File

@@ -25,7 +25,7 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
matcher: matchAnyBoardDeleted,
effect: (action, { dispatch, getState }) => {
const state = getState();
const deletedBoardId = action.meta.arg.originalArgs;
const deletedBoardId = action.meta.arg.originalArgs.board_id;
const { autoAddBoardId, selectedBoardId } = state.gallery;
// If the deleted board was currently selected, we should reset the selected board to uncategorized

View File

@@ -30,9 +30,9 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
const selectedImage = boardImagesData.items.find(
(item) => item.image_name === action.payload.selectedImageName
);
dispatch(imageSelected(selectedImage || null));
dispatch(imageSelected(selectedImage?.image_name ?? null));
} else if (boardImagesData) {
dispatch(imageSelected(boardImagesData.items[0] || null));
dispatch(imageSelected(boardImagesData.items[0]?.image_name ?? null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));

View File

@@ -9,7 +9,7 @@ export const addEnsureImageIsSelectedListener = (startAppListening: AppStartList
effect: (action, { dispatch, getState }) => {
const selection = getState().gallery.selection;
if (selection.length === 0) {
dispatch(imageSelected(action.payload.items[0] ?? null));
dispatch(imageSelected(action.payload.items[0]?.image_name ?? null));
}
},
});

View File

@@ -2,11 +2,11 @@ import { createAction } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import { uniq } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const galleryImageClicked = createAction<{
imageDTO: ImageDTO;
imageName: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
@@ -28,7 +28,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
startAppListening({
actionCreator: galleryImageClicked,
effect: (action, { dispatch, getState }) => {
const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
const state = getState();
const queryArgs = selectListImagesQueryArgs(state);
const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state);
@@ -42,31 +42,31 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
const selection = state.gallery.selection;
if (altKey) {
if (state.gallery.imageToCompare?.image_name === imageDTO.image_name) {
if (state.gallery.imageToCompare === imageName) {
dispatch(imageToCompareChanged(null));
} else {
dispatch(imageToCompareChanged(imageDTO));
dispatch(imageToCompareChanged(imageName));
}
} else if (shiftKey) {
const rangeEndImageName = imageDTO.image_name;
const lastSelectedImage = selection[selection.length - 1]?.image_name;
const rangeEndImageName = imageName;
const lastSelectedImage = selection.at(-1);
const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage);
const currentClickedIndex = imageDTOs.findIndex((n) => n.image_name === rangeEndImageName);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = imageDTOs.slice(start, end + 1);
dispatch(selectionChanged(selection.concat(imagesToSelect)));
const imagesToSelect = imageDTOs.slice(start, end + 1).map(({ image_name }) => image_name);
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect))));
}
} else if (ctrlKey || metaKey) {
if (selection.some((i) => i.image_name === imageDTO.image_name) && selection.length > 1) {
dispatch(selectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name)));
if (selection.some((n) => n === imageName) && selection.length > 1) {
dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName))));
} else {
dispatch(selectionChanged(selection.concat(imageDTO)));
dispatch(selectionChanged(uniq(selection.concat(imageName))));
}
} else {
dispatch(selectionChanged([imageDTO]));
dispatch(selectionChanged([imageName]));
}
},
});

View File

@@ -84,14 +84,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe
if (offset < prevOffset) {
// We've gone backwards
const lastImage = imageDTOs[imageDTOs.length - 1];
if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) {
dispatch(selectionChanged(lastImage ? [lastImage] : []));
if (!selection.some((selectedImage) => selectedImage === lastImage?.image_name)) {
dispatch(selectionChanged(lastImage ? [lastImage.image_name] : []));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) {
dispatch(selectionChanged(firstImage ? [firstImage] : []));
if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) {
dispatch(selectionChanged(firstImage ? [firstImage.image_name] : []));
}
}
return;
@@ -102,14 +102,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe
if (offset < prevOffset) {
// We've gone backwards
const lastImage = imageDTOs[imageDTOs.length - 1];
if (lastImage && imageToCompare?.image_name !== lastImage.image_name) {
dispatch(imageToCompareChanged(lastImage));
if (lastImage && imageToCompare !== lastImage.image_name) {
dispatch(imageToCompareChanged(lastImage.image_name));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (firstImage && imageToCompare?.image_name !== firstImage.image_name) {
dispatch(imageToCompareChanged(firstImage));
if (firstImage && imageToCompare !== firstImage.image_name) {
dispatch(imageToCompareChanged(firstImage.image_name));
}
}
return;

View File

@@ -8,16 +8,16 @@ export const addImageAddedToBoardFulfilledListener = (startAppListening: AppStar
startAppListening({
matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action) => {
const { board_id, imageDTO } = action.meta.arg.originalArgs;
log.debug({ board_id, imageDTO }, 'Image added to board');
const { board_id, image_name } = action.meta.arg.originalArgs;
log.debug({ board_id, image_name }, 'Image added to board');
},
});
startAppListening({
matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action) => {
const { board_id, imageDTO } = action.meta.arg.originalArgs;
log.debug({ board_id, imageDTO }, 'Problem adding image to board');
const { board_id, image_name } = action.meta.arg.originalArgs;
log.debug({ board_id, image_name }, 'Problem adding image to board');
},
});
};

View File

@@ -1,30 +1,25 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const addImagesStarredListener = (startAppListening: AppStartListening) => {
startAppListening({
matcher: imagesApi.endpoints.starImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const { updated_image_names: starredImages } = action.payload;
const state = getState();
const { selection } = state.gallery;
const updatedSelection: ImageDTO[] = [];
selection.forEach((selectedImageDTO) => {
if (starredImages.includes(selectedImageDTO.image_name)) {
updatedSelection.push({
...selectedImageDTO,
starred: true,
});
} else {
updatedSelection.push(selectedImageDTO);
}
});
dispatch(selectionChanged(updatedSelection));
// const { updated_image_names: starredImages } = action.payload;
// const state = getState();
// const { selection } = state.gallery;
// const updatedSelection: ImageDTO[] = [];
// selection.forEach((selectedImageDTO) => {
// if (starredImages.includes(selectedImageDTO.image_name)) {
// updatedSelection.push({
// ...selectedImageDTO,
// starred: true,
// });
// } else {
// updatedSelection.push(selectedImageDTO);
// }
// });
// dispatch(selectionChanged(updatedSelection));
},
});
};

View File

@@ -1,30 +1,25 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const addImagesUnstarredListener = (startAppListening: AppStartListening) => {
startAppListening({
matcher: imagesApi.endpoints.unstarImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const { updated_image_names: unstarredImages } = action.payload;
const state = getState();
const { selection } = state.gallery;
const updatedSelection: ImageDTO[] = [];
selection.forEach((selectedImageDTO) => {
if (unstarredImages.includes(selectedImageDTO.image_name)) {
updatedSelection.push({
...selectedImageDTO,
starred: false,
});
} else {
updatedSelection.push(selectedImageDTO);
}
});
dispatch(selectionChanged(updatedSelection));
// const { updated_image_names: unstarredImages } = action.payload;
// const state = getState();
// const { selection } = state.gallery;
// const updatedSelection: ImageDTO[] = [];
// selection.forEach((selectedImageDTO) => {
// if (unstarredImages.includes(selectedImageDTO.image_name)) {
// updatedSelection.push({
// ...selectedImageDTO,
// starred: false,
// });
// } else {
// updatedSelection.push(selectedImageDTO);
// }
// });
// dispatch(selectionChanged(updatedSelection));
},
});
};

View File

@@ -39,6 +39,7 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware';
import type { JsonObject } from 'type-fest';
import { STORAGE_PREFIX } from './constants';
import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware';
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
@@ -176,7 +177,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
.concat(api.middleware)
.concat(dynamicMiddlewares)
.concat(authToastMiddleware)
// .concat(getDebugLoggerMiddleware())
.concat(getDebugLoggerMiddleware())
.prepend(listenerMiddleware.middleware),
enhancers: (getDefaultEnhancers) => {
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());

View File

@@ -0,0 +1,28 @@
import type { Selector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { useAppStore } from 'app/store/storeHooks';
import type { Atom, WritableAtom } from 'nanostores';
import { atom } from 'nanostores';
import { useEffect, useState } from 'react';
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const useSelectorAsAtom = <T extends Selector<RootState, any>>(selector: T): Atom<ReturnType<T>> => {
const store = useAppStore();
const $atom = useState<WritableAtom<ReturnType<T>>>(() => atom<ReturnType<T>>(selector(store.getState())))[0];
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const prev = $atom.get();
const next = selector(store.getState());
if (prev !== next) {
$atom.set(next);
}
});
return () => {
unsubscribe();
};
}, [$atom, selector, store]);
return $atom;
};

View File

@@ -16,7 +16,7 @@ import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 's
const selectImagesToChange = createMemoizedSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.imagesToChange
(changeBoardModal) => changeBoardModal.image_names
);
const selectIsModalOpen = createSelector(
@@ -57,10 +57,10 @@ const ChangeBoardModal = () => {
}
if (selectedBoard === 'none') {
removeImagesFromBoard({ imageDTOs: imagesToChange });
removeImagesFromBoard({ image_names: imagesToChange });
} else {
addImagesToBoard({
imageDTOs: imagesToChange,
image_names: imagesToChange,
board_id: selectedBoard,
});
}

View File

@@ -2,5 +2,5 @@ import type { ChangeBoardModalState } from './types';
export const initialState: ChangeBoardModalState = {
isModalOpen: false,
imagesToChange: [],
image_names: [],
};

View File

@@ -1,7 +1,6 @@
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 { initialState } from './initialState';
@@ -12,11 +11,11 @@ export const changeBoardModalSlice = createSlice({
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isModalOpen = action.payload;
},
imagesToChangeSelected: (state, action: PayloadAction<ImageDTO[]>) => {
state.imagesToChange = action.payload;
imagesToChangeSelected: (state, action: PayloadAction<string[]>) => {
state.image_names = action.payload;
},
changeBoardReset: (state) => {
state.imagesToChange = [];
state.image_names = [];
state.isModalOpen = false;
},
},

View File

@@ -1,6 +1,4 @@
import type { ImageDTO } from 'services/api/types';
export type ChangeBoardModalState = {
isModalOpen: boolean;
imagesToChange: ImageDTO[];
image_names: string[];
};

View File

@@ -22,7 +22,7 @@ export const DeleteImageModal = memo(() => {
return (
<ConfirmationAlertDialog
title={`${t('gallery.deleteImage', { count: state.imageDTOs.length })}2`}
title={`${t('gallery.deleteImage', { count: state.image_names.length })}2`}
isOpen={state.isOpen}
onClose={api.close}
cancelButtonText={t('common.cancel')}

View File

@@ -19,17 +19,16 @@ import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from '
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 { forEach, intersection, 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[];
image_names: string[];
usagePerImage: ImageUsage[];
usageSummary: ImageUsage;
isOpen: boolean;
@@ -38,7 +37,7 @@ type DeleteImagesModalState = {
};
const getInitialState = (): DeleteImagesModalState => ({
imageDTOs: [],
image_names: [],
usagePerImage: [],
usageSummary: {
isControlLayerImage: false,
@@ -54,21 +53,21 @@ const getInitialState = (): DeleteImagesModalState => ({
const $deleteModalState = atom<DeleteImagesModalState>(getInitialState());
const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
const deleteImagesWithDialog = async (image_names: string[]): Promise<void> => {
const { getState, dispatch } = getStore();
const imageUsage = getImageUsageFromImageDTOs(imageDTOs, getState());
const imageUsage = getImageUsageFromImageNames(image_names, 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);
await handleDeletions(image_names, dispatch, getState);
}
return new Promise<void>((resolve, reject) => {
$deleteModalState.set({
usagePerImage: imageUsage,
usageSummary: getImageUsageSummary(imageUsage),
imageDTOs,
image_names,
isOpen: true,
resolve,
reject,
@@ -76,12 +75,12 @@ const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
});
};
const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, getState: AppGetState) => {
const handleDeletions = async (image_names: string[], dispatch: AppDispatch, getState: AppGetState) => {
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap();
if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) {
if (intersection(state.gallery.selection, image_names).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);
@@ -93,11 +92,11 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get
}
// 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);
for (const image_name of image_names) {
deleteNodesImages(state, dispatch, image_name);
deleteControlLayerImages(state, dispatch, image_name);
deleteReferenceImages(state, dispatch, image_name);
deleteRasterLayerImages(state, dispatch, image_name);
}
} catch {
// no-op
@@ -106,7 +105,7 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get
const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => {
const state = $deleteModalState.get();
await handleDeletions(state.imageDTOs, dispatch, getState);
await handleDeletions(state.image_names, dispatch, getState);
state.resolve?.();
closeSilently();
};
@@ -142,8 +141,8 @@ export const useDeleteImageModalApi = () => {
return api;
};
const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): ImageUsage[] => {
if (imageDTOs.length === 0) {
const getImageUsageFromImageNames = (image_names: string[], state: RootState): ImageUsage[] => {
if (image_names.length === 0) {
return [];
}
@@ -152,7 +151,7 @@ const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): Im
const upscale = selectUpscaleSlice(state);
const refImages = selectRefImagesSlice(state);
return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
return image_names.map((image_name) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
};
const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({
@@ -178,7 +177,7 @@ const isAnyImageInUse = (imageUsage: ImageUsage[]): boolean =>
);
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
const actions: Param0<typeof dispatch>[] = [];
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
@@ -186,7 +185,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
}
forEach(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
if (isImageFieldInputInstance(input) && input.value?.image_name === image_name) {
actions.push(
fieldImageValueChanged({
nodeId: node.data.id,
@@ -201,7 +200,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
fieldImageCollectionValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
value: input.value?.filter((value) => value?.image_name !== image_name),
})
);
}
@@ -211,11 +210,11 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
actions.forEach(dispatch);
};
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
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) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) {
shouldDelete = true;
break;
}
@@ -226,19 +225,19 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image
});
};
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
selectReferenceImageEntities(state).forEach((entity) => {
if (entity.config.image?.image_name === imageDTO.image_name) {
if (entity.config.image?.image_name === image_name) {
dispatch(refImageImageChanged({ id: entity.id, imageDTO: null }));
}
});
};
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
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) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) {
shouldDelete = true;
break;
}

View File

@@ -6,10 +6,9 @@ import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } f
import { memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[] }) => {
const DndDragPreviewMultipleImage = memo(({ image_names }: { image_names: string[] }) => {
const { t } = useTranslation();
return (
<Flex
@@ -21,7 +20,7 @@ const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[]
bg="base.900"
borderRadius="base"
>
<Heading>{imageDTOs.length}</Heading>
<Heading>{image_names.length}</Heading>
<Heading size="sm">{t('parameters.images')}</Heading>
</Flex>
);
@@ -32,11 +31,11 @@ DndDragPreviewMultipleImage.displayName = 'DndDragPreviewMultipleImage';
export type DndDragPreviewMultipleImageState = {
type: 'multiple-image';
container: HTMLElement;
imageDTOs: ImageDTO[];
image_names: string[];
};
export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageState) =>
createPortal(<DndDragPreviewMultipleImage imageDTOs={arg.imageDTOs} />, arg.container);
createPortal(<DndDragPreviewMultipleImage image_names={arg.image_names} />, arg.container);
type SetMultipleDragPreviewArg = {
multipleImageDndData: MultipleImageDndSourceData;
@@ -52,7 +51,7 @@ export const setMultipleImageDragPreview = ({
const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
setCustomNativeDragPreview({
render({ container }) {
setDragPreviewState({ type: 'multiple-image', container, imageDTOs: multipleImageDndData.payload.imageDTOs });
setDragPreviewState({ type: 'multiple-image', container, image_names: multipleImageDndData.payload.image_names });
return () => setDragPreviewState(null);
},
nativeSetDragImage,

View File

@@ -87,7 +87,7 @@ const _multipleImage = buildTypeAndKey('multiple-image');
export type MultipleImageDndSourceData = DndData<
typeof _multipleImage.type,
typeof _multipleImage.key,
{ imageDTOs: ImageDTO[]; boardId: BoardId }
{ image_names: string[]; board_id: BoardId }
>;
export const multipleImageDndSource: DndSource<MultipleImageDndSourceData> = {
..._multipleImage,
@@ -305,7 +305,7 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget<
if (singleImageDndSource.typeGuard(sourceData)) {
newValue.push({ image_name: sourceData.payload.imageDTO.image_name });
} else {
newValue.push(...sourceData.payload.imageDTOs.map(({ image_name }) => ({ image_name })));
newValue.push(...sourceData.payload.image_names.map((image_name) => ({ image_name })));
}
dispatch(fieldImageCollectionValueChanged({ ...fieldIdentifier, value: newValue }));
@@ -330,17 +330,17 @@ export const setComparisonImageDndTarget: DndTarget<SetComparisonImageDndTargetD
}
const { firstImage, secondImage } = selectComparisonImages(getState());
// Do not allow the same images to be selected for comparison
if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) {
if (sourceData.payload.imageDTO.image_name === firstImage) {
return false;
}
if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) {
if (sourceData.payload.imageDTO.image_name === secondImage) {
return false;
}
return true;
},
handler: ({ sourceData, dispatch }) => {
const { imageDTO } = sourceData.payload;
setComparisonImage({ imageDTO, dispatch });
setComparisonImage({ image_name: imageDTO.image_name, dispatch });
},
};
//#endregion
@@ -450,7 +450,7 @@ export const addImageToBoardDndTarget: DndTarget<
return currentBoard !== destinationBoard;
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
const currentBoard = sourceData.payload.board_id;
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
@@ -460,13 +460,13 @@ export const addImageToBoardDndTarget: DndTarget<
if (singleImageDndSource.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
const { boardId } = targetData.payload;
addImagesToBoard({ imageDTOs: [imageDTO], boardId, dispatch });
addImagesToBoard({ image_names: [imageDTO.image_name], boardId, dispatch });
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const { imageDTOs } = sourceData.payload;
const { image_names } = sourceData.payload;
const { boardId } = targetData.payload;
addImagesToBoard({ imageDTOs, boardId, dispatch });
addImagesToBoard({ image_names, boardId, dispatch });
}
},
};
@@ -494,7 +494,7 @@ export const removeImageFromBoardDndTarget: DndTarget<
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
const currentBoard = sourceData.payload.board_id;
return currentBoard !== 'none';
}
@@ -503,12 +503,12 @@ export const removeImageFromBoardDndTarget: DndTarget<
handler: ({ sourceData, dispatch }) => {
if (singleImageDndSource.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
removeImagesFromBoard({ imageDTOs: [imageDTO], dispatch });
removeImagesFromBoard({ image_names: [imageDTO.image_name], dispatch });
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const { imageDTOs } = sourceData.payload;
removeImagesFromBoard({ imageDTOs, dispatch });
const { image_names } = sourceData.payload;
removeImagesFromBoard({ image_names, dispatch });
}
},
};

View File

@@ -91,7 +91,7 @@ const DeleteBoardModal = () => {
if (!boardToDelete || boardToDelete === 'none') {
return;
}
deleteBoardOnly(boardToDelete.board_id);
deleteBoardOnly({ board_id: boardToDelete.board_id });
$boardToDelete.set(null);
}, [boardToDelete, deleteBoardOnly]);
@@ -99,7 +99,7 @@ const DeleteBoardModal = () => {
if (!boardToDelete || boardToDelete === 'none') {
return;
}
deleteBoardAndImages(boardToDelete.board_id);
deleteBoardAndImages({ board_id: boardToDelete.board_id });
$boardToDelete.set(null);
}, [boardToDelete, deleteBoardAndImages]);

View File

@@ -12,7 +12,7 @@ export const ImageMenuItemChangeBoard = memo(() => {
const imageDTO = useImageDTOContext();
const onClick = useCallback(() => {
dispatch(imagesToChangeSelected([imageDTO]));
dispatch(imagesToChangeSelected([imageDTO.image_name]));
dispatch(isModalOpenChanged(true));
}, [dispatch, imageDTO]);

View File

@@ -12,7 +12,7 @@ export const ImageMenuItemDelete = memo(() => {
const onClick = useCallback(async () => {
try {
await deleteImageModal.delete([imageDTO]);
await deleteImageModal.delete([imageDTO.image_name]);
} catch {
// noop;
}

View File

@@ -12,7 +12,7 @@ export const ImageMenuItemOpenInViewer = memo(() => {
const imageDTO = useImageDTOContext();
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO));
dispatch(imageSelected(imageDTO.image_name));
// TODO: figure out how to select the closest image viewer...
}, [dispatch, imageDTO]);

View File

@@ -12,13 +12,13 @@ export const ImageMenuItemSelectForCompare = memo(() => {
const dispatch = useAppDispatch();
const imageDTO = useImageDTOContext();
const selectMaySelectForCompare = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name !== imageDTO.image_name),
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare !== imageDTO.image_name),
[imageDTO.image_name]
);
const maySelectForCompare = useAppSelector(selectMaySelectForCompare);
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(imageDTO));
dispatch(imageToCompareChanged(imageDTO.image_name));
}, [dispatch, imageDTO]);
return (

View File

@@ -16,13 +16,13 @@ export const ImageMenuItemStarUnstar = memo(() => {
const starImage = useCallback(() => {
if (imageDTO) {
starImages({ imageDTOs: [imageDTO] });
starImages({ image_names: [imageDTO.image_name] });
}
}, [starImages, imageDTO]);
const unstarImage = useCallback(() => {
if (imageDTO) {
unstarImages({ imageDTOs: [imageDTO] });
unstarImages({ image_names: [imageDTO.image_name] });
}
}, [unstarImages, imageDTO]);

View File

@@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold, PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi';
import {
@@ -37,37 +37,25 @@ const MultipleSelectionMenuItems = () => {
}, [deleteImageModal, selection]);
const handleStarSelection = useCallback(() => {
starImages({ imageDTOs: selection });
starImages({ image_names: selection });
}, [starImages, selection]);
const handleUnstarSelection = useCallback(() => {
unstarImages({ imageDTOs: selection });
unstarImages({ image_names: selection });
}, [unstarImages, selection]);
const handleBulkDownload = useCallback(() => {
bulkDownload({ image_names: selection.map((img) => img.image_name) });
bulkDownload({ image_names: selection });
}, [selection, bulkDownload]);
const areAllStarred = useMemo(() => {
return selection.every((img) => img.starred);
}, [selection]);
const areAllUnstarred = useMemo(() => {
return selection.every((img) => !img.starred);
}, [selection]);
return (
<>
{areAllStarred && (
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarBold />} onClickCapture={handleUnstarSelection}>
{customStarUi ? customStarUi.off.text : `Unstar All`}
</MenuItem>
)}
{(areAllUnstarred || (!areAllStarred && !areAllUnstarred)) && (
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarFill />} onClickCapture={handleStarSelection}>
{customStarUi ? customStarUi.on.text : `Star All`}
</MenuItem>
)}
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarBold />} onClickCapture={handleUnstarSelection}>
{customStarUi ? customStarUi.off.text : `Unstar All`}
</MenuItem>
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarFill />} onClickCapture={handleStarSelection}>
{customStarUi ? customStarUi.on.text : `Star All`}
</MenuItem>
{isBulkDownloadEnabled && (
<MenuItem icon={<PiDownloadSimpleBold />} onClickCapture={handleBulkDownload}>
{t('gallery.downloadSelection')}

View File

@@ -93,7 +93,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
const ref = useRef<HTMLImageElement>(null);
const dndId = useId();
const selectIsSelectedForCompare = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name),
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare === imageDTO.image_name),
[imageDTO.image_name]
);
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
@@ -101,7 +101,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
() =>
createSelector(selectGallerySlice, (gallery) => {
for (const selectedImage of gallery.selection) {
if (selectedImage.image_name === imageDTO.image_name) {
if (selectedImage === imageDTO.image_name) {
return true;
}
}
@@ -126,11 +126,11 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
// multi-image drag.
if (
gallery.selection.length > 1 &&
gallery.selection.find(({ image_name }) => image_name === imageDTO.image_name) !== undefined
gallery.selection.find((image_name) => image_name === imageDTO.image_name) !== undefined
) {
return multipleImageDndSource.getData({
imageDTOs: gallery.selection,
boardId: gallery.selectedBoardId,
image_names: gallery.selection,
board_id: gallery.selectedBoardId,
});
}
@@ -167,7 +167,10 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
onDragStart: ({ source }) => {
// When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
// selection. This is called for all drag events.
if (multipleImageDndSource.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) {
if (
multipleImageDndSource.typeGuard(source.data) &&
source.data.payload.image_names.includes(imageDTO.image_name)
) {
setIsDragging(true);
}
},
@@ -193,7 +196,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
(e) => {
store.dispatch(
galleryImageClicked({
imageDTO,
imageName: imageDTO.image_name,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,

View File

@@ -22,7 +22,7 @@ export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
if (!imageDTO) {
return;
}
deleteImageModal.delete([imageDTO]);
deleteImageModal.delete([imageDTO.image_name]);
},
[deleteImageModal, imageDTO]
);

View File

@@ -19,7 +19,7 @@ export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) =>
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO));
dispatch(imageSelected(imageDTO.image_name));
focusPanel(VIEWER_PANEL_ID);
}, [dispatch, focusPanel, imageDTO]);

View File

@@ -17,9 +17,9 @@ export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => {
const toggleStarredState = useCallback(() => {
if (imageDTO.starred) {
unstarImages({ imageDTOs: [imageDTO] });
unstarImages({ image_names: [imageDTO.image_name] });
} else {
starImages({ imageDTOs: [imageDTO] });
starImages({ image_names: [imageDTO.image_name] });
}
}, [starImages, unstarImages, imageDTO]);

View File

@@ -19,7 +19,7 @@ export const GallerySelectionCountTag = memo(() => {
const isGalleryFocused = useIsRegionFocused('gallery');
const onSelectPage = useCallback(() => {
dispatch(selectionChanged([...selection, ...imageDTOs]));
dispatch(selectionChanged([...selection, ...imageDTOs.map(({ image_name }) => image_name)]));
}, [dispatch, selection, imageDTOs]);
useRegisteredHotkeys({

View File

@@ -1,6 +1,5 @@
import { Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
@@ -10,6 +9,7 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -21,14 +21,22 @@ import {
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useImageDTO } from 'services/api/endpoints/images';
import { useImageViewerContext } from './ImageViewerPanel';
export const CurrentImageButtons = memo(() => {
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
const { t } = useTranslation();
const ctx = useImageViewerContext();
const hasProgressImage = useStore(ctx.$hasProgressImage);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
const imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
const hasTemplates = useStore($hasTemplates);
const imageActions = useImageActions(imageDTO ?? null);
const imageActions = useImageActions(imageDTO);
const isStaging = useAppSelector(selectIsStaging);
const isUpscalingEnabled = useFeatureStatus('upscaling');
@@ -39,7 +47,7 @@ export const CurrentImageButtons = memo(() => {
as={IconButton}
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={!imageDTO}
isDisabled={isDisabledOverride || !imageDTO}
variant="link"
alignSelf="stretch"
icon={<PiDotsThreeOutlineFill />}
@@ -53,7 +61,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!imageDTO || !imageActions.hasWorkflow || !hasTemplates}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasWorkflow || !hasTemplates}
variant="link"
alignSelf="stretch"
onClick={imageActions.loadWorkflow}
@@ -62,7 +70,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiArrowsCounterClockwiseBold />}
tooltip={`${t('parameters.remixImage')} (R)`}
aria-label={`${t('parameters.remixImage')} (R)`}
isDisabled={!imageDTO || !imageActions.hasMetadata}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
variant="link"
alignSelf="stretch"
onClick={imageActions.remix}
@@ -71,7 +79,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!imageDTO || !imageActions.hasPrompts}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasPrompts}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallPrompts}
@@ -80,7 +88,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!imageDTO || !imageActions.hasSeed}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasSeed}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallSeed}
@@ -92,23 +100,23 @@ export const CurrentImageButtons = memo(() => {
variant="link"
alignSelf="stretch"
onClick={imageActions.recallSize}
isDisabled={!imageDTO || isStaging}
isDisabled={isDisabledOverride || !imageDTO || isStaging}
/>
<IconButton
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!imageDTO || !imageActions.hasMetadata}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallAll}
/>
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} />}
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={isDisabledOverride} />}
<Divider orientation="vertical" h={8} mx={2} />
<DeleteImageButton onClick={imageActions.delete} isDisabled={!imageDTO} />
<DeleteImageButton onClick={imageActions.delete} isDisabled={isDisabledOverride || !imageDTO} />
</>
);
});

View File

@@ -6,7 +6,7 @@ import { DndImage } from 'features/dnd/DndImage';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors';
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
@@ -20,6 +20,8 @@ import { ProgressIndicator } from './ProgressIndicator2';
export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => {
const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const socket = useStore($socket);
const [progressEvent, setProgressEvent] = useState<S['InvocationProgressEvent'] | null>(null);
const [progressImage, setProgressImage] = useState<ProgressImageType | null>(null);
@@ -65,6 +67,8 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
}
}, [imageDTO, progressEvent]);
const withProgress = shouldShowProgressInViewer && progressEvent && progressImage;
return (
<Flex
onMouseOver={onMouseOver}
@@ -75,14 +79,13 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
justifyContent="center"
position="relative"
>
{imageDTO ? (
{imageDTO && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} />
</Flex>
) : (
<NoContentForViewer />
)}
{progressEvent && progressImage && (
{!imageDTO && <NoContentForViewer />}
{withProgress && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center" bg="base.900">
<ProgressImage progressImage={progressImage} />
<ProgressIndicator progressEvent={progressEvent} position="absolute" top={6} right={6} size={8} />
@@ -90,9 +93,9 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
)}
<Flex flexDir="column" gap={2} position="absolute" top={0} insetInlineStart={0} alignItems="flex-start">
<CanvasAlertsInvocationProgress />
{imageDTO && <ImageMetadataMini imageName={imageDTO.image_name} />}
{imageDTO && !withProgress && <ImageMetadataMini imageName={imageDTO.image_name} />}
</Flex>
{shouldShowImageDetails && imageDTO && (
{shouldShowImageDetails && imageDTO && !withProgress && (
<Box position="absolute" opacity={0.8} top={0} width="full" height="full" borderRadius="base">
<ImageMetadataViewer image={imageDTO} />
</Box>

View File

@@ -1,11 +1,10 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview';
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useImageDTO } from 'services/api/endpoints/images';
// type Props = {
// closeButton?: ReactNode;
@@ -28,9 +27,10 @@ import { useGetImageDTOQuery } from 'services/api/endpoints/images';
// };
export const ImageViewer = memo(() => {
const lastSelectedImageName = useAppSelector(selectLastSelectedImageName);
const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken);
const comparisonImageDTO = useAppSelector(selectImageToCompare);
const lastSelectedImageName = useAppSelector(selectLastSelectedImage);
const lastSelectedImageDTO = useImageDTO(lastSelectedImageName);
const comparisonImageName = useAppSelector(selectImageToCompare);
const comparisonImageDTO = useImageDTO(comparisonImageName);
if (lastSelectedImageDTO && comparisonImageDTO) {
return <ImageComparison firstImage={lastSelectedImageDTO} secondImage={comparisonImageDTO} />;

View File

@@ -1,13 +1,86 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
import { memo } from 'react';
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
import { type Atom, atom, computed } from 'nanostores';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ImageDTO, S } from 'services/api/types';
import { $socket } from 'services/events/stores';
import { assert } from 'tsafe';
export const ImageViewerPanel = memo(() => (
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2} gap={2}>
<ViewerToolbar />
<Divider />
<ImageViewer />
</Flex>
));
export const ImageViewerPanel = memo(() => {
return (
<ImageViewerContextProvider>
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2} gap={2}>
<ViewerToolbar />
<Divider />
<ImageViewer />
</Flex>
</ImageViewerContextProvider>
);
});
ImageViewerPanel.displayName = 'ImageViewerPanel';
type ImageViewerContextValue = {
$progressEvent: Atom<S['InvocationProgressEvent'] | null>;
$progressImage: Atom<ProgressImageType | null>;
$hasProgressImage: Atom<boolean>;
onLoadImage: (imageDTO: ImageDTO) => void;
};
const ImageViewerContext = createContext<ImageViewerContextValue | null>(null);
const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
const socket = useStore($socket);
const $progressEvent = useState(() => atom<S['InvocationProgressEvent'] | null>(null))[0];
const $progressImage = useState(() => atom<ProgressImageType | null>(null))[0];
const $hasProgressImage = useState(() => computed($progressImage, (progressImage) => progressImage !== null))[0];
useEffect(() => {
if (!socket) {
return;
}
const onInvocationProgress = (data: S['InvocationProgressEvent']) => {
$progressEvent.set(data);
if (data.image) {
$progressImage.set(data.image);
}
};
socket.on('invocation_progress', onInvocationProgress);
return () => {
socket.off('invocation_progress', onInvocationProgress);
};
}, [$progressEvent, $progressImage, socket]);
const onLoadImage = useCallback(
(imageDTO: ImageDTO) => {
const progressEvent = $progressEvent.get();
if (!progressEvent || !imageDTO) {
return;
}
if (progressEvent.session_id === imageDTO.session_id) {
$progressImage.set(null);
}
},
[$progressEvent, $progressImage]
);
const value = useMemo(
() => ({ $progressEvent, $progressImage, $hasProgressImage, onLoadImage }),
[$hasProgressImage, $progressEvent, $progressImage, onLoadImage]
);
return <ImageViewerContext.Provider value={value}>{props.children}</ImageViewerContext.Provider>;
});
ImageViewerContextProvider.displayName = 'ImageViewerContextProvider';
export const useImageViewerContext = () => {
const value = useContext(ImageViewerContext);
assert(value !== null, 'useImageViewerContext must be used within a ImageViewerContextProvider');
return value;
};

View File

@@ -1,15 +1,24 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors';
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import { setShouldShowImageDetails } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiInfoBold } from 'react-icons/pi';
import { useImageViewerContext } from './ImageViewerPanel';
export const ToggleMetadataViewerButton = memo(() => {
const dispatch = useAppDispatch();
const ctx = useImageViewerContext();
const hasProgressImage = useStore(ctx.$hasProgressImage);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails);
const imageDTO = useAppSelector(selectLastSelectedImage);
const { t } = useTranslation();
@@ -35,6 +44,7 @@ export const ToggleMetadataViewerButton = memo(() => {
alignSelf="stretch"
colorScheme={shouldShowImageDetails ? 'invokeBlue' : 'base'}
data-testid="toggle-show-metadata-button"
isDisabled={isDisabledOverride}
/>
);
});

View File

@@ -1,16 +1,18 @@
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
import { memo } from 'react';
import { CurrentImageButtons } from './CurrentImageButtons';
import { ToggleProgressButton } from './ToggleProgressButton';
export const ViewerToolbar = memo(() => {
return (
<Flex w="full" justifyContent="center" h={8}>
<ButtonGroup>
<ToggleMetadataViewerButton />
<CurrentImageButtons />
</ButtonGroup>
<ToggleProgressButton />
<Spacer />
<CurrentImageButtons />
<Spacer />
<ToggleMetadataViewerButton />
</Flex>
);
});

View File

@@ -177,7 +177,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
if (imageDTOs.length === 0 || !lastSelectedImage) {
return 0;
}
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage.image_name);
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage);
}, [imageDTOs, lastSelectedImage]);
const handleNavigation = useCallback(
@@ -187,9 +187,9 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
return;
}
if (alt) {
dispatch(imageToCompareChanged(image));
dispatch(imageToCompareChanged(image.image_name));
} else {
dispatch(imageSelected(image));
dispatch(imageSelected(image.image_name));
}
scrollToImage(image.image_name, index);
},

View File

@@ -199,7 +199,7 @@ export const useImageActions = (imageDTO: ImageDTO | null) => {
if (!imageDTO) {
return;
}
deleteImageModal.delete([imageDTO]);
deleteImageModal.delete([imageDTO.image_name]);
}, [deleteImageModal, imageDTO]);
return {

View File

@@ -8,10 +8,6 @@ import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types';
export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0));
export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1));
export const selectLastSelectedImageName = createSelector(
selectGallerySlice,
(gallery) => gallery.selection.at(-1)?.image_name
);
export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit);
export const selectListImagesQueryArgs = createMemoizedSelector(

View File

@@ -1,8 +1,8 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { isEqual, uniqBy } from 'lodash-es';
import type { BoardRecordOrderBy, ImageDTO } from 'services/api/types';
import { isEqual, uniq } from 'lodash-es';
import type { BoardRecordOrderBy } from 'services/api/types';
import type { BoardId, ComparisonMode, GalleryState, GalleryView, OrderDir } from './types';
@@ -33,14 +33,14 @@ export const gallerySlice = createSlice({
name: 'gallery',
initialState: initialGalleryState,
reducers: {
imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
imageSelected: (state, action: PayloadAction<string | null>) => {
// Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent
// unnecessary re-renders of the gallery.
const selectedImage = action.payload;
const selectedImageName = action.payload;
// If we got `null`, clear the selection
if (!selectedImage) {
if (!selectedImageName) {
// But only if we have images selected
if (state.selection.length > 0) {
state.selection = [];
@@ -50,24 +50,24 @@ export const gallerySlice = createSlice({
// If we have multiple images selected, clear the selection and select the new image
if (state.selection.length !== 1) {
state.selection = [selectedImage];
state.selection = [selectedImageName];
return;
}
// If the selected image is different from the current selection, clear the selection and select the new image
if (!isEqual(state.selection[0], selectedImage)) {
state.selection = [selectedImage];
if (!isEqual(state.selection[0], selectedImageName)) {
state.selection = [selectedImageName];
return;
}
// Else we have the same image selected, do nothing
},
selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
selectionChanged: (state, action: PayloadAction<string[]>) => {
// Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent
// unnecessary re-renders of the gallery.
// Remove duplicates from the selection
const newSelection = uniqBy(action.payload, (i) => i.image_name);
const newSelection = uniq(action.payload);
// If the new selection has a different length, update the selection
if (newSelection.length !== state.selection.length) {
@@ -83,7 +83,7 @@ export const gallerySlice = createSlice({
// Else we have the same selection, do nothing
},
imageToCompareChanged: (state, action: PayloadAction<ImageDTO | null>) => {
imageToCompareChanged: (state, action: PayloadAction<string | null>) => {
state.imageToCompare = action.payload;
},
comparisonModeChanged: (state, action: PayloadAction<ComparisonMode>) => {

View File

@@ -1,4 +1,4 @@
import type { BoardRecordOrderBy, ImageCategory, ImageDTO } from 'services/api/types';
import type { BoardRecordOrderBy, ImageCategory } from 'services/api/types';
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other'];
@@ -10,7 +10,7 @@ export type ComparisonFit = 'contain' | 'fill';
export type OrderDir = 'ASC' | 'DESC';
export type GalleryState = {
selection: ImageDTO[];
selection: string[];
shouldAutoSwitch: boolean;
autoAssignBoardOnClick: boolean;
autoAddBoardId: BoardId;
@@ -24,7 +24,7 @@ export type GalleryState = {
orderDir: OrderDir;
searchTerm: string;
alwaysShowImageSizeBadge: boolean;
imageToCompare: ImageDTO | null;
imageToCompare: string | null;
comparisonMode: ComparisonMode;
comparisonFit: ComparisonFit;
shouldShowArchivedBoards: boolean;

View File

@@ -71,9 +71,9 @@ export const setNodeImageFieldImage = (arg: {
dispatch(fieldImageValueChanged({ ...fieldIdentifier, value: imageDTO }));
};
export const setComparisonImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => {
const { imageDTO, dispatch } = arg;
dispatch(imageToCompareChanged(imageDTO));
export const setComparisonImage = (arg: { image_name: string; dispatch: AppDispatch }) => {
const { image_name, dispatch } = arg;
dispatch(imageToCompareChanged(image_name));
};
export const createNewCanvasEntityFromImage = (arg: {
@@ -292,14 +292,14 @@ export const replaceCanvasEntityObjectsWithImage = (arg: {
);
};
export const addImagesToBoard = (arg: { imageDTOs: ImageDTO[]; boardId: BoardId; dispatch: AppDispatch }) => {
const { imageDTOs, boardId, dispatch } = arg;
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false }));
export const addImagesToBoard = (arg: { image_names: string[]; boardId: BoardId; dispatch: AppDispatch }) => {
const { image_names, boardId, dispatch } = arg;
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ image_names, board_id: boardId }, { track: false }));
dispatch(selectionChanged([]));
};
export const removeImagesFromBoard = (arg: { imageDTOs: ImageDTO[]; dispatch: AppDispatch }) => {
const { imageDTOs, dispatch } = arg;
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false }));
export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: AppDispatch }) => {
const { image_names, dispatch } = arg;
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false }));
dispatch(selectionChanged([]));
};

View File

@@ -13,11 +13,13 @@ import { motion } from 'framer-motion';
import type { CSSProperties, PropsWithChildren } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useImageDTO } from 'services/api/endpoints/images';
import { $lastProgressEvent } from 'services/events/stores';
const CurrentImageNode = (props: NodeProps) => {
const imageDTO = useAppSelector(selectLastSelectedImage);
const image_name = useAppSelector(selectLastSelectedImage);
const lastProgressEvent = useStore($lastProgressEvent);
const imageDTO = useImageDTO(image_name);
if (lastProgressEvent?.image) {
return (

View File

@@ -21,10 +21,10 @@ import { Trans, useTranslation } from 'react-i18next';
import { PiFrameCornersBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
type Props = { imageDTO?: ImageDTO };
type Props = { imageDTO: ImageDTO | null; isDisabled: boolean };
export const PostProcessingPopover = memo((props: Props) => {
const { imageDTO } = props;
const { imageDTO, isDisabled } = props;
const dispatch = useAppDispatch();
const postProcessingModel = useAppSelector(selectPostProcessingModel);
const inProgress = useIsQueueMutationInProgress();
@@ -49,6 +49,7 @@ export const PostProcessingPopover = memo((props: Props) => {
aria-label={t('parameters.postProcessing')}
variant="link"
alignSelf="stretch"
isDisabled={isDisabled}
/>
</PopoverTrigger>
<PopoverContent>
@@ -56,7 +57,11 @@ export const PostProcessingPopover = memo((props: Props) => {
<Flex flexDirection="column" gap={4}>
<ParamPostProcessingModel />
{!postProcessingModel && <MissingModelWarning />}
<Button size="sm" isDisabled={!imageDTO || inProgress || !postProcessingModel} onClick={handleClickUpscale}>
<Button
size="sm"
isDisabled={isDisabled || !imageDTO || inProgress || !postProcessingModel}
onClick={handleClickUpscale}
>
{t('parameters.processImage')}
</Button>
</Flex>

View File

@@ -75,16 +75,6 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive();
};

View File

@@ -60,16 +60,6 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive();
};

View File

@@ -60,16 +60,6 @@ const initializeCenterLayout = (api: DockviewApi) => {
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive();
};

View File

@@ -73,16 +73,6 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive();
};

View File

@@ -1,11 +1,9 @@
import { skipToken } from '@reduxjs/toolkit/query';
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,
GraphAndWorkflowResponse,
ImageDTO,
ImageUploadEntryRequest,
@@ -15,7 +13,6 @@ 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';
@@ -94,141 +91,77 @@ export const imagesApi = api.injectEndpoints({
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}/workflow`) }),
providesTags: (result, error, image_name) => [{ type: 'ImageWorkflow', id: image_name }],
}),
deleteImage: build.mutation<void, ImageDTO>({
deleteImage: build.mutation<
paths['/api/v1/images/i/{image_name}']['delete']['responses']['200']['content']['application/json'],
paths['/api/v1/images/i/{image_name}']['delete']['parameters']['path']
>({
query: ({ image_name }) => ({
url: buildImagesUrl(`i/${image_name}`),
method: 'DELETE',
}),
invalidatesTags: (result, error, imageDTO) => {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
return [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
},
];
},
}),
deleteImages: build.mutation<components['schemas']['DeleteImagesFromListResult'], { imageDTOs: ImageDTO[] }>({
query: ({ imageDTOs }) => {
const image_names = imageDTOs.map((imageDTO) => imageDTO.image_name);
return {
url: buildImagesUrl('delete'),
method: 'POST',
body: {
image_names,
},
};
},
invalidatesTags: (result, error, { imageDTOs }) => {
const tags: ApiTagDescription[] = [];
for (const imageDTO of imageDTOs) {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
tags.push(
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
}
);
invalidatesTags: (result) => {
if (!result) {
return [];
}
const dedupedTags = uniqBy(tags, stableHash);
return dedupedTags;
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
// will force those queries to re-fetch, and the requests will of course 404.
return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards);
},
}),
deleteUncategorizedImages: build.mutation<components['schemas']['DeleteImagesFromListResult'], void>({
deleteImages: build.mutation<
paths['/api/v1/images/delete']['post']['responses']['200']['content']['application/json'],
paths['/api/v1/images/delete']['post']['requestBody']['content']['application/json']
>({
query: (body) => ({
url: buildImagesUrl('delete'),
method: 'POST',
body,
}),
invalidatesTags: (result) => {
if (!result) {
return [];
}
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
// will force those queries to re-fetch, and the requests will of course 404.
return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards);
},
}),
deleteUncategorizedImages: build.mutation<
paths['/api/v1/images/uncategorized']['delete']['responses']['200']['content']['application/json'],
void
>({
query: () => ({ url: buildImagesUrl('uncategorized'), method: 'DELETE' }),
invalidatesTags: (result) => {
if (result && result.deleted_images.length > 0) {
const boardId = 'none';
const tags: ApiTagDescription[] = [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories: IMAGE_CATEGORIES,
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories: ASSETS_CATEGORIES,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
},
];
return tags;
if (!result) {
return [];
}
return [];
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
// will force those queries to re-fetch, and the requests will of course 404.
return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards);
},
}),
/**
* Change an image's `is_intermediate` property.
*/
changeImageIsIntermediate: build.mutation<ImageDTO, { imageDTO: ImageDTO; is_intermediate: boolean }>({
query: ({ imageDTO, is_intermediate }) => ({
url: buildImagesUrl(`i/${imageDTO.image_name}`),
changeImageIsIntermediate: build.mutation<
paths['/api/v1/images/i/{image_name}']['patch']['responses']['200']['content']['application/json'],
{ image_name: string; is_intermediate: boolean }
>({
query: ({ image_name, is_intermediate }) => ({
url: buildImagesUrl(`i/${image_name}`),
method: 'PATCH',
body: { is_intermediate },
}),
invalidatesTags: (result, error, { imageDTO }) => {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [
{ type: 'Image', id: imageDTO.image_name },
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
},
...getTagsToInvalidateForImageMutation([result.image_name]),
...getTagsToInvalidateForBoardAffectingMutation([result.board_id ?? 'none']),
];
},
}),
@@ -236,38 +169,22 @@ export const imagesApi = api.injectEndpoints({
* Star a list of images.
*/
starImages: build.mutation<
paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'],
{ imageDTOs: ImageDTO[] }
paths['/api/v1/images/star']['post']['responses']['200']['content']['application/json'],
paths['/api/v1/images/star']['post']['requestBody']['content']['application/json']
>({
query: ({ imageDTOs: images }) => ({
query: (body) => ({
url: buildImagesUrl('star'),
method: 'POST',
body: { image_names: images.map((img) => img.image_name) },
body,
}),
invalidatesTags: (result, error, { imageDTOs }) => {
// assume all images are on the same board/category
if (imageDTOs[0]) {
const categories = getCategories(imageDTOs[0]);
const boardId = imageDTOs[0].board_id ?? 'none';
const tags: ApiTagDescription[] = [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
];
for (const imageDTO of imageDTOs) {
tags.push({ type: 'Image', id: imageDTO.image_name });
}
return tags;
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [];
return [
...getTagsToInvalidateForImageMutation(result.starred_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
/**
@@ -275,40 +192,27 @@ export const imagesApi = api.injectEndpoints({
*/
unstarImages: build.mutation<
paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'],
{ imageDTOs: ImageDTO[] }
paths['/api/v1/images/unstar']['post']['requestBody']['content']['application/json']
>({
query: ({ imageDTOs: images }) => ({
query: (body) => ({
url: buildImagesUrl('unstar'),
method: 'POST',
body: { image_names: images.map((img) => img.image_name) },
body,
}),
invalidatesTags: (result, error, { imageDTOs }) => {
// assume all images are on the same board/category
if (imageDTOs[0]) {
const categories = getCategories(imageDTOs[0]);
const boardId = imageDTOs[0].board_id ?? 'none';
const tags: ApiTagDescription[] = [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
];
for (const imageDTO of imageDTOs) {
tags.push({ type: 'Image', id: imageDTO.image_name });
}
return tags;
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [];
return [
...getTagsToInvalidateForImageMutation(result.unstarred_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
uploadImage: build.mutation<ImageDTO, UploadImageArg>({
uploadImage: build.mutation<
paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'],
UploadImageArg
>({
query: ({ file, image_category, is_intermediate, session_id, board_id, crop_visible, metadata, resize_to }) => {
const formData = new FormData();
formData.append('file', file);
@@ -366,8 +270,11 @@ export const imagesApi = api.injectEndpoints({
body: { width, height, board_id },
}),
}),
deleteBoard: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }),
deleteBoard: build.mutation<
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'],
paths['/api/v1/boards/{board_id}']['delete']['parameters']['path']
>({
query: ({ board_id }) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }),
invalidatesTags: () => [
{ type: 'Board', id: LIST_TAG },
// invalidate the 'No Board' cache
@@ -388,192 +295,95 @@ export const imagesApi = api.injectEndpoints({
],
}),
deleteBoardAndImages: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({
deleteBoardAndImages: build.mutation<
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'],
paths['/api/v1/boards/{board_id}']['delete']['parameters']['path']
>({
query: ({ board_id }) => ({
url: buildBoardsUrl(board_id),
method: 'DELETE',
params: { include_images: true },
}),
invalidatesTags: () => [{ type: 'Board', id: LIST_TAG }],
}),
addImageToBoard: build.mutation<void, { board_id: BoardId; imageDTO: ImageDTO }>({
query: ({ board_id, imageDTO }) => {
const { image_name } = imageDTO;
addImageToBoard: build.mutation<
paths['/api/v1/board_images/']['post']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json']
>({
query: (body) => {
return {
url: buildBoardImagesUrl(),
method: 'POST',
body: { board_id, image_name },
body,
};
},
invalidatesTags: (result, error, { board_id, imageDTO }) => {
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [
{ type: 'Image', id: imageDTO.image_name },
{
type: 'ImageList',
id: getListImagesUrl({
board_id,
categories: getCategories(imageDTO),
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTO.board_id ?? 'none',
categories: getCategories(imageDTO),
}),
},
{ type: 'Board', id: board_id },
{ type: 'Board', id: imageDTO.board_id ?? 'none' },
{
type: 'BoardImagesTotal',
id: imageDTO.board_id ?? 'none',
},
{
type: 'BoardImagesTotal',
id: board_id,
},
...getTagsToInvalidateForImageMutation(result.added_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
removeImageFromBoard: build.mutation<void, { imageDTO: ImageDTO }>({
query: ({ imageDTO }) => {
const { image_name } = imageDTO;
removeImageFromBoard: build.mutation<
paths['/api/v1/board_images/']['delete']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json']
>({
query: (body) => {
return {
url: buildBoardImagesUrl(),
method: 'DELETE',
body: { image_name },
body,
};
},
invalidatesTags: (result, error, { imageDTO }) => {
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [
{ type: 'Image', id: imageDTO.image_name },
{
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTO.board_id,
categories: getCategories(imageDTO),
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: getCategories(imageDTO),
}),
},
{ type: 'Board', id: imageDTO.board_id ?? 'none' },
{ type: 'Board', id: 'none' },
{
type: 'BoardImagesTotal',
id: imageDTO.board_id ?? 'none',
},
{ type: 'BoardImagesTotal', id: 'none' },
...getTagsToInvalidateForImageMutation(result.removed_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
addImagesToBoard: build.mutation<
components['schemas']['AddImagesToBoardResult'],
{
board_id: string;
imageDTOs: ImageDTO[];
}
paths['/api/v1/board_images/batch']['post']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/batch']['post']['requestBody']['content']['application/json']
>({
query: ({ board_id, imageDTOs }) => ({
query: (body) => ({
url: buildBoardImagesUrl('batch'),
method: 'POST',
body: {
image_names: imageDTOs.map((i) => i.image_name),
board_id,
},
body,
}),
invalidatesTags: (result, error, { board_id, imageDTOs }) => {
const tags: ApiTagDescription[] = [];
if (imageDTOs[0]) {
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTOs[0].board_id ?? 'none',
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: board_id,
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({ type: 'Board', id: imageDTOs[0].board_id ?? 'none' });
tags.push({
type: 'BoardImagesTotal',
id: imageDTOs[0].board_id ?? 'none',
});
invalidatesTags: (result) => {
if (!result) {
return [];
}
for (const imageDTO of imageDTOs) {
tags.push({ type: 'Image', id: imageDTO.image_name });
}
tags.push({ type: 'Board', id: board_id });
tags.push({
type: 'BoardImagesTotal',
id: board_id ?? 'none',
});
return tags;
return [
...getTagsToInvalidateForImageMutation(result.added_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
removeImagesFromBoard: build.mutation<
components['schemas']['RemoveImagesFromBoardResult'],
{
imageDTOs: ImageDTO[];
}
paths['/api/v1/board_images/batch/delete']['post']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/batch/delete']['post']['requestBody']['content']['application/json']
>({
query: ({ imageDTOs }) => ({
query: (body) => ({
url: buildBoardImagesUrl('batch/delete'),
method: 'POST',
body: {
image_names: imageDTOs.map((i) => i.image_name),
},
body,
}),
invalidatesTags: (result, error, { imageDTOs }) => {
const touchedBoardIds: string[] = [];
const tags: ApiTagDescription[] = [];
if (imageDTOs[0]) {
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTOs[0].board_id,
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({
type: 'BoardImagesTotal',
id: 'none',
});
invalidatesTags: (result) => {
if (!result) {
return [];
}
result?.removed_image_names.forEach((image_name) => {
const board_id = imageDTOs.find((i) => i.image_name === image_name)?.board_id;
if (!board_id || touchedBoardIds.includes(board_id)) {
tags.push({ type: 'Board', id: 'none' });
return;
}
tags.push({ type: 'Image', id: image_name });
tags.push({ type: 'Board', id: board_id });
tags.push({
type: 'BoardImagesTotal',
id: board_id ?? 'none',
});
});
return tags;
return [
...getTagsToInvalidateForImageMutation(result.removed_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
bulkDownloadImages: build.mutation<
@@ -711,3 +521,63 @@ export const imageDTOToFile = async (imageDTO: ImageDTO): Promise<File> => {
const file = new File([blob], `copy_of_${imageDTO.image_name}`, { type: 'image/png' });
return file;
};
export const useImageDTO = (imageName: string | null | undefined) => {
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
return imageDTO ?? null;
};
export const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => {
const tags: ApiTagDescription[] = [];
for (const image_name of image_names) {
tags.push({
type: 'Image',
id: image_name,
});
tags.push({
type: 'ImageMetadata',
id: image_name,
});
tags.push({
type: 'ImageWorkflow',
id: image_name,
});
}
return tags;
};
export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => {
const tags: ApiTagDescription[] = [];
for (const board_id of affected_boards) {
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id,
categories: IMAGE_CATEGORIES,
}),
});
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id,
categories: ASSETS_CATEGORIES,
}),
});
tags.push({
type: 'Board',
id: board_id,
});
tags.push({
type: 'BoardImagesTotal',
id: board_id,
});
}
return tags;
};

View File

@@ -117,7 +117,7 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi
);
} else {
// Else just select the image, no need to switch boards
dispatch(imageSelected(lastImageDTO));
dispatch(imageSelected(lastImageDTO.image_name));
if (galleryView !== 'images') {
// We also need to update the gallery view to images. This also updates the offset.