Adds full-app image drag and drop, also image paste

This commit is contained in:
psychedelicious 2022-10-29 23:34:21 +11:00
parent 2e9463089d
commit 101fe9efa9
21 changed files with 847 additions and 724 deletions

File diff suppressed because one or more lines are too long

517
frontend/dist/assets/index.18143ecd.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InvokeAI - A Stable Diffusion Toolkit</title>
<link rel="shortcut icon" type="icon" href="./assets/favicon.0d253ced.ico" />
<script type="module" crossorigin src="./assets/index.01c74d27.js"></script>
<link rel="stylesheet" href="./assets/index.61df3eff.css">
<script type="module" crossorigin src="./assets/index.18143ecd.js"></script>
<link rel="stylesheet" href="./assets/index.ff30f6a0.css">
</head>
<body>

@ -17,5 +17,5 @@
}
.app-console {
z-index: 9999;
z-index: 20;
}

@ -7,11 +7,13 @@ import { useAppDispatch } from './store';
import { requestSystemConfig } from './socketio/actions';
import { keepGUIAlive } from './utils';
import InvokeTabs from '../features/tabs/InvokeTabs';
import ImageUploader from '../common/components/ImageUploader';
keepGUIAlive();
const App = () => {
const dispatch = useAppDispatch();
const [isReady, setIsReady] = useState<boolean>(false);
useEffect(() => {
@ -21,14 +23,16 @@ const App = () => {
return isReady ? (
<div className="App">
<ProgressBar />
<div className="app-content">
<SiteHeader />
<InvokeTabs />
</div>
<div className="app-console">
<Console />
</div>
<ImageUploader>
<ProgressBar />
<div className="app-content">
<SiteHeader />
<InvokeTabs />
</div>
<div className="app-console">
<Console />
</div>
</ImageUploader>
</div>
) : (
<Loading />

@ -0,0 +1,8 @@
import { createContext } from 'react';
type VoidFunc = () => void;
type ImageUploaderTriggerContextType = VoidFunc | null;
export const ImageUploaderTriggerContext =
createContext<ImageUploaderTriggerContextType>(null);

@ -197,7 +197,9 @@ export declare type ImageUrlResponse = {
url: string;
};
export declare type ImageUploadDestination = 'img2img' | 'inpainting';
export declare type UploadImagePayload = {
file: File;
destination: 'img2img' | 'inpainting';
destination?: ImageUploadDestination;
};

@ -23,6 +23,7 @@ import {
clearIntermediateImage,
GalleryState,
removeImage,
setCurrentImage,
setIntermediateImage,
} from '../../features/gallery/gallerySlice';
@ -305,6 +306,10 @@ const makeSocketIOListeners = (
dispatch(setImageToInpaint(image));
break;
}
default: {
dispatch(setCurrentImage(image));
break;
}
}
dispatch(

@ -0,0 +1,72 @@
.dropzone-container {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
backdrop-filter: blur(20px);
.dropzone-overlay {
opacity: 0.5;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
row-gap: 1rem;
align-items: center;
justify-content: center;
&.is-drag-accept {
box-shadow: inset 0 0 20rem 1rem var(--status-good-color);
}
&.is-drag-reject {
box-shadow: inset 0 0 20rem 1rem var(--status-bad-color);
}
&.is-handling-upload {
box-shadow: inset 0 0 20rem 1rem var(--status-working-color);
}
}
}
.image-uploader-button-outer {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 0.5rem;
color: var(--subtext-color-bright);
&:hover {
background-color: var(--inpaint-bg-color);
}
}
.image-upload-button-inner {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.image-upload-button {
display: flex;
flex-direction: column;
row-gap: 2rem;
align-items: center;
justify-content: center;
text-align: center;
svg {
width: 4rem !important;
height: 4rem !important;
}
h2 {
font-size: 1.2rem !important;
}
}

@ -0,0 +1,173 @@
import { useCallback, ReactNode, useState, useEffect } from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { tabMap } from '../../features/tabs/InvokeTabs';
import { FileRejection, useDropzone } from 'react-dropzone';
import { Heading, Spinner, useToast } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { OptionsState } from '../../features/options/optionsSlice';
import { uploadImage } from '../../app/socketio/actions';
import { ImageUploadDestination, UploadImagePayload } from '../../app/invokeai';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
const appSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
const { activeTab } = options;
return {
activeTabName: tabMap[activeTab],
};
}
);
type ImageUploaderProps = {
children: ReactNode;
};
const ImageUploader = (props: ImageUploaderProps) => {
const { children } = props;
const dispatch = useAppDispatch();
const { activeTabName } = useAppSelector(appSelector);
const toast = useToast({});
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
setIsHandlingUpload(true);
const msg = rejection.errors.reduce(
(acc: string, cur: { message: string }) => acc + '\n' + cur.message,
''
);
toast({
title: 'Upload failed',
description: msg,
status: 'error',
isClosable: true,
});
},
[toast]
);
const fileAcceptedCallback = useCallback(
(file: File) => {
setIsHandlingUpload(true);
const payload: UploadImagePayload = { file };
if (['img2img', 'inpainting'].includes(activeTabName)) {
payload.destination = activeTabName as ImageUploadDestination;
}
dispatch(uploadImage(payload));
},
[dispatch, activeTabName]
);
const onDrop = useCallback(
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
fileRejections.forEach((rejection: FileRejection) => {
fileRejectionCallback(rejection);
});
acceptedFiles.forEach((file: File) => {
fileAcceptedCallback(file);
});
},
[fileAcceptedCallback, fileRejectionCallback]
);
const {
getRootProps,
getInputProps,
isDragAccept,
isDragReject,
isDragActive,
open,
} = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true,
onDrop,
maxFiles: 1,
});
useEffect(() => {
const pasteImageListener = (e: ClipboardEvent) => {
const dataTransferItemList = e.clipboardData?.items;
if (!dataTransferItemList) return;
const imageItems: Array<DataTransferItem> = [];
for (const item of dataTransferItemList) {
if (
item.kind === 'file' &&
['image/png', 'image/jpg'].includes(item.type)
) {
imageItems.push(item);
}
}
if (!imageItems.length) return;
e.stopImmediatePropagation();
if (imageItems.length > 1) {
toast({
description:
'Multiple images pasted, may only upload one image at a time',
status: 'error',
isClosable: true,
});
return;
}
const file = imageItems[0].getAsFile();
if (!file) {
toast({
description: 'Unable to load file',
status: 'error',
isClosable: true,
});
return;
}
const payload: UploadImagePayload = { file };
if (['img2img', 'inpainting'].includes(activeTabName)) {
payload.destination = activeTabName as ImageUploadDestination;
}
dispatch(uploadImage(payload));
};
document.addEventListener('paste', pasteImageListener);
return () => {
document.removeEventListener('paste', pasteImageListener);
};
}, [dispatch, toast, activeTabName]);
return (
<ImageUploaderTriggerContext.Provider value={open}>
<div {...getRootProps({ style: {} })}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="dropzone-container">
{isDragAccept && (
<div className="dropzone-overlay is-drag-accept">
<Heading size={'lg'}>Drop Images</Heading>
</div>
)}
{isDragReject && (
<div className="dropzone-overlay is-drag-reject">
<Heading size={'lg'}>Invalid Upload</Heading>
<Heading size={'md'}>Must be single JPEG or PNG image</Heading>
</div>
)}
{isHandlingUpload && (
<div className="dropzone-overlay is-handling-upload">
<Spinner />
</div>
)}
</div>
)}
</div>
</ImageUploaderTriggerContext.Provider>
);
};
export default ImageUploader;

@ -0,0 +1,31 @@
import { Heading } from '@chakra-ui/react';
import { useContext } from 'react';
import { FaUpload } from 'react-icons/fa';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
type ImageUploaderButtonProps = {
styleClass?: string;
};
const ImageUploaderButton = (props: ImageUploaderButtonProps) => {
const { styleClass } = props;
const open = useContext(ImageUploaderTriggerContext);
const handleClickUpload = () => {
open && open();
};
return (
<div
className={`image-uploader-button-outer ${styleClass}`}
onClick={handleClickUpload}
>
<div className="image-upload-button">
<FaUpload />
<Heading size={'lg'}>Click or Drag and Drop</Heading>
</div>
</div>
);
};
export default ImageUploaderButton;

@ -1,39 +0,0 @@
.image-upload-zone {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 0.5rem;
color: var(--subtext-color-bright);
&:hover {
background-color: var(--inpaint-bg-color);
}
}
.image-upload-child-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.image-upload-child {
display: flex;
flex-direction: column;
row-gap: 2rem;
align-items: center;
justify-content: center;
text-align: center;
svg {
width: 4rem !important;
height: 4rem !important;
}
h2 {
font-size: 1.2rem !important;
}
}

@ -1,58 +0,0 @@
import { Heading, useToast } from '@chakra-ui/react';
import { useCallback } from 'react';
import { FileRejection } from 'react-dropzone';
import { FaUpload } from 'react-icons/fa';
import ImageUploader from '../../features/options/ImageUploader';
interface InvokeImageUploaderProps {
handleFile: (file: File) => void;
styleClass?: string;
}
export default function InvokeImageUploader(props: InvokeImageUploaderProps) {
const { handleFile, styleClass } = props;
const toast = useToast();
// Callbacks to for handling file upload attempts
const fileAcceptedCallback = useCallback(
(file: File) => handleFile(file),
[handleFile]
);
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
const msg = rejection.errors.reduce(
(acc: string, cur: { message: string }) => acc + '\n' + cur.message,
''
);
toast({
title: 'Upload failed',
description: msg,
status: 'error',
isClosable: true,
});
},
[toast]
);
return (
<div className="image-upload-zone">
<ImageUploader
fileAcceptedCallback={fileAcceptedCallback}
fileRejectionCallback={fileRejectionCallback}
styleClass={
styleClass
? `${styleClass} image-upload-child-wrapper`
: `image-upload-child-wrapper`
}
>
<div className="image-upload-child">
<FaUpload />
<Heading size={'lg'}>Upload or Drop Image Here</Heading>
</div>
</ImageUploader>
</div>
);
}

@ -64,7 +64,7 @@
}
.hoverable-image-context-menu {
z-index: 999;
z-index: 15;
padding: 0.4rem;
border-radius: 0.25rem;
background-color: var(--context-menu-bg-color);

@ -1,73 +0,0 @@
import { Box } from '@chakra-ui/react';
import {
cloneElement,
ReactElement,
ReactNode,
SyntheticEvent,
useCallback,
} from 'react';
import { FileRejection, useDropzone } from 'react-dropzone';
type ImageUploaderProps = {
/**
* Component which, on click, should open the upload interface.
*/
children: ReactElement;
/**
* Callback to handle uploading the selected file.
*/
fileAcceptedCallback: (file: File) => void;
/**
* Callback to handle a file being rejected.
*/
fileRejectionCallback: (rejection: FileRejection) => void;
// Styling
styleClass?: string;
};
/**
* File upload using react-dropzone.
* Needs a child to be the button to activate the upload interface.
*/
const ImageUploader = ({
children,
fileAcceptedCallback,
fileRejectionCallback,
styleClass,
}: ImageUploaderProps) => {
const onDrop = useCallback(
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
fileRejections.forEach((rejection: FileRejection) => {
fileRejectionCallback(rejection);
});
acceptedFiles.forEach((file: File) => {
fileAcceptedCallback(file);
});
},
[fileAcceptedCallback, fileRejectionCallback]
);
const { getRootProps, getInputProps, open } = useDropzone({
onDrop,
accept: {
'image/jpeg': ['.jpg', '.jpeg', '.png'],
},
});
const handleClickUploadIcon = (e: SyntheticEvent) => {
e.stopPropagation();
open();
};
return (
<Box {...getRootProps()} className={styleClass}>
<input {...getInputProps({ multiple: false })} />
{cloneElement(children, {
onClick: handleClickUploadIcon,
})}
</Box>
);
};
export default ImageUploader;

@ -1,12 +1,9 @@
import { uploadImage } from '../../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import InvokeImageUploader from '../../../common/components/InvokeImageUploader';
import { RootState, useAppSelector } from '../../../app/store';
import ImageUploadButton from '../../../common/components/ImageUploaderButton';
import CurrentImageDisplay from '../../gallery/CurrentImageDisplay';
import InitImagePreview from './InitImagePreview';
const ImageToImageDisplay = () => {
const dispatch = useAppDispatch();
const initialImage = useAppSelector(
(state: RootState) => state.options.initialImage
);
@ -18,11 +15,7 @@ const ImageToImageDisplay = () => {
<InitImagePreview />
</div>
) : (
<InvokeImageUploader
handleFile={(file: File) =>
dispatch(uploadImage({ file, destination: 'img2img' }))
}
/>
<ImageUploadButton />
);
return (

@ -16,6 +16,7 @@ import { useAppDispatch, useAppSelector } from '../../../app/store';
import {
addLine,
addPointToCurrentLine,
clearImageToInpaint,
setCursorPosition,
setIsDrawing,
} from './inpaintingSlice';
@ -33,6 +34,7 @@ import InpaintingBoundingBoxPreview, {
} from './components/InpaintingBoundingBoxPreview';
import { KonvaEventObject } from 'konva/lib/Node';
import KeyboardEventManager from './components/KeyboardEventManager';
import { useToast } from '@chakra-ui/react';
// Use a closure allow other components to use these things... not ideal...
export let stageRef: MutableRefObject<StageType | null>;
@ -57,6 +59,8 @@ const InpaintingCanvas = () => {
shouldShowBoundingBox,
} = useAppSelector(inpaintingCanvasSelector);
const toast = useToast();
// set the closure'd refs
stageRef = useRef<StageType>(null);
maskLayerRef = useRef<Konva.Layer>(null);
@ -80,9 +84,18 @@ const InpaintingCanvas = () => {
inpaintingImageElementRef.current = image;
setCanvasBgImage(image);
};
image.onerror = () => {
toast({
title: 'Unable to Load Image',
description: `Image ${imageToInpaint.url} failed to load`,
status: 'error',
isClosable: true,
});
dispatch(clearImageToInpaint());
};
image.src = imageToInpaint.url;
}
}, [imageToInpaint, dispatch, stageScale]);
}, [imageToInpaint, dispatch, stageScale, toast]);
/**
*

@ -1,9 +1,8 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { useLayoutEffect } from 'react';
import { uploadImage } from '../../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import InvokeImageUploader from '../../../common/components/InvokeImageUploader';
import ImageUploadButton from '../../../common/components/ImageUploaderButton';
import CurrentImageDisplay from '../../gallery/CurrentImageDisplay';
import { OptionsState } from '../../options/optionsSlice';
import InpaintingCanvas from './InpaintingCanvas';
@ -36,10 +35,7 @@ const InpaintingDisplay = () => {
);
useLayoutEffect(() => {
const resizeCallback = _.debounce(
() => dispatch(setNeedsCache(true)),
250
);
const resizeCallback = _.debounce(() => dispatch(setNeedsCache(true)), 250);
window.addEventListener('resize', resizeCallback);
return () => window.removeEventListener('resize', resizeCallback);
}, [dispatch]);
@ -52,11 +48,7 @@ const InpaintingDisplay = () => {
</div>
</div>
) : (
<InvokeImageUploader
handleFile={(file: File) =>
dispatch(uploadImage({ file, destination: 'inpainting' }))
}
/>
<ImageUploadButton />
);
return (

@ -51,7 +51,7 @@
@use '../common/components/IAICheckbox.scss';
@use '../common/components/IAIPopover.scss';
@use '../common/components/IAIColorPicker.scss';
@use '../common/components/InvokeImageUploader.scss';
@use '../common/components/ImageUploader.scss';
@use '../common/components/WorkInProgress/WorkInProgress.scss';
@use '../common/components/GuidePopover.scss';