mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat: add multi-select to gallery
multi-select actions include:
- drag to board to move all to that board
- right click to add all to board or delete all
backend changes:
- add routes for changing board for list of image names, deleting list of images
- change image-specific routes to `images/i/{image_name}` to not clobber other routes (like `images/upload`, `images/delete`)
- subclass pydantic `BaseModel` as `BaseModelExcludeNull`, which excludes null values when calling `dict()` on the model. this fixes inconsistent types related to JSON parsing null values into `null` instead of `undefined`
- remove `board_id` from `remove_image_from_board`
frontend changes:
- multi-selection stuff uses `ImageDTO[]` as payloads, for dnd and other mutations. this gives us access to image `board_id`s when hitting routes, and enables efficient cache updates.
- consolidate change board and delete image modals to handle single and multiples
- board totals are now re-fetched on mutation and not kept in sync manually - was way too tedious to do this
- fixed warning about nested `<p>` elements
- closes #4088 , need to handle case when `autoAddBoardId` is `"none"`
- add option to show gallery image delete button on every gallery image
frontend refactors/organisation:
- make typegen script js instead of ts
- enable `noUncheckedIndexedAccess` to help avoid bugs when indexing into arrays, many small changes needed to satisfy TS after this
- move all image-related endpoints into `endpoints/images.ts`, its a big file now, but this fixes a number of circular dependency issues that were otherwise felt impossible to resolve
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { IconButtonProps } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaTrash } from 'react-icons/fa';
|
||||
|
||||
const deleteImageButtonsSelector = createSelector(
|
||||
[stateSelector],
|
||||
({ system }) => {
|
||||
const { isProcessing, isConnected } = system;
|
||||
|
||||
return isConnected && !isProcessing;
|
||||
}
|
||||
);
|
||||
|
||||
type DeleteImageButtonProps = Omit<IconButtonProps, 'aria-label'> & {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const DeleteImageButton = (props: DeleteImageButtonProps) => {
|
||||
const { onClick, isDisabled } = props;
|
||||
const { t } = useTranslation();
|
||||
const canDeleteImage = useAppSelector(deleteImageButtonsSelector);
|
||||
|
||||
return (
|
||||
<IAIIconButton
|
||||
onClick={onClick}
|
||||
icon={<FaTrash />}
|
||||
tooltip={`${t('gallery.deleteImage')} (Del)`}
|
||||
aria-label={`${t('gallery.deleteImage')} (Del)`}
|
||||
isDisabled={isDisabled || !canDeleteImage}
|
||||
colorScheme="error"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Divider,
|
||||
Flex,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIButton from 'common/components/IAIButton';
|
||||
import IAISwitch from 'common/components/IAISwitch';
|
||||
import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import { some } from 'lodash-es';
|
||||
import { ChangeEvent, memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imageDeletionConfirmed } from '../store/actions';
|
||||
import { getImageUsage, selectImageUsage } from '../store/selectors';
|
||||
import { imageDeletionCanceled, isModalOpenChanged } from '../store/slice';
|
||||
import ImageUsageMessage from './ImageUsageMessage';
|
||||
import { ImageUsage } from '../store/types';
|
||||
|
||||
const selector = createSelector(
|
||||
[stateSelector, selectImageUsage],
|
||||
(state, imagesUsage) => {
|
||||
const { system, config, deleteImageModal } = state;
|
||||
const { shouldConfirmOnDelete } = system;
|
||||
const { canRestoreDeletedImagesFromBin } = config;
|
||||
const { imagesToDelete, isModalOpen } = deleteImageModal;
|
||||
|
||||
const allImageUsage = (imagesToDelete ?? []).map(({ image_name }) =>
|
||||
getImageUsage(state, image_name)
|
||||
);
|
||||
|
||||
const imageUsageSummary: ImageUsage = {
|
||||
isInitialImage: some(allImageUsage, (i) => i.isInitialImage),
|
||||
isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage),
|
||||
isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
|
||||
isControlNetImage: some(allImageUsage, (i) => i.isControlNetImage),
|
||||
};
|
||||
|
||||
return {
|
||||
shouldConfirmOnDelete,
|
||||
canRestoreDeletedImagesFromBin,
|
||||
imagesToDelete,
|
||||
imagesUsage,
|
||||
isModalOpen,
|
||||
imageUsageSummary,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const DeleteImageModal = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
shouldConfirmOnDelete,
|
||||
canRestoreDeletedImagesFromBin,
|
||||
imagesToDelete,
|
||||
imagesUsage,
|
||||
isModalOpen,
|
||||
imageUsageSummary,
|
||||
} = useAppSelector(selector);
|
||||
|
||||
const handleChangeShouldConfirmOnDelete = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setShouldConfirmOnDelete(!e.target.checked)),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(imageDeletionCanceled());
|
||||
dispatch(isModalOpenChanged(false));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!imagesToDelete.length || !imagesUsage.length) {
|
||||
return;
|
||||
}
|
||||
dispatch(imageDeletionCanceled());
|
||||
dispatch(
|
||||
imageDeletionConfirmed({ imageDTOs: imagesToDelete, imagesUsage })
|
||||
);
|
||||
}, [dispatch, imagesToDelete, imagesUsage]);
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{t('gallery.deleteImage')}
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
<Flex direction="column" gap={3}>
|
||||
<ImageUsageMessage imageUsage={imageUsageSummary} />
|
||||
<Divider />
|
||||
<Text>
|
||||
{canRestoreDeletedImagesFromBin
|
||||
? t('gallery.deleteImageBin')
|
||||
: t('gallery.deleteImagePermanent')}
|
||||
</Text>
|
||||
<Text>{t('common.areYouSure')}</Text>
|
||||
<IAISwitch
|
||||
label={t('common.dontAskMeAgain')}
|
||||
isChecked={!shouldConfirmOnDelete}
|
||||
onChange={handleChangeShouldConfirmOnDelete}
|
||||
/>
|
||||
</Flex>
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<IAIButton ref={cancelRef} onClick={handleClose}>
|
||||
Cancel
|
||||
</IAIButton>
|
||||
<IAIButton colorScheme="error" onClick={handleDelete} ml={3}>
|
||||
Delete
|
||||
</IAIButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DeleteImageModal);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||
import { some } from 'lodash-es';
|
||||
import { memo } from 'react';
|
||||
import { ImageUsage } from '../store/types';
|
||||
type Props = {
|
||||
imageUsage?: ImageUsage;
|
||||
topMessage?: string;
|
||||
bottomMessage?: string;
|
||||
};
|
||||
const ImageUsageMessage = (props: Props) => {
|
||||
const {
|
||||
imageUsage,
|
||||
topMessage = 'This image is currently in use in the following features:',
|
||||
bottomMessage = 'If you delete this image, those features will immediately be reset.',
|
||||
} = props;
|
||||
|
||||
if (!imageUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!some(imageUsage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>{topMessage}</Text>
|
||||
<UnorderedList sx={{ paddingInlineStart: 6 }}>
|
||||
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
|
||||
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
|
||||
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
|
||||
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
|
||||
</UnorderedList>
|
||||
<Text>{bottomMessage}</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ImageUsageMessage);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { ImageUsage } from './types';
|
||||
|
||||
export const imageDeletionConfirmed = createAction<{
|
||||
imageDTOs: ImageDTO[];
|
||||
imagesUsage: ImageUsage[];
|
||||
}>('deleteImageModal/imageDeletionConfirmed');
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DeleteImageState } from './types';
|
||||
|
||||
export const initialDeleteImageState: DeleteImageState = {
|
||||
imagesToDelete: [],
|
||||
isModalOpen: false,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { some } from 'lodash-es';
|
||||
import { ImageUsage } from './types';
|
||||
|
||||
export const getImageUsage = (state: RootState, image_name: string) => {
|
||||
const { generation, canvas, nodes, controlNet } = state;
|
||||
const isInitialImage = generation.initialImage?.imageName === image_name;
|
||||
|
||||
const isCanvasImage = canvas.layerState.objects.some(
|
||||
(obj) => obj.kind === 'image' && obj.imageName === image_name
|
||||
);
|
||||
|
||||
const isNodesImage = nodes.nodes.some((node) => {
|
||||
return some(
|
||||
node.data.inputs,
|
||||
(input) =>
|
||||
input.type === 'image' && input.value?.image_name === image_name
|
||||
);
|
||||
});
|
||||
|
||||
const isControlNetImage = some(
|
||||
controlNet.controlNets,
|
||||
(c) =>
|
||||
c.controlImage === image_name || c.processedControlImage === image_name
|
||||
);
|
||||
|
||||
const imageUsage: ImageUsage = {
|
||||
isInitialImage,
|
||||
isCanvasImage,
|
||||
isNodesImage,
|
||||
isControlNetImage,
|
||||
};
|
||||
|
||||
return imageUsage;
|
||||
};
|
||||
|
||||
export const selectImageUsage = createSelector(
|
||||
[(state: RootState) => state],
|
||||
(state) => {
|
||||
const { imagesToDelete } = state.deleteImageModal;
|
||||
|
||||
if (!imagesToDelete.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const imagesUsage = imagesToDelete.map((i) =>
|
||||
getImageUsage(state, i.image_name)
|
||||
);
|
||||
|
||||
return imagesUsage;
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { initialDeleteImageState } from './initialState';
|
||||
|
||||
const deleteImageModal = createSlice({
|
||||
name: 'deleteImageModal',
|
||||
initialState: initialDeleteImageState,
|
||||
reducers: {
|
||||
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isModalOpen = action.payload;
|
||||
},
|
||||
imagesToDeleteSelected: (state, action: PayloadAction<ImageDTO[]>) => {
|
||||
state.imagesToDelete = action.payload;
|
||||
},
|
||||
imageDeletionCanceled: (state) => {
|
||||
state.imagesToDelete = [];
|
||||
state.isModalOpen = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
isModalOpenChanged,
|
||||
imagesToDeleteSelected,
|
||||
imageDeletionCanceled,
|
||||
} = deleteImageModal.actions;
|
||||
|
||||
export default deleteImageModal.reducer;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
|
||||
export type DeleteImageState = {
|
||||
imagesToDelete: ImageDTO[];
|
||||
isModalOpen: boolean;
|
||||
};
|
||||
|
||||
export type ImageUsage = {
|
||||
isInitialImage: boolean;
|
||||
isCanvasImage: boolean;
|
||||
isNodesImage: boolean;
|
||||
isControlNetImage: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user