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/theme-tools": "^2.0.16",
"@dagrejs/graphlib": "^2.1.12",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/inter": "^4.5.15",

View File

@ -645,5 +645,9 @@
"betaDarkenOutside": "Darken Outside",
"betaLimitToBox": "Limit To Box",
"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 { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
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 = {};
@ -40,6 +50,9 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
const log = useLogger();
const currentTheme = useAppSelector((state) => state.ui.currentTheme);
const shouldShowProgressImage = useAppSelector(
(state) => state.ui.shouldShowProgressImage
);
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
@ -63,64 +76,90 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
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 (
<Grid w="100vw" h="100vh" position="relative">
{isLightboxEnabled && <Lightbox />}
<ImageUploader>
<ProgressBar />
<Grid
gap={4}
p={4}
gridAutoRows="min-content auto"
w={APP_WIDTH}
h={APP_HEIGHT}
>
{children || <SiteHeader />}
<Flex
<DndContext
onDragEnd={handleDragEnd}
sensors={sensors}
modifiers={[restrictToWindowEdges]}
>
<Grid w="100vw" h="100vh" position="relative" overflow="hidden">
{isLightboxEnabled && <Lightbox />}
<ImageUploader>
<ProgressBar />
<Grid
gap={4}
w={{ base: '100vw', xl: 'full' }}
h="full"
flexDir={{ base: 'column', xl: 'row' }}
p={4}
gridAutoRows="min-content auto"
w={APP_WIDTH}
h={APP_HEIGHT}
>
<InvokeTabs />
<ImageGalleryPanel />
</Flex>
</Grid>
</ImageUploader>
{children || <SiteHeader />}
<Flex
gap={4}
w={{ base: '100vw', xl: 'full' }}
h="full"
flexDir={{ base: 'column', xl: 'row' }}
>
<InvokeTabs />
<ImageGalleryPanel />
</Flex>
</Grid>
</ImageUploader>
<AnimatePresence>
{!isApplicationReady && !loadingOverridden && (
<motion.div
key="loading"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
style={{ zIndex: 3 }}
>
<Box position="absolute" top={0} left={0} w="100vw" h="100vh">
<Loading />
</Box>
<Box
onClick={handleOverrideClicked}
position="absolute"
top={0}
right={0}
cursor="pointer"
w="2rem"
h="2rem"
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{!isApplicationReady && !loadingOverridden && (
<motion.div
key="loading"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
style={{ zIndex: 3 }}
>
<Box position="absolute" top={0} left={0} w="100vw" h="100vh">
<Loading />
</Box>
<Box
onClick={handleOverrideClicked}
position="absolute"
top={0}
right={0}
cursor="pointer"
w="2rem"
h="2rem"
/>
</motion.div>
)}
</AnimatePresence>
<Portal>
<FloatingParametersPanelButtons />
</Portal>
<Portal>
<FloatingGalleryButton />
</Portal>
</Grid>
<Portal>
<FloatingParametersPanelButtons />
</Portal>
<Portal>
<FloatingGalleryButton />
</Portal>
<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 { PersistGate } from 'redux-persist/integration/react';
import { store } from 'app/store/store';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,260 +1,80 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/types/invokeai';
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 { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import { imageUploaded } from 'services/thunks/image';
export type GalleryCategory = 'user' | 'result';
export type AddImagesPayload = {
images: Array<InvokeAI._Image>;
areMoreImagesAvailable: boolean;
category: GalleryCategory;
};
import { SelectedImage } from 'features/parameters/store/generationSlice';
type GalleryImageObjectFitType = 'contain' | 'cover';
export type Gallery = {
images: InvokeAI._Image[];
latest_mtime?: number;
earliest_mtime?: number;
areMoreImagesAvailable: boolean;
};
export interface GalleryState {
/**
* The selected image's unique name
* Use `selectedImageSelector` to access the image
* The selected image
*/
selectedImageName: string;
/**
* 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;
};
selectedImage?: SelectedImage;
galleryImageMinimumWidth: number;
galleryImageObjectFit: GalleryImageObjectFitType;
shouldAutoSwitchToNewImages: boolean;
categories: {
user: Gallery;
result: Gallery;
};
currentCategory: GalleryCategory;
galleryWidth: number;
shouldUseSingleGalleryColumn: boolean;
currentCategory: 'results' | 'uploads';
}
const initialState: GalleryState = {
selectedImageName: '',
currentImageUuid: '',
selectedImage: undefined,
galleryImageMinimumWidth: 64,
galleryImageObjectFit: 'cover',
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,
shouldUseSingleGalleryColumn: false,
currentCategory: 'results',
};
export const gallerySlice = createSlice({
name: 'gallery',
initialState,
reducers: {
imageSelected: (state, action: PayloadAction<string>) => {
state.selectedImageName = action.payload;
},
setCurrentImage: (state, action: PayloadAction<InvokeAI._Image>) => {
state.currentImage = action.payload;
state.currentImageUuid = action.payload.uuid;
},
removeImage: (
imageSelected: (
state,
action: PayloadAction<InvokeAI.ImageDeletedResponse>
action: PayloadAction<SelectedImage | undefined>
) => {
const { uuid, category } = 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;
state.selectedImage = action.payload;
},
addImage: (
state,
action: PayloadAction<{
image: InvokeAI._Image;
category: GalleryCategory;
}>
) => {
const { image: newImage, category } = action.payload;
const { uuid, url, mtime } = newImage;
// selectNextImage: (state) => {
// const { currentImage } = state;
// if (!currentImage) return;
// const tempImages =
// state.categories[currentImage.category as GalleryCategory].images;
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 (tempCategory.images.find((i) => i.url === url && i.mtime === mtime)) {
return;
}
tempCategory.images.unshift(newImage);
if (state.shouldAutoSwitchToNewImages) {
state.currentImageUuid = 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;
}
},
// 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;
// }
// }
// },
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload;
},
@ -267,7 +87,10 @@ export const gallerySlice = createSlice({
setShouldAutoSwitchToNewImages: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitchToNewImages = action.payload;
},
setCurrentCategory: (state, action: PayloadAction<GalleryCategory>) => {
setCurrentCategory: (
state,
action: PayloadAction<'results' | 'uploads'>
) => {
state.currentCategory = action.payload;
},
setGalleryWidth: (state, action: PayloadAction<number>) => {
@ -286,9 +109,11 @@ export const gallerySlice = createSlice({
*/
builder.addCase(invocationComplete, (state, action) => {
const { data } = action.payload;
if (isImageOutput(data.result)) {
state.selectedImageName = data.result.image.image_name;
state.intermediateImage = undefined;
if (isImageOutput(data.result) && state.shouldAutoSwitchToNewImages) {
state.selectedImage = {
name: data.result.image.image_name,
type: 'results',
};
}
});
@ -299,27 +124,19 @@ export const gallerySlice = createSlice({
const { response } = action.payload;
const uploadedImage = deserializeImageResponse(response);
state.selectedImageName = uploadedImage.name;
state.selectedImage = { name: uploadedImage.name, type: 'uploads' };
});
},
});
export const {
imageSelected,
addImage,
clearIntermediateImage,
removeImage,
setCurrentImage,
addGalleryImages,
setIntermediateImage,
selectNextImage,
selectPrevImage,
setGalleryImageMinimumWidth,
setGalleryImageObjectFit,
setShouldAutoSwitchToNewImages,
setCurrentCategory,
setGalleryWidth,
setShouldUseSingleGalleryColumn,
setCurrentCategory,
} = gallerySlice.actions;
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 { ImageField, ImageType } from 'services/api';
export type InitialImage = {
export type SelectedImage = {
name: string;
type: ImageType;
};
@ -17,7 +17,7 @@ export interface GenerationState {
height: number;
img2imgStrength: number;
infillMethod: string;
initialImage?: InitialImage; // can be an Image or url
initialImage?: SelectedImage; // can be an Image or url
iterations: number;
maskPath: string;
perlin: number;
@ -351,7 +351,7 @@ export const generationSlice = createSlice({
setVerticalSymmetrySteps: (state, action: PayloadAction<number>) => {
state.verticalSymmetrySteps = action.payload;
},
initialImageSelected: (state, action: PayloadAction<InitialImage>) => {
initialImageSelected: (state, action: PayloadAction<SelectedImage>) => {
state.initialImage = action.payload;
state.isImageToImageEnabled = true;
},

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { createSlice } from '@reduxjs/toolkit';
import { setActiveTabReducer } from './extraReducers';
import { InvokeTabName, tabMap } from './tabMap';
import { AddNewModelType, UIState } from './uiTypes';
import { Coordinates } from '@dnd-kit/core/dist/types';
const initialUIState: UIState = {
activeTab: 0,
@ -21,6 +22,8 @@ const initialUIState: UIState = {
openLinearAccordionItems: [],
openGenerateAccordionItems: [],
openUnifiedCanvasAccordionItems: [],
floatingProgressImageCoordinates: { x: 0, y: 0 },
shouldShowProgressImage: false,
};
const initialState: UIState = initialUIState;
@ -105,6 +108,15 @@ export const uiSlice = createSlice({
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,
toggleGalleryPanel,
openAccordionItemsChanged,
floatingProgressImageMoved,
shouldShowProgressImageChanged,
} = uiSlice.actions;
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;
@ -19,4 +19,6 @@ export interface UIState {
openLinearAccordionItems: number[];
openGenerateAccordionItems: 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 { createAppAsyncThunk } from 'app/store/storeUtils';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { clamp } from 'lodash-es';
import { clamp, isString } from 'lodash-es';
import { ImagesService } from 'services/api';
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.
// Unfortunately, we have to do this here, because the resultsSlice and uploadsSlice cannot change
// the selected image.
const selectedImageName = getState().gallery.selectedImageName;
const selectedImageName = getState().gallery.selectedImage?.name;
if (selectedImageName === imageName) {
const allIds = getState()[imageType].ids;
@ -104,9 +104,13 @@ export const imageDeleted = createAppAsyncThunk(
const newSelectedImageId = filteredIds[newSelectedImageIndex];
dispatch(
imageSelected(newSelectedImageId ? newSelectedImageId.toString() : '')
);
if (newSelectedImageId) {
dispatch(
imageSelected({ name: newSelectedImageId as string, type: imageType })
);
} else {
dispatch(imageSelected());
}
}
const response = await ImagesService.deleteImage(arg);

View File

@ -937,6 +937,37 @@
gonzales-pe "^4.3.0"
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":
version "11.10.8"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.8.tgz#bae325c902937665d00684038fd5294223ef9e1d"