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

View File

@ -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>

View File

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

View File

@ -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 />

View File

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

View File

@ -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;
};

View File

@ -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(

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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;

View File

@ -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 (

View File

@ -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]);
/**
*

View File

@ -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 (

View File

@ -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';