mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
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
187 lines
5.1 KiB
TypeScript
187 lines
5.1 KiB
TypeScript
import { Box } from '@chakra-ui/react';
|
|
import { createSelector } from '@reduxjs/toolkit';
|
|
import { useAppToaster } from 'app/components/Toaster';
|
|
import { useAppSelector } from 'app/store/storeHooks';
|
|
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
|
import { selectIsBusy } from 'features/system/store/systemSelectors';
|
|
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
|
|
import {
|
|
KeyboardEvent,
|
|
ReactNode,
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useState,
|
|
} from 'react';
|
|
import { FileRejection, useDropzone } from 'react-dropzone';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useUploadImageMutation } from 'services/api/endpoints/images';
|
|
import { PostUploadAction } from 'services/api/types';
|
|
import ImageUploadOverlay from './ImageUploadOverlay';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { stateSelector } from 'app/store/store';
|
|
|
|
const selector = createSelector(
|
|
[stateSelector, activeTabNameSelector],
|
|
({ gallery }, activeTabName) => {
|
|
let postUploadAction: PostUploadAction = { type: 'TOAST' };
|
|
|
|
if (activeTabName === 'unifiedCanvas') {
|
|
postUploadAction = { type: 'SET_CANVAS_INITIAL_IMAGE' };
|
|
}
|
|
|
|
if (activeTabName === 'img2img') {
|
|
postUploadAction = { type: 'SET_INITIAL_IMAGE' };
|
|
}
|
|
|
|
const { autoAddBoardId } = gallery;
|
|
|
|
return {
|
|
autoAddBoardId,
|
|
postUploadAction,
|
|
};
|
|
},
|
|
defaultSelectorOptions
|
|
);
|
|
|
|
type ImageUploaderProps = {
|
|
children: ReactNode;
|
|
};
|
|
|
|
const ImageUploader = (props: ImageUploaderProps) => {
|
|
const { children } = props;
|
|
const { autoAddBoardId, postUploadAction } = useAppSelector(selector);
|
|
const isBusy = useAppSelector(selectIsBusy);
|
|
const toaster = useAppToaster();
|
|
const { t } = useTranslation();
|
|
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
|
|
|
|
const [uploadImage] = useUploadImageMutation();
|
|
|
|
const fileRejectionCallback = useCallback(
|
|
(rejection: FileRejection) => {
|
|
setIsHandlingUpload(true);
|
|
|
|
toaster({
|
|
title: t('toast.uploadFailed'),
|
|
description: rejection.errors.map((error) => error.message).join('\n'),
|
|
status: 'error',
|
|
});
|
|
},
|
|
[t, toaster]
|
|
);
|
|
|
|
const fileAcceptedCallback = useCallback(
|
|
async (file: File) => {
|
|
uploadImage({
|
|
file,
|
|
image_category: 'user',
|
|
is_intermediate: false,
|
|
postUploadAction,
|
|
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
|
|
});
|
|
},
|
|
[autoAddBoardId, postUploadAction, uploadImage]
|
|
);
|
|
|
|
const onDrop = useCallback(
|
|
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
|
|
if (fileRejections.length > 1) {
|
|
toaster({
|
|
title: t('toast.uploadFailed'),
|
|
description: t('toast.uploadFailedInvalidUploadDesc'),
|
|
status: 'error',
|
|
});
|
|
return;
|
|
}
|
|
|
|
fileRejections.forEach((rejection: FileRejection) => {
|
|
fileRejectionCallback(rejection);
|
|
});
|
|
|
|
acceptedFiles.forEach((file: File) => {
|
|
fileAcceptedCallback(file);
|
|
});
|
|
},
|
|
[t, toaster, fileAcceptedCallback, fileRejectionCallback]
|
|
);
|
|
|
|
const {
|
|
getRootProps,
|
|
getInputProps,
|
|
isDragAccept,
|
|
isDragReject,
|
|
isDragActive,
|
|
inputRef,
|
|
} = useDropzone({
|
|
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
|
|
noClick: true,
|
|
onDrop,
|
|
onDragOver: () => setIsHandlingUpload(true),
|
|
disabled: isBusy,
|
|
multiple: false,
|
|
});
|
|
|
|
useEffect(() => {
|
|
// This is a hack to allow pasting images into the uploader
|
|
const handlePaste = async (e: ClipboardEvent) => {
|
|
if (!inputRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (e.clipboardData?.files) {
|
|
// Set the files on the inputRef
|
|
inputRef.current.files = e.clipboardData.files;
|
|
// Dispatch the change event, dropzone catches this and we get to use its own validation
|
|
inputRef.current?.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
};
|
|
|
|
// Add the paste event listener
|
|
document.addEventListener('paste', handlePaste);
|
|
|
|
return () => {
|
|
document.removeEventListener('paste', handlePaste);
|
|
};
|
|
}, [inputRef]);
|
|
|
|
return (
|
|
<Box
|
|
{...getRootProps({ style: {} })}
|
|
onKeyDown={(e: KeyboardEvent) => {
|
|
// Bail out if user hits spacebar - do not open the uploader
|
|
if (e.key === ' ') return;
|
|
}}
|
|
>
|
|
<input {...getInputProps()} />
|
|
{children}
|
|
<AnimatePresence>
|
|
{isDragActive && isHandlingUpload && (
|
|
<motion.div
|
|
key="image-upload-overlay"
|
|
initial={{
|
|
opacity: 0,
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
transition: { duration: 0.1 },
|
|
}}
|
|
exit={{
|
|
opacity: 0,
|
|
transition: { duration: 0.1 },
|
|
}}
|
|
>
|
|
<ImageUploadOverlay
|
|
isDragAccept={isDragAccept}
|
|
isDragReject={isDragReject}
|
|
setIsHandlingUpload={setIsHandlingUpload}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default memo(ImageUploader);
|