feat(ui): refactor base image uploading logic

This commit is contained in:
psychedelicious 2023-05-15 17:45:05 +10:00
parent 5e4457445f
commit e1e5266fc3
21 changed files with 213 additions and 126 deletions

View File

@ -552,8 +552,8 @@
"canceled": "Processing Canceled",
"tempFoldersEmptied": "Temp Folder Emptied",
"uploadFailed": "Upload failed",
"uploadFailedMultipleImagesDesc": "Multiple images pasted, may only upload one image at a time",
"uploadFailedUnableToLoadDesc": "Unable to load file",
"uploadFailedInvalidUploadDesc": "Must be single PNG or JPEG image",
"downloadImageStarted": "Image Download Started",
"imageCopied": "Image Copied",
"imageLinkCopied": "Image Link Copied",

View File

@ -22,6 +22,7 @@ import { languageSelector } from 'features/system/store/systemSelectors';
import i18n from 'i18n';
import Toaster from './Toaster';
import GlobalHotkeys from './GlobalHotkeys';
import AuxiliaryProgressIndicator from './AuxiliaryProgressIndicator';
const DEFAULT_CONFIG = {};
@ -99,6 +100,8 @@ const App = ({
<GalleryDrawer />
<ParametersDrawer />
{/* <AuxiliaryProgressIndicator /> */}
<AnimatePresence>
{!isApplicationReady && !loadingOverridden && (
<motion.div

View File

@ -0,0 +1,44 @@
import { Flex, Spinner, Tooltip } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { systemSelector } from 'features/system/store/systemSelectors';
import { memo } from 'react';
const selector = createSelector(systemSelector, (system) => {
const { isUploading } = system;
let tooltip = '';
if (isUploading) {
tooltip = 'Uploading...';
}
return {
tooltip,
shouldShow: isUploading,
};
});
export const AuxiliaryProgressIndicator = () => {
const { shouldShow, tooltip } = useAppSelector(selector);
if (!shouldShow) {
return null;
}
return (
<Flex
sx={{
alignItems: 'center',
justifyContent: 'center',
color: 'base.600',
}}
>
<Tooltip label={tooltip} placement="right" hasArrow>
<Spinner />
</Tooltip>
</Flex>
);
};
export default memo(AuxiliaryProgressIndicator);

View File

@ -3,6 +3,7 @@ import { startAppListening } from '..';
import { uploadAdded } from 'features/gallery/store/uploadsSlice';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageUploaded } from 'services/thunks/image';
import { addToast } from 'features/system/store/systemSlice';
export const addImageUploadedListener = () => {
startAppListening({
@ -17,6 +18,8 @@ export const addImageUploadedListener = () => {
dispatch(uploadAdded(image));
dispatch(addToast({ title: 'Image Uploaded', status: 'success' }));
if (state.gallery.shouldAutoSwitchToNewImages) {
dispatch(imageSelected(image));
}

View File

@ -5,6 +5,7 @@ import {
Input,
InputProps,
} from '@chakra-ui/react';
import { stopPastePropagation } from 'common/util/stopPastePropagation';
import { ChangeEvent, memo } from 'react';
interface IAIInputProps extends InputProps {
@ -31,7 +32,7 @@ const IAIInput = (props: IAIInputProps) => {
{...formControlProps}
>
{label !== '' && <FormLabel>{label}</FormLabel>}
<Input {...rest} />
<Input {...rest} onPaste={stopPastePropagation} />
</FormControl>
);
};

View File

@ -14,6 +14,7 @@ import {
Tooltip,
TooltipProps,
} from '@chakra-ui/react';
import { stopPastePropagation } from 'common/util/stopPastePropagation';
import { clamp } from 'lodash-es';
import { FocusEvent, memo, useEffect, useState } from 'react';
@ -125,6 +126,7 @@ const IAINumberInput = (props: Props) => {
onChange={handleOnChange}
onBlur={handleBlur}
{...rest}
onPaste={stopPastePropagation}
>
<NumberInputField {...numberInputFieldProps} />
{showStepper && (

View File

@ -0,0 +1,9 @@
import { Textarea, TextareaProps, forwardRef } from '@chakra-ui/react';
import { stopPastePropagation } from 'common/util/stopPastePropagation';
import { memo } from 'react';
const IAITextarea = forwardRef((props: TextareaProps, ref) => {
return <Textarea ref={ref} onPaste={stopPastePropagation} {...props} />;
});
export default memo(IAITextarea);

View File

@ -6,10 +6,12 @@ import { FaUndo, FaUpload } from 'react-icons/fa';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCallback } from 'react';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import useImageUploader from 'common/hooks/useImageUploader';
const InitialImageButtons = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { openUploader } = useImageUploader();
const handleResetInitialImage = useCallback(() => {
dispatch(clearInitialImage());
@ -27,7 +29,11 @@ const InitialImageButtons = () => {
aria-label={t('accessibility.reset')}
onClick={handleResetInitialImage}
/>
<IAIIconButton icon={<FaUpload />} aria-label={t('common.upload')} />
<IAIIconButton
icon={<FaUpload />}
onClick={openUploader}
aria-label={t('common.upload')}
/>
</ButtonGroup>
</Flex>
);

View File

@ -10,6 +10,8 @@ import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FileRejection, useDropzone } from 'react-dropzone';
@ -17,6 +19,24 @@ import { useTranslation } from 'react-i18next';
import { imageUploaded } from 'services/thunks/image';
import ImageUploadOverlay from './ImageUploadOverlay';
import { useAppToaster } from 'app/components/Toaster';
import { filter, map, some } from 'lodash-es';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
import { ErrorCode } from 'react-dropzone';
const selector = createSelector(
[systemSelector, activeTabNameSelector],
(system, activeTabName) => {
const { isConnected, isUploading } = system;
const isUploaderDisabled = !isConnected || isUploading;
return {
isUploaderDisabled,
activeTabName,
};
}
);
type ImageUploaderProps = {
children: ReactNode;
@ -25,24 +45,20 @@ type ImageUploaderProps = {
const ImageUploader = (props: ImageUploaderProps) => {
const { children } = props;
const dispatch = useAppDispatch();
const activeTabName = useAppSelector(activeTabNameSelector);
const { isUploaderDisabled, activeTabName } = useAppSelector(selector);
const toaster = useAppToaster();
const { t } = useTranslation();
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const { setOpenUploader } = useImageUploader();
const { setOpenUploaderFunction } = useImageUploader();
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
setIsHandlingUpload(true);
const msg = rejection.errors.reduce(
(acc: string, cur: { message: string }) => `${acc}\n${cur.message}`,
''
);
toaster({
title: t('toast.uploadFailed'),
description: msg,
description: rejection.errors.map((error) => error.message).join('\n'),
status: 'error',
isClosable: true,
});
},
[t, toaster]
@ -57,6 +73,15 @@ const ImageUploader = (props: ImageUploaderProps) => {
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);
});
@ -65,7 +90,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
fileAcceptedCallback(file);
});
},
[fileAcceptedCallback, fileRejectionCallback]
[t, toaster, fileAcceptedCallback, fileRejectionCallback]
);
const {
@ -74,92 +99,67 @@ const ImageUploader = (props: ImageUploaderProps) => {
isDragAccept,
isDragReject,
isDragActive,
inputRef,
open,
} = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true,
onDrop,
onDragOver: () => setIsHandlingUpload(true),
maxFiles: 1,
disabled: isUploaderDisabled,
multiple: false,
});
setOpenUploader(open);
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) {
toaster({
description: t('toast.uploadFailedMultipleImagesDesc'),
status: 'error',
isClosable: true,
});
const handlePaste = async (e: ClipboardEvent) => {
if (!inputRef.current) {
return;
}
const file = imageItems[0].getAsFile();
if (!file) {
toaster({
description: t('toast.uploadFailedUnableToLoadDesc'),
status: 'error',
isClosable: true,
});
return;
if (e.clipboardData?.files) {
inputRef.current.files = e.clipboardData.files;
inputRef.current?.dispatchEvent(new Event('change', { bubbles: true }));
}
dispatch(imageUploaded({ imageType: 'uploads', formData: { file } }));
};
document.addEventListener('paste', pasteImageListener);
setOpenUploaderFunction(open);
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', pasteImageListener);
document.removeEventListener('paste', handlePaste);
setOpenUploaderFunction(() => {
return;
});
};
}, [t, dispatch, toaster, activeTabName]);
}, [inputRef, open, setOpenUploaderFunction]);
const overlaySecondaryText = ['img2img', 'unifiedCanvas'].includes(
activeTabName
)
? ` to ${String(t(`common.${activeTabName}` as ResourceKey))}`
: ``;
const overlaySecondaryText = useMemo(() => {
if (['img2img', 'unifiedCanvas'].includes(activeTabName)) {
return ` to ${String(t(`common.${activeTabName}` as ResourceKey))}`;
}
return '';
}, [t, activeTabName]);
return (
<ImageUploaderTriggerContext.Provider value={open}>
<Box
{...getRootProps({ style: {} })}
onKeyDown={(e: KeyboardEvent) => {
// Bail out if user hits spacebar - do not open the uploader
if (e.key === ' ') return;
}}
>
<input {...getInputProps()} />
{children}
{isDragActive && isHandlingUpload && (
<ImageUploadOverlay
isDragAccept={isDragAccept}
isDragReject={isDragReject}
overlaySecondaryText={overlaySecondaryText}
setIsHandlingUpload={setIsHandlingUpload}
/>
)}
</Box>
</ImageUploaderTriggerContext.Provider>
<Box
{...getRootProps({ style: {} })}
onKeyDown={(e: KeyboardEvent) => {
// Bail out if user hits spacebar - do not open the uploader
if (e.key === ' ') return;
}}
>
<input {...getInputProps()} />
{children}
{isDragActive && isHandlingUpload && (
<ImageUploadOverlay
isDragAccept={isDragAccept}
isDragReject={isDragReject}
overlaySecondaryText={overlaySecondaryText}
setIsHandlingUpload={setIsHandlingUpload}
/>
)}
</Box>
);
};

View File

@ -1,6 +1,5 @@
import { Flex, Heading, Icon } from '@chakra-ui/react';
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
import { useContext } from 'react';
import useImageUploader from 'common/hooks/useImageUploader';
import { FaUpload } from 'react-icons/fa';
type ImageUploaderButtonProps = {
@ -9,11 +8,7 @@ type ImageUploaderButtonProps = {
const ImageUploaderButton = (props: ImageUploaderButtonProps) => {
const { styleClass } = props;
const open = useContext(ImageUploaderTriggerContext);
const handleClickUpload = () => {
open && open();
};
const { openUploader } = useImageUploader();
return (
<Flex
@ -26,7 +21,7 @@ const ImageUploaderButton = (props: ImageUploaderButtonProps) => {
className={styleClass}
>
<Flex
onClick={handleClickUpload}
onClick={openUploader}
sx={{
display: 'flex',
flexDirection: 'column',

View File

@ -1,19 +1,18 @@
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa';
import IAIIconButton from './IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader';
const ImageUploaderIconButton = () => {
const { t } = useTranslation();
const openImageUploader = useContext(ImageUploaderTriggerContext);
const { openUploader } = useImageUploader();
return (
<IAIIconButton
aria-label={t('accessibility.uploadImage')}
tooltip="Upload Image"
icon={<FaUpload />}
onClick={openImageUploader || undefined}
onClick={openUploader}
/>
);
};

View File

@ -24,7 +24,6 @@ const Loading = () => {
height="24px !important"
right="1.5rem"
bottom="1.5rem"
speed="1.2s"
/>
</Flex>
);

View File

@ -1,13 +1,22 @@
let openFunction: () => void;
import { useCallback } from 'react';
let openUploader = () => {
return;
};
const useImageUploader = () => {
return {
setOpenUploader: (open?: () => void) => {
if (open) {
openFunction = open;
const setOpenUploaderFunction = useCallback(
(openUploaderFunction?: () => void) => {
if (openUploaderFunction) {
openUploader = openUploaderFunction;
}
},
openUploader: openFunction,
[]
);
return {
setOpenUploaderFunction,
openUploader,
};
};

View File

@ -0,0 +1,5 @@
import { ClipboardEvent } from 'react';
export const stopPastePropagation = (e: ClipboardEvent) => {
e.stopPropagation();
};

View File

@ -81,7 +81,7 @@ const IAICanvasResizer = () => {
height: '100%',
}}
>
<Spinner thickness="2px" speed="1s" size="xl" />
<Spinner thickness="2px" size="xl" />
</Flex>
);
};

View File

@ -62,7 +62,10 @@ const GalleryProgressImage = () => {
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
}}
/>
<Spinner sx={{ position: 'absolute', top: 1, right: 1, opacity: 0.7 }} />
<Spinner
sx={{ position: 'absolute', top: 1, right: 1, opacity: 0.7 }}
speed="1.2s"
/>
</Flex>
);
};

View File

@ -1,6 +1,7 @@
import { FormControl, Textarea } from '@chakra-ui/react';
import { FormControl } from '@chakra-ui/react';
import type { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAITextarea from 'common/components/IAITextarea';
import { setNegativePrompt } from 'features/parameters/store/generationSlice';
import { useTranslation } from 'react-i18next';
@ -14,7 +15,7 @@ const ParamNegativeConditioning = () => {
return (
<FormControl>
<Textarea
<IAITextarea
id="negativePrompt"
name="negativePrompt"
value={negativePrompt}

View File

@ -1,4 +1,4 @@
import { Box, FormControl, Textarea } from '@chakra-ui/react';
import { Box, FormControl } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { ChangeEvent, KeyboardEvent, useCallback, useRef } from 'react';
@ -16,6 +16,7 @@ import { isEqual } from 'lodash-es';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { userInvoked } from 'app/store/actions';
import IAITextarea from 'common/components/IAITextarea';
const promptInputSelector = createSelector(
[(state: RootState) => state.generation, activeTabNameSelector],
@ -72,7 +73,7 @@ const ParamPositiveConditioning = () => {
<FormControl
isInvalid={prompt.length === 0 || Boolean(prompt.match(/^[\s\r\n]+$/))}
>
<Textarea
<IAITextarea
id="prompt"
name="prompt"
placeholder={t('parameters.promptPlaceholder')}

View File

@ -3,24 +3,6 @@ import { SystemState } from './systemSlice';
/**
* System slice persist denylist
*/
const itemsToDenylist: (keyof SystemState)[] = [
'currentIteration',
'currentStatus',
'currentStep',
'isCancelable',
'isConnected',
'isESRGANAvailable',
'isGFPGANAvailable',
'isProcessing',
'socketId',
'totalIterations',
'totalSteps',
'openModel',
'isCancelScheduled',
'progressImage',
'wereModelsReceived',
'wasSchemaParsed',
];
export const systemPersistDenylist: (keyof SystemState)[] = [
'currentIteration',
'currentStatus',
@ -39,8 +21,5 @@ export const systemPersistDenylist: (keyof SystemState)[] = [
'wereModelsReceived',
'wasSchemaParsed',
'isPersisted',
'isUploading',
];
export const systemDenylist = itemsToDenylist.map(
(denylistItem) => `system.${denylistItem}`
);

View File

@ -25,6 +25,7 @@ import { TFuncKey } from 'i18next';
import { t } from 'i18next';
import { userInvoked } from 'app/store/actions';
import { LANGUAGES } from '../components/LanguagePicker';
import { imageUploaded } from 'services/thunks/image';
export type CancelStrategy = 'immediate' | 'scheduled';
@ -93,6 +94,7 @@ export interface SystemState {
isPersisted: boolean;
shouldAntialiasProgressImage: boolean;
language: keyof typeof LANGUAGES;
isUploading: boolean;
}
export const initialSystemState: SystemState = {
@ -128,6 +130,7 @@ export const initialSystemState: SystemState = {
infillMethods: ['tile', 'patchmatch'],
isPersisted: false,
language: 'en',
isUploading: false,
};
export const systemSlice = createSlice({
@ -456,6 +459,27 @@ export const systemSlice = createSlice({
builder.addCase(parsedOpenAPISchema, (state) => {
state.wasSchemaParsed = true;
});
/**
* Image Uploading Started
*/
builder.addCase(imageUploaded.pending, (state) => {
state.isUploading = true;
});
/**
* Image Uploading Complete
*/
builder.addCase(imageUploaded.rejected, (state) => {
state.isUploading = false;
});
/**
* Image Uploading Complete
*/
builder.addCase(imageUploaded.fulfilled, (state) => {
state.isUploading = false;
});
},
});

View File

@ -1,5 +1,6 @@
import {
Icon,
Spacer,
Tab,
TabList,
TabPanel,
@ -35,6 +36,7 @@ import NodesTab from './tabs/Nodes/NodesTab';
import { FaImage } from 'react-icons/fa';
import ResizeHandle from './tabs/ResizeHandle';
import ImageTab from './tabs/ImageToImage/ImageToImageTab';
import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator';
export interface InvokeTabInfo {
id: InvokeTabName;
@ -162,6 +164,8 @@ const InvokeTabs = () => {
justifyContent={{ base: 'center', xl: 'start' }}
>
{tabs}
<Spacer />
<AuxiliaryProgressIndicator />
</TabList>
<PanelGroup
autoSaveId="app"