feat(ui): gallery & progress image refactor

This commit is contained in:
psychedelicious 2023-04-30 22:01:34 +10:00
parent 3601b9c860
commit 270657a62c
21 changed files with 466 additions and 410 deletions

View File

@ -61,6 +61,8 @@
"@chakra-ui/styled-system": "^2.9.0", "@chakra-ui/styled-system": "^2.9.0",
"@chakra-ui/theme-tools": "^2.0.16", "@chakra-ui/theme-tools": "^2.0.16",
"@dagrejs/graphlib": "^2.1.12", "@dagrejs/graphlib": "^2.1.12",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/inter": "^4.5.15", "@fontsource/inter": "^4.5.15",

View File

@ -645,5 +645,9 @@
"betaDarkenOutside": "Darken Outside", "betaDarkenOutside": "Darken Outside",
"betaLimitToBox": "Limit To Box", "betaLimitToBox": "Limit To Box",
"betaPreserveMasked": "Preserve Masked" "betaPreserveMasked": "Preserve Masked"
},
"ui": {
"showProgressImages": "Show Progress Images",
"hideProgressImages": "Hide Progress Images"
} }
} }

View File

@ -27,6 +27,16 @@ import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { configChanged } from 'features/system/store/configSlice'; import { configChanged } from 'features/system/store/configSlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useLogger } from 'app/logging/useLogger'; import { useLogger } from 'app/logging/useLogger';
import ProgressImagePreview from 'features/parameters/components/ProgressImagePreview';
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { floatingProgressImageMoved } from 'features/ui/store/uiSlice';
import { restrictToWindowEdges } from '@dnd-kit/modifiers';
const DEFAULT_CONFIG = {}; const DEFAULT_CONFIG = {};
@ -40,6 +50,9 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
const log = useLogger(); const log = useLogger();
const currentTheme = useAppSelector((state) => state.ui.currentTheme); const currentTheme = useAppSelector((state) => state.ui.currentTheme);
const shouldShowProgressImage = useAppSelector(
(state) => state.ui.shouldShowProgressImage
);
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
@ -63,64 +76,90 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
setLoadingOverridden(true); setLoadingOverridden(true);
}, []); }, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
dispatch(
floatingProgressImageMoved({ x: event.delta.x, y: event.delta.y })
);
},
[dispatch]
);
const pointer = useSensor(PointerSensor, {
// Delay the drag events (allow clicks on elements)
activationConstraint: {
delay: 100,
tolerance: 5,
},
});
const sensors = useSensors(pointer);
return ( return (
<Grid w="100vw" h="100vh" position="relative"> <DndContext
{isLightboxEnabled && <Lightbox />} onDragEnd={handleDragEnd}
<ImageUploader> sensors={sensors}
<ProgressBar /> modifiers={[restrictToWindowEdges]}
<Grid >
gap={4} <Grid w="100vw" h="100vh" position="relative" overflow="hidden">
p={4} {isLightboxEnabled && <Lightbox />}
gridAutoRows="min-content auto" <ImageUploader>
w={APP_WIDTH} <ProgressBar />
h={APP_HEIGHT} <Grid
>
{children || <SiteHeader />}
<Flex
gap={4} gap={4}
w={{ base: '100vw', xl: 'full' }} p={4}
h="full" gridAutoRows="min-content auto"
flexDir={{ base: 'column', xl: 'row' }} w={APP_WIDTH}
h={APP_HEIGHT}
> >
<InvokeTabs /> {children || <SiteHeader />}
<ImageGalleryPanel /> <Flex
</Flex> gap={4}
</Grid> w={{ base: '100vw', xl: 'full' }}
</ImageUploader> h="full"
flexDir={{ base: 'column', xl: 'row' }}
>
<InvokeTabs />
<ImageGalleryPanel />
</Flex>
</Grid>
</ImageUploader>
<AnimatePresence> <AnimatePresence>
{!isApplicationReady && !loadingOverridden && ( {!isApplicationReady && !loadingOverridden && (
<motion.div <motion.div
key="loading" key="loading"
initial={{ opacity: 1 }} initial={{ opacity: 1 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
style={{ zIndex: 3 }} style={{ zIndex: 3 }}
> >
<Box position="absolute" top={0} left={0} w="100vw" h="100vh"> <Box position="absolute" top={0} left={0} w="100vw" h="100vh">
<Loading /> <Loading />
</Box> </Box>
<Box <Box
onClick={handleOverrideClicked} onClick={handleOverrideClicked}
position="absolute" position="absolute"
top={0} top={0}
right={0} right={0}
cursor="pointer" cursor="pointer"
w="2rem" w="2rem"
h="2rem" h="2rem"
/> />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<Portal> <Portal>
<FloatingParametersPanelButtons /> <FloatingParametersPanelButtons />
</Portal> </Portal>
<Portal> <Portal>
<FloatingGalleryButton /> <FloatingGalleryButton />
</Portal> </Portal>
</Grid> <ProgressImagePreview />
</Grid>
</DndContext>
); );
}; };

View File

@ -1,4 +1,11 @@
import React, { lazy, memo, PropsWithChildren, useEffect } from 'react'; import React, {
lazy,
memo,
PropsWithChildren,
useCallback,
useEffect,
useState,
} from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react'; import { PersistGate } from 'redux-persist/integration/react';
import { store } from 'app/store/store'; import { store } from 'app/store/store';

View File

@ -233,7 +233,7 @@ const IAISlider = (props: IAIFullSliderProps) => {
hidden={hideTooltip} hidden={hideTooltip}
{...sliderTooltipProps} {...sliderTooltipProps}
> >
<SliderThumb {...sliderThumbProps} /> <SliderThumb {...sliderThumbProps} zIndex={0} />
</Tooltip> </Tooltip>
</Slider> </Slider>

View File

@ -1,7 +1,7 @@
import { AnyAction, ThunkAction } from '@reduxjs/toolkit'; import { AnyAction, ThunkAction } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/types/invokeai'; import * as InvokeAI from 'app/types/invokeai';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { addImage } from 'features/gallery/store/gallerySlice'; // import { addImage } from 'features/gallery/store/gallerySlice';
import { import {
addToast, addToast,
setCurrentStatus, setCurrentStatus,

View File

@ -17,31 +17,11 @@ export const imagesSelector = createSelector(
[uiSelector, selectedImageSelector, systemSelector], [uiSelector, selectedImageSelector, systemSelector],
(ui, selectedImage, system) => { (ui, selectedImage, system) => {
const { shouldShowImageDetails, shouldHidePreview } = ui; const { shouldShowImageDetails, shouldHidePreview } = ui;
const { progressImage } = system;
// TODO: Clean this up, this is really gross
const imageToDisplay = progressImage
? {
url: progressImage.dataURL,
width: progressImage.width,
height: progressImage.height,
isProgressImage: true,
image: progressImage,
}
: selectedImage
? {
url: selectedImage.url,
width: selectedImage.metadata.width,
height: selectedImage.metadata.height,
isProgressImage: false,
image: selectedImage,
}
: null;
return { return {
shouldShowImageDetails, shouldShowImageDetails,
shouldHidePreview, shouldHidePreview,
imageToDisplay, image: selectedImage,
}; };
}, },
{ {
@ -52,7 +32,7 @@ export const imagesSelector = createSelector(
); );
const CurrentImagePreview = () => { const CurrentImagePreview = () => {
const { shouldShowImageDetails, imageToDisplay, shouldHidePreview } = const { shouldShowImageDetails, image, shouldHidePreview } =
useAppSelector(imagesSelector); useAppSelector(imagesSelector);
const { getUrl } = useGetUrl(); const { getUrl } = useGetUrl();
@ -66,54 +46,37 @@ const CurrentImagePreview = () => {
height: '100%', height: '100%',
}} }}
> >
{imageToDisplay && ( {image && (
<Image <Image
src={ src={shouldHidePreview ? undefined : getUrl(image.url)}
shouldHidePreview width={image.metadata.width}
? undefined height={image.metadata.height}
: imageToDisplay.isProgressImage fallback={shouldHidePreview ? <CurrentImageHidden /> : undefined}
? imageToDisplay.url
: getUrl(imageToDisplay.url)
}
width={imageToDisplay.width}
height={imageToDisplay.height}
fallback={
shouldHidePreview ? (
<CurrentImageHidden />
) : !imageToDisplay.isProgressImage ? (
<CurrentImageFallback />
) : undefined
}
sx={{ sx={{
objectFit: 'contain', objectFit: 'contain',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
height: 'auto', height: 'auto',
position: 'absolute', position: 'absolute',
imageRendering: imageToDisplay.isProgressImage
? 'pixelated'
: 'initial',
borderRadius: 'base', borderRadius: 'base',
}} }}
/> />
)} )}
{!shouldShowImageDetails && <NextPrevImageButtons />} {!shouldShowImageDetails && <NextPrevImageButtons />}
{shouldShowImageDetails && {shouldShowImageDetails && image && 'metadata' in image && (
imageToDisplay && <Box
'metadata' in imageToDisplay.image && ( sx={{
<Box position: 'absolute',
sx={{ top: '0',
position: 'absolute', width: '100%',
top: '0', height: '100%',
width: '100%', borderRadius: 'base',
height: '100%', overflow: 'scroll',
borderRadius: 'base', }}
overflow: 'scroll', >
}} <ImageMetadataViewer image={image} />
> </Box>
<ImageMetadataViewer image={imageToDisplay.image} /> )}
</Box>
)}
</Flex> </Flex>
); );
}; };

View File

@ -125,7 +125,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
}, [handleDelete, onDeleteDialogOpen, shouldConfirmOnDelete]); }, [handleDelete, onDeleteDialogOpen, shouldConfirmOnDelete]);
const handleSelectImage = useCallback(() => { const handleSelectImage = useCallback(() => {
dispatch(imageSelected(image.name)); dispatch(imageSelected(image));
}, [image, dispatch]); }, [image, dispatch]);
const handleDragStart = useCallback( const handleDragStart = useCallback(

View File

@ -49,7 +49,7 @@ const gallerySelector = createSelector(
(uploads, results, gallery) => { (uploads, results, gallery) => {
const { currentCategory } = gallery; const { currentCategory } = gallery;
return currentCategory === 'result' return currentCategory === 'results'
? { ? {
images: resultsAdapter.getSelectors().selectAll(results), images: resultsAdapter.getSelectors().selectAll(results),
isLoading: results.isLoading, isLoading: results.isLoading,
@ -72,7 +72,6 @@ const ImageGalleryContent = () => {
const { const {
// images, // images,
currentCategory, currentCategory,
currentImageUuid,
shouldPinGallery, shouldPinGallery,
galleryImageMinimumWidth, galleryImageMinimumWidth,
galleryGridTemplateColumns, galleryGridTemplateColumns,
@ -80,6 +79,7 @@ const ImageGalleryContent = () => {
shouldAutoSwitchToNewImages, shouldAutoSwitchToNewImages,
// areMoreImagesAvailable, // areMoreImagesAvailable,
shouldUseSingleGalleryColumn, shouldUseSingleGalleryColumn,
selectedImage,
} = useAppSelector(imageGallerySelector); } = useAppSelector(imageGallerySelector);
const { images, areMoreImagesAvailable, isLoading } = const { images, areMoreImagesAvailable, isLoading } =
@ -89,11 +89,11 @@ const ImageGalleryContent = () => {
// dispatch(requestImages(currentCategory)); // dispatch(requestImages(currentCategory));
// }; // };
const handleClickLoadMore = () => { const handleClickLoadMore = () => {
if (currentCategory === 'result') { if (currentCategory === 'results') {
dispatch(receivedResultImagesPage()); dispatch(receivedResultImagesPage());
} }
if (currentCategory === 'user') { if (currentCategory === 'uploads') {
dispatch(receivedUploadImagesPage()); dispatch(receivedUploadImagesPage());
} }
}; };
@ -147,34 +147,34 @@ const ImageGalleryContent = () => {
<IAIIconButton <IAIIconButton
aria-label={t('gallery.showGenerations')} aria-label={t('gallery.showGenerations')}
tooltip={t('gallery.showGenerations')} tooltip={t('gallery.showGenerations')}
isChecked={currentCategory === 'result'} isChecked={currentCategory === 'results'}
role="radio" role="radio"
icon={<FaImage />} icon={<FaImage />}
onClick={() => dispatch(setCurrentCategory('result'))} onClick={() => dispatch(setCurrentCategory('results'))}
/> />
<IAIIconButton <IAIIconButton
aria-label={t('gallery.showUploads')} aria-label={t('gallery.showUploads')}
tooltip={t('gallery.showUploads')} tooltip={t('gallery.showUploads')}
role="radio" role="radio"
isChecked={currentCategory === 'user'} isChecked={currentCategory === 'uploads'}
icon={<FaUser />} icon={<FaUser />}
onClick={() => dispatch(setCurrentCategory('user'))} onClick={() => dispatch(setCurrentCategory('uploads'))}
/> />
</> </>
) : ( ) : (
<> <>
<IAIButton <IAIButton
size="sm" size="sm"
isChecked={currentCategory === 'result'} isChecked={currentCategory === 'results'}
onClick={() => dispatch(setCurrentCategory('result'))} onClick={() => dispatch(setCurrentCategory('results'))}
flexGrow={1} flexGrow={1}
> >
{t('gallery.generations')} {t('gallery.generations')}
</IAIButton> </IAIButton>
<IAIButton <IAIButton
size="sm" size="sm"
isChecked={currentCategory === 'user'} isChecked={currentCategory === 'uploads'}
onClick={() => dispatch(setCurrentCategory('user'))} onClick={() => dispatch(setCurrentCategory('uploads'))}
flexGrow={1} flexGrow={1}
> >
{t('gallery.uploads')} {t('gallery.uploads')}
@ -251,7 +251,7 @@ const ImageGalleryContent = () => {
> >
{images.map((image) => { {images.map((image) => {
const { name } = image; const { name } = image;
const isSelected = currentImageUuid === name; const isSelected = selectedImage?.name === name;
return ( return (
<HoverableImage <HoverableImage
key={`${name}-${image.thumbnail}`} key={`${name}-${image.thumbnail}`}

View File

@ -1,8 +1,8 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { gallerySelector } from 'features/gallery/store/gallerySelectors'; import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import { import {
selectNextImage, // selectNextImage,
selectPrevImage, // selectPrevImage,
setGalleryImageMinimumWidth, setGalleryImageMinimumWidth,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { InvokeTabName } from 'features/ui/store/tabMap'; import { InvokeTabName } from 'features/ui/store/tabMap';

View File

@ -6,11 +6,13 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa'; import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { gallerySelector } from '../store/gallerySelectors'; import { gallerySelector } from '../store/gallerySelectors';
import { import { RootState } from 'app/store/store';
GalleryCategory, import { selectResultsEntities } from '../store/resultsSlice';
selectNextImage, // import {
selectPrevImage, // GalleryCategory,
} from '../store/gallerySlice'; // selectNextImage,
// selectPrevImage,
// } from '../store/gallerySlice';
const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = { const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = {
height: '100%', height: '100%',
@ -23,19 +25,22 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
}; };
export const nextPrevImageButtonsSelector = createSelector( export const nextPrevImageButtonsSelector = createSelector(
gallerySelector, [(state: RootState) => state, gallerySelector],
(gallery) => { (state, gallery) => {
const { currentImage } = gallery; const { selectedImage, currentCategory } = gallery;
const tempImages = if (!selectedImage) {
gallery.categories[ return {
currentImage ? (currentImage.category as GalleryCategory) : 'result' isOnFirstImage: true,
].images; isOnLastImage: true,
};
}
const currentImageIndex = tempImages.findIndex( const currentImageIndex = state[currentCategory].ids.findIndex(
(i) => i.uuid === gallery?.currentImage?.uuid (i) => i === selectedImage.name
); );
const imagesLength = tempImages.length;
const imagesLength = state[currentCategory].ids.length;
return { return {
isOnFirstImage: currentImageIndex === 0, isOnFirstImage: currentImageIndex === 0,
@ -81,7 +86,6 @@ const NextPrevImageButtons = () => {
<Flex <Flex
sx={{ sx={{
justifyContent: 'space-between', justifyContent: 'space-between',
zIndex: 1,
height: '100%', height: '100%',
width: '100%', width: '100%',
pointerEvents: 'none', pointerEvents: 'none',

View File

@ -22,17 +22,22 @@ import {
export const gallerySelector = (state: RootState) => state.gallery; export const gallerySelector = (state: RootState) => state.gallery;
export const imageGallerySelector = createSelector( export const imageGallerySelector = createSelector(
[gallerySelector, uiSelector, lightboxSelector, activeTabNameSelector], [
(gallery, ui, lightbox, activeTabName) => { (state: RootState) => state,
gallerySelector,
uiSelector,
lightboxSelector,
activeTabNameSelector,
],
(state, gallery, ui, lightbox, activeTabName) => {
const { const {
categories,
currentCategory, currentCategory,
currentImageUuid,
galleryImageMinimumWidth, galleryImageMinimumWidth,
galleryImageObjectFit, galleryImageObjectFit,
shouldAutoSwitchToNewImages, shouldAutoSwitchToNewImages,
galleryWidth, galleryWidth,
shouldUseSingleGalleryColumn, shouldUseSingleGalleryColumn,
selectedImage,
} = gallery; } = gallery;
const { shouldPinGallery } = ui; const { shouldPinGallery } = ui;
@ -40,7 +45,6 @@ export const imageGallerySelector = createSelector(
const { isLightboxOpen } = lightbox; const { isLightboxOpen } = lightbox;
return { return {
currentImageUuid,
shouldPinGallery, shouldPinGallery,
galleryImageMinimumWidth, galleryImageMinimumWidth,
galleryImageObjectFit, galleryImageObjectFit,
@ -49,9 +53,7 @@ export const imageGallerySelector = createSelector(
: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, auto))`, : `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, auto))`,
shouldAutoSwitchToNewImages, shouldAutoSwitchToNewImages,
currentCategory, currentCategory,
images: categories[currentCategory].images, images: state[currentCategory].entities,
areMoreImagesAvailable:
categories[currentCategory].areMoreImagesAvailable,
galleryWidth, galleryWidth,
shouldEnableResize: shouldEnableResize:
isLightboxOpen || isLightboxOpen ||
@ -59,6 +61,7 @@ export const imageGallerySelector = createSelector(
? false ? false
: true, : true,
shouldUseSingleGalleryColumn, shouldUseSingleGalleryColumn,
selectedImage,
}; };
}, },
{ {
@ -69,16 +72,16 @@ export const imageGallerySelector = createSelector(
); );
export const selectedImageSelector = createSelector( export const selectedImageSelector = createSelector(
[gallerySelector, selectResultsEntities, selectUploadsEntities], [(state: RootState) => state, gallerySelector],
(gallery, allResults, allUploads) => { (state, gallery) => {
const selectedImageName = gallery.selectedImageName; const selectedImage = gallery.selectedImage;
if (selectedImageName in allResults) { if (selectedImage?.type === 'results') {
return allResults[selectedImageName]; return selectResultsById(state, selectedImage.name);
} }
if (selectedImageName in allUploads) { if (selectedImage?.type === 'uploads') {
return allUploads[selectedImageName]; return selectUploadsById(state, selectedImage.name);
} }
} }
); );

View File

@ -1,260 +1,80 @@
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/types/invokeai';
import { invocationComplete } from 'services/events/actions'; import { invocationComplete } from 'services/events/actions';
import { InvokeTabName } from 'features/ui/store/tabMap';
import { IRect } from 'konva/lib/types';
import { clamp } from 'lodash-es';
import { isImageOutput } from 'services/types/guards'; import { isImageOutput } from 'services/types/guards';
import { deserializeImageResponse } from 'services/util/deserializeImageResponse'; import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import { imageUploaded } from 'services/thunks/image'; import { imageUploaded } from 'services/thunks/image';
import { SelectedImage } from 'features/parameters/store/generationSlice';
export type GalleryCategory = 'user' | 'result';
export type AddImagesPayload = {
images: Array<InvokeAI._Image>;
areMoreImagesAvailable: boolean;
category: GalleryCategory;
};
type GalleryImageObjectFitType = 'contain' | 'cover'; type GalleryImageObjectFitType = 'contain' | 'cover';
export type Gallery = {
images: InvokeAI._Image[];
latest_mtime?: number;
earliest_mtime?: number;
areMoreImagesAvailable: boolean;
};
export interface GalleryState { export interface GalleryState {
/** /**
* The selected image's unique name * The selected image
* Use `selectedImageSelector` to access the image
*/ */
selectedImageName: string; selectedImage?: SelectedImage;
/**
* The currently selected image
* @deprecated See `state.gallery.selectedImageName`
*/
currentImage?: InvokeAI._Image;
/**
* The currently selected image's uuid.
* @deprecated See `state.gallery.selectedImageName`, use `selectedImageSelector` to access the image
*/
currentImageUuid: string;
/**
* The current progress image
* @deprecated See `state.system.progressImage`
*/
intermediateImage?: InvokeAI._Image & {
boundingBox?: IRect;
generationMode?: InvokeTabName;
};
galleryImageMinimumWidth: number; galleryImageMinimumWidth: number;
galleryImageObjectFit: GalleryImageObjectFitType; galleryImageObjectFit: GalleryImageObjectFitType;
shouldAutoSwitchToNewImages: boolean; shouldAutoSwitchToNewImages: boolean;
categories: {
user: Gallery;
result: Gallery;
};
currentCategory: GalleryCategory;
galleryWidth: number; galleryWidth: number;
shouldUseSingleGalleryColumn: boolean; shouldUseSingleGalleryColumn: boolean;
currentCategory: 'results' | 'uploads';
} }
const initialState: GalleryState = { const initialState: GalleryState = {
selectedImageName: '', selectedImage: undefined,
currentImageUuid: '',
galleryImageMinimumWidth: 64, galleryImageMinimumWidth: 64,
galleryImageObjectFit: 'cover', galleryImageObjectFit: 'cover',
shouldAutoSwitchToNewImages: true, shouldAutoSwitchToNewImages: true,
currentCategory: 'result',
categories: {
user: {
images: [],
latest_mtime: undefined,
earliest_mtime: undefined,
areMoreImagesAvailable: true,
},
result: {
images: [],
latest_mtime: undefined,
earliest_mtime: undefined,
areMoreImagesAvailable: true,
},
},
galleryWidth: 300, galleryWidth: 300,
shouldUseSingleGalleryColumn: false, shouldUseSingleGalleryColumn: false,
currentCategory: 'results',
}; };
export const gallerySlice = createSlice({ export const gallerySlice = createSlice({
name: 'gallery', name: 'gallery',
initialState, initialState,
reducers: { reducers: {
imageSelected: (state, action: PayloadAction<string>) => { imageSelected: (
state.selectedImageName = action.payload;
},
setCurrentImage: (state, action: PayloadAction<InvokeAI._Image>) => {
state.currentImage = action.payload;
state.currentImageUuid = action.payload.uuid;
},
removeImage: (
state, state,
action: PayloadAction<InvokeAI.ImageDeletedResponse> action: PayloadAction<SelectedImage | undefined>
) => { ) => {
const { uuid, category } = action.payload; state.selectedImage = action.payload;
const tempImages = state.categories[category as GalleryCategory].images;
const newImages = tempImages.filter((image) => image.uuid !== uuid);
if (uuid === state.currentImageUuid) {
/**
* We are deleting the currently selected image.
*
* We want the new currentl selected image to be under the cursor in the
* gallery, so we need to do some fanagling. The currently selected image
* is set by its UUID, not its index in the image list.
*
* Get the currently selected image's index.
*/
const imageToDeleteIndex = tempImages.findIndex(
(image) => image.uuid === uuid
);
/**
* New current image needs to be in the same spot, but because the gallery
* is sorted in reverse order, the new current image's index will actuall be
* one less than the deleted image's index.
*
* Clamp the new index to ensure it is valid..
*/
const newCurrentImageIndex = clamp(
imageToDeleteIndex,
0,
newImages.length - 1
);
state.currentImage = newImages.length
? newImages[newCurrentImageIndex]
: undefined;
state.currentImageUuid = newImages.length
? newImages[newCurrentImageIndex].uuid
: '';
}
state.categories[category as GalleryCategory].images = newImages;
}, },
addImage: ( // selectNextImage: (state) => {
state, // const { currentImage } = state;
action: PayloadAction<{ // if (!currentImage) return;
image: InvokeAI._Image; // const tempImages =
category: GalleryCategory; // state.categories[currentImage.category as GalleryCategory].images;
}>
) => {
const { image: newImage, category } = action.payload;
const { uuid, url, mtime } = newImage;
const tempCategory = state.categories[category as GalleryCategory]; // if (currentImage) {
// const currentImageIndex = tempImages.findIndex(
// (i) => i.uuid === currentImage.uuid
// );
// if (currentImageIndex < tempImages.length - 1) {
// const newCurrentImage = tempImages[currentImageIndex + 1];
// state.currentImage = newCurrentImage;
// state.currentImageUuid = newCurrentImage.uuid;
// }
// }
// },
// selectPrevImage: (state) => {
// const { currentImage } = state;
// if (!currentImage) return;
// const tempImages =
// state.categories[currentImage.category as GalleryCategory].images;
// Do not add duplicate images // if (currentImage) {
if (tempCategory.images.find((i) => i.url === url && i.mtime === mtime)) { // const currentImageIndex = tempImages.findIndex(
return; // (i) => i.uuid === currentImage.uuid
} // );
// if (currentImageIndex > 0) {
tempCategory.images.unshift(newImage); // const newCurrentImage = tempImages[currentImageIndex - 1];
if (state.shouldAutoSwitchToNewImages) { // state.currentImage = newCurrentImage;
state.currentImageUuid = uuid; // state.currentImageUuid = newCurrentImage.uuid;
state.currentImage = newImage; // }
state.currentCategory = category; // }
} // },
state.intermediateImage = undefined;
tempCategory.latest_mtime = mtime;
},
setIntermediateImage: (
state,
action: PayloadAction<
InvokeAI._Image & {
boundingBox?: IRect;
generationMode?: InvokeTabName;
}
>
) => {
state.intermediateImage = action.payload;
},
clearIntermediateImage: (state) => {
state.intermediateImage = undefined;
},
selectNextImage: (state) => {
const { currentImage } = state;
if (!currentImage) return;
const tempImages =
state.categories[currentImage.category as GalleryCategory].images;
if (currentImage) {
const currentImageIndex = tempImages.findIndex(
(i) => i.uuid === currentImage.uuid
);
if (currentImageIndex < tempImages.length - 1) {
const newCurrentImage = tempImages[currentImageIndex + 1];
state.currentImage = newCurrentImage;
state.currentImageUuid = newCurrentImage.uuid;
}
}
},
selectPrevImage: (state) => {
const { currentImage } = state;
if (!currentImage) return;
const tempImages =
state.categories[currentImage.category as GalleryCategory].images;
if (currentImage) {
const currentImageIndex = tempImages.findIndex(
(i) => i.uuid === currentImage.uuid
);
if (currentImageIndex > 0) {
const newCurrentImage = tempImages[currentImageIndex - 1];
state.currentImage = newCurrentImage;
state.currentImageUuid = newCurrentImage.uuid;
}
}
},
addGalleryImages: (state, action: PayloadAction<AddImagesPayload>) => {
const { images, areMoreImagesAvailable, category } = action.payload;
const tempImages = state.categories[category].images;
// const prevImages = category === 'user' ? state.userImages : state.resultImages
if (images.length > 0) {
// Filter images that already exist in the gallery
const newImages = images.filter(
(newImage) =>
!tempImages.find(
(i) => i.url === newImage.url && i.mtime === newImage.mtime
)
);
state.categories[category].images = tempImages
.concat(newImages)
.sort((a, b) => b.mtime - a.mtime);
if (!state.currentImage) {
const newCurrentImage = images[0];
state.currentImage = newCurrentImage;
state.currentImageUuid = newCurrentImage.uuid;
}
// keep track of the timestamps of latest and earliest images received
state.categories[category].latest_mtime = images[0].mtime;
state.categories[category].earliest_mtime =
images[images.length - 1].mtime;
}
if (areMoreImagesAvailable !== undefined) {
state.categories[category].areMoreImagesAvailable =
areMoreImagesAvailable;
}
},
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => { setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload; state.galleryImageMinimumWidth = action.payload;
}, },
@ -267,7 +87,10 @@ export const gallerySlice = createSlice({
setShouldAutoSwitchToNewImages: (state, action: PayloadAction<boolean>) => { setShouldAutoSwitchToNewImages: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitchToNewImages = action.payload; state.shouldAutoSwitchToNewImages = action.payload;
}, },
setCurrentCategory: (state, action: PayloadAction<GalleryCategory>) => { setCurrentCategory: (
state,
action: PayloadAction<'results' | 'uploads'>
) => {
state.currentCategory = action.payload; state.currentCategory = action.payload;
}, },
setGalleryWidth: (state, action: PayloadAction<number>) => { setGalleryWidth: (state, action: PayloadAction<number>) => {
@ -286,9 +109,11 @@ export const gallerySlice = createSlice({
*/ */
builder.addCase(invocationComplete, (state, action) => { builder.addCase(invocationComplete, (state, action) => {
const { data } = action.payload; const { data } = action.payload;
if (isImageOutput(data.result)) { if (isImageOutput(data.result) && state.shouldAutoSwitchToNewImages) {
state.selectedImageName = data.result.image.image_name; state.selectedImage = {
state.intermediateImage = undefined; name: data.result.image.image_name,
type: 'results',
};
} }
}); });
@ -299,27 +124,19 @@ export const gallerySlice = createSlice({
const { response } = action.payload; const { response } = action.payload;
const uploadedImage = deserializeImageResponse(response); const uploadedImage = deserializeImageResponse(response);
state.selectedImageName = uploadedImage.name; state.selectedImage = { name: uploadedImage.name, type: 'uploads' };
}); });
}, },
}); });
export const { export const {
imageSelected, imageSelected,
addImage,
clearIntermediateImage,
removeImage,
setCurrentImage,
addGalleryImages,
setIntermediateImage,
selectNextImage,
selectPrevImage,
setGalleryImageMinimumWidth, setGalleryImageMinimumWidth,
setGalleryImageObjectFit, setGalleryImageObjectFit,
setShouldAutoSwitchToNewImages, setShouldAutoSwitchToNewImages,
setCurrentCategory,
setGalleryWidth, setGalleryWidth,
setShouldUseSingleGalleryColumn, setShouldUseSingleGalleryColumn,
setCurrentCategory,
} = gallerySlice.actions; } = gallerySlice.actions;
export default gallerySlice.reducer; export default gallerySlice.reducer;

View File

@ -0,0 +1,168 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
Icon,
Image,
Text,
} from '@chakra-ui/react';
import { useDraggable } from '@dnd-kit/core';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { systemSelector } from 'features/system/store/systemSelectors';
import { memo, useCallback, useRef, MouseEvent } from 'react';
import { CSS } from '@dnd-kit/utilities';
import { FaEye, FaImage } from 'react-icons/fa';
import { uiSelector } from 'features/ui/store/uiSelectors';
import { Resizable } from 're-resizable';
import { useBoolean, useHoverDirty } from 'react-use';
import IAIIconButton from 'common/components/IAIIconButton';
import { CloseIcon } from '@chakra-ui/icons';
import { useTranslation } from 'react-i18next';
const selector = createSelector([systemSelector, uiSelector], (system, ui) => {
const { progressImage } = system;
const { floatingProgressImageCoordinates, shouldShowProgressImage } = ui;
return {
progressImage,
coords: floatingProgressImageCoordinates,
shouldShowProgressImage,
};
});
const ProgressImagePreview = () => {
const { progressImage, coords, shouldShowProgressImage } =
useAppSelector(selector);
const [shouldShowProgressImages, toggleShouldShowProgressImages] =
useBoolean(false);
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: 'progress-image',
});
const transformStyles = transform
? {
transform: CSS.Translate.toString(transform),
}
: {};
return shouldShowProgressImages ? (
<Box
sx={{
position: 'absolute',
left: `${coords.x}px`,
top: `${coords.y}px`,
}}
>
<Box ref={setNodeRef} sx={transformStyles}>
<Box
sx={{
boxShadow: 'dark-lg',
w: 'full',
h: 'full',
bg: 'base.800',
borderRadius: 'base',
}}
>
<Resizable
defaultSize={{ width: 300, height: 300 }}
minWidth={200}
minHeight={200}
boundsByDirection={true}
enable={{ bottomRight: true }}
>
<Flex
sx={{
cursor: 'move',
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
{...listeners}
{...attributes}
>
<Flex
sx={{
position: 'relative',
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
p: 4,
}}
>
{progressImage ? (
<Flex
sx={{
position: 'relative',
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Image
src={progressImage.dataURL}
width={progressImage.width}
height={progressImage.height}
sx={{
position: 'absolute',
objectFit: 'contain',
maxWidth: '100%',
maxHeight: '100%',
height: 'auto',
imageRendering: 'pixelated',
borderRadius: 'base',
}}
/>
</Flex>
) : (
<Flex
sx={{
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon color="base.400" boxSize={32} as={FaImage}></Icon>
</Flex>
)}
</Flex>
<IAIIconButton
onClick={toggleShouldShowProgressImages}
aria-label={t('ui.hideProgressImages')}
size="xs"
icon={<CloseIcon />}
sx={{
position: 'absolute',
top: 2,
insetInlineEnd: 2,
}}
variant="ghost"
/>
</Flex>
</Resizable>
</Box>
</Box>
</Box>
) : (
<IAIIconButton
onClick={toggleShouldShowProgressImages}
tooltip={t('ui.showProgressImages')}
sx={{ position: 'absolute', bottom: 4, insetInlineStart: 4 }}
aria-label={t('ui.showProgressImages')}
icon={<FaEye />}
/>
);
};
export default memo(ProgressImagePreview);

View File

@ -7,7 +7,7 @@ import { seedWeightsToString } from 'common/util/seedWeightPairs';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { ImageField, ImageType } from 'services/api'; import { ImageField, ImageType } from 'services/api';
export type InitialImage = { export type SelectedImage = {
name: string; name: string;
type: ImageType; type: ImageType;
}; };
@ -17,7 +17,7 @@ export interface GenerationState {
height: number; height: number;
img2imgStrength: number; img2imgStrength: number;
infillMethod: string; infillMethod: string;
initialImage?: InitialImage; // can be an Image or url initialImage?: SelectedImage; // can be an Image or url
iterations: number; iterations: number;
maskPath: string; maskPath: string;
perlin: number; perlin: number;
@ -351,7 +351,7 @@ export const generationSlice = createSlice({
setVerticalSymmetrySteps: (state, action: PayloadAction<number>) => { setVerticalSymmetrySteps: (state, action: PayloadAction<number>) => {
state.verticalSymmetrySteps = action.payload; state.verticalSymmetrySteps = action.payload;
}, },
initialImageSelected: (state, action: PayloadAction<InitialImage>) => { initialImageSelected: (state, action: PayloadAction<SelectedImage>) => {
state.initialImage = action.payload; state.initialImage = action.payload;
state.isImageToImageEnabled = true; state.isImageToImageEnabled = true;
}, },

View File

@ -307,7 +307,7 @@ export const systemSlice = createSlice({
state.totalSteps = 0; state.totalSteps = 0;
// state.currentIteration = 0; // state.currentIteration = 0;
// state.totalIterations = 0; // state.totalIterations = 0;
state.statusTranslationKey = 'common.statusPreparing'; state.statusTranslationKey = 'common.statusGenerating';
}); });
/** /**
@ -347,7 +347,6 @@ export const systemSlice = createSlice({
state.currentStatusHasSteps = false; state.currentStatusHasSteps = false;
state.currentStep = 0; state.currentStep = 0;
state.totalSteps = 0; state.totalSteps = 0;
state.progressImage = null;
state.statusTranslationKey = 'common.statusProcessingComplete'; state.statusTranslationKey = 'common.statusProcessingComplete';
}); });
@ -364,7 +363,6 @@ export const systemSlice = createSlice({
state.currentStatusHasSteps = false; state.currentStatusHasSteps = false;
state.currentStep = 0; state.currentStep = 0;
state.totalSteps = 0; state.totalSteps = 0;
state.progressImage = null;
state.statusTranslationKey = 'common.statusError'; state.statusTranslationKey = 'common.statusError';
state.toastQueue.push( state.toastQueue.push(
@ -391,7 +389,6 @@ export const systemSlice = createSlice({
state.isCancelScheduled = false; state.isCancelScheduled = false;
state.currentStep = 0; state.currentStep = 0;
state.totalSteps = 0; state.totalSteps = 0;
state.progressImage = null;
state.statusTranslationKey = 'common.statusConnected'; state.statusTranslationKey = 'common.statusConnected';
state.toastQueue.push( state.toastQueue.push(
@ -410,7 +407,6 @@ export const systemSlice = createSlice({
state.isCancelScheduled = false; state.isCancelScheduled = false;
state.currentStep = 0; state.currentStep = 0;
state.totalSteps = 0; state.totalSteps = 0;
state.progressImage = null;
state.statusTranslationKey = 'common.statusConnected'; state.statusTranslationKey = 'common.statusConnected';
}); });

View File

@ -1,10 +1,12 @@
import { Box, Flex } from '@chakra-ui/react'; import { Box, Flex } from '@chakra-ui/react';
import CurrentImageDisplay from 'features/gallery/components/CurrentImageDisplay'; import CurrentImageDisplay from 'features/gallery/components/CurrentImageDisplay';
import ProgressImagePreview from 'features/parameters/components/ProgressImagePreview';
const GenerateContent = () => { const GenerateContent = () => {
return ( return (
<Box <Box
sx={{ sx={{
position: 'relative',
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 'base', borderRadius: 'base',

View File

@ -3,6 +3,7 @@ import { createSlice } from '@reduxjs/toolkit';
import { setActiveTabReducer } from './extraReducers'; import { setActiveTabReducer } from './extraReducers';
import { InvokeTabName, tabMap } from './tabMap'; import { InvokeTabName, tabMap } from './tabMap';
import { AddNewModelType, UIState } from './uiTypes'; import { AddNewModelType, UIState } from './uiTypes';
import { Coordinates } from '@dnd-kit/core/dist/types';
const initialUIState: UIState = { const initialUIState: UIState = {
activeTab: 0, activeTab: 0,
@ -21,6 +22,8 @@ const initialUIState: UIState = {
openLinearAccordionItems: [], openLinearAccordionItems: [],
openGenerateAccordionItems: [], openGenerateAccordionItems: [],
openUnifiedCanvasAccordionItems: [], openUnifiedCanvasAccordionItems: [],
floatingProgressImageCoordinates: { x: 0, y: 0 },
shouldShowProgressImage: false,
}; };
const initialState: UIState = initialUIState; const initialState: UIState = initialUIState;
@ -105,6 +108,15 @@ export const uiSlice = createSlice({
state.openUnifiedCanvasAccordionItems = action.payload; state.openUnifiedCanvasAccordionItems = action.payload;
} }
}, },
floatingProgressImageMoved: (state, action: PayloadAction<Coordinates>) => {
const { x, y } = state.floatingProgressImageCoordinates;
const { x: _x, y: _y } = action.payload;
state.floatingProgressImageCoordinates = { x: x + _x, y: y + _y };
},
shouldShowProgressImageChanged: (state, action: PayloadAction<boolean>) => {
state.shouldShowProgressImage = action.payload;
},
}, },
}); });
@ -128,6 +140,8 @@ export const {
toggleParametersPanel, toggleParametersPanel,
toggleGalleryPanel, toggleGalleryPanel,
openAccordionItemsChanged, openAccordionItemsChanged,
floatingProgressImageMoved,
shouldShowProgressImageChanged,
} = uiSlice.actions; } = uiSlice.actions;
export default uiSlice.reducer; export default uiSlice.reducer;

View File

@ -1,4 +1,4 @@
import { InvokeTabName } from './tabMap'; import { Coordinates } from '@dnd-kit/core/dist/types';
export type AddNewModelType = 'ckpt' | 'diffusers' | null; export type AddNewModelType = 'ckpt' | 'diffusers' | null;
@ -19,4 +19,6 @@ export interface UIState {
openLinearAccordionItems: number[]; openLinearAccordionItems: number[];
openGenerateAccordionItems: number[]; openGenerateAccordionItems: number[];
openUnifiedCanvasAccordionItems: number[]; openUnifiedCanvasAccordionItems: number[];
floatingProgressImageCoordinates: Coordinates;
shouldShowProgressImage: boolean;
} }

View File

@ -2,7 +2,7 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { createAppAsyncThunk } from 'app/store/storeUtils'; import { createAppAsyncThunk } from 'app/store/storeUtils';
import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageSelected } from 'features/gallery/store/gallerySlice';
import { clamp } from 'lodash-es'; import { clamp, isString } from 'lodash-es';
import { ImagesService } from 'services/api'; import { ImagesService } from 'services/api';
import { getHeaders } from 'services/util/getHeaders'; import { getHeaders } from 'services/util/getHeaders';
@ -85,7 +85,7 @@ export const imageDeleted = createAppAsyncThunk(
// Determine which image should replace the deleted image, if the deleted image is the selected image. // Determine which image should replace the deleted image, if the deleted image is the selected image.
// Unfortunately, we have to do this here, because the resultsSlice and uploadsSlice cannot change // Unfortunately, we have to do this here, because the resultsSlice and uploadsSlice cannot change
// the selected image. // the selected image.
const selectedImageName = getState().gallery.selectedImageName; const selectedImageName = getState().gallery.selectedImage?.name;
if (selectedImageName === imageName) { if (selectedImageName === imageName) {
const allIds = getState()[imageType].ids; const allIds = getState()[imageType].ids;
@ -104,9 +104,13 @@ export const imageDeleted = createAppAsyncThunk(
const newSelectedImageId = filteredIds[newSelectedImageIndex]; const newSelectedImageId = filteredIds[newSelectedImageIndex];
dispatch( if (newSelectedImageId) {
imageSelected(newSelectedImageId ? newSelectedImageId.toString() : '') dispatch(
); imageSelected({ name: newSelectedImageId as string, type: imageType })
);
} else {
dispatch(imageSelected());
}
} }
const response = await ImagesService.deleteImage(arg); const response = await ImagesService.deleteImage(arg);

View File

@ -937,6 +937,37 @@
gonzales-pe "^4.3.0" gonzales-pe "^4.3.0"
node-source-walk "^5.0.1" node-source-walk "^5.0.1"
"@dnd-kit/accessibility@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c"
integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==
dependencies:
tslib "^2.0.0"
"@dnd-kit/core@^6.0.8":
version "6.0.8"
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.8.tgz#040ae13fea9787ee078e5f0361f3b49b07f3f005"
integrity sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==
dependencies:
"@dnd-kit/accessibility" "^3.0.0"
"@dnd-kit/utilities" "^3.2.1"
tslib "^2.0.0"
"@dnd-kit/modifiers@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz#9e39b25fd6e323659604cc74488fe044d33188c8"
integrity sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==
dependencies:
"@dnd-kit/utilities" "^3.2.1"
tslib "^2.0.0"
"@dnd-kit/utilities@^3.2.1":
version "3.2.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.1.tgz#53f9e2016fd2506ec49e404c289392cfff30332a"
integrity sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==
dependencies:
tslib "^2.0.0"
"@emotion/babel-plugin@^11.10.8": "@emotion/babel-plugin@^11.10.8":
version "11.10.8" version "11.10.8"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.8.tgz#bae325c902937665d00684038fd5294223ef9e1d" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.8.tgz#bae325c902937665d00684038fd5294223ef9e1d"