From 270657a62c9337cad011d4973adaee126dfee7af Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 30 Apr 2023 22:01:34 +1000 Subject: [PATCH] feat(ui): gallery & progress image refactor --- invokeai/frontend/web/package.json | 2 + invokeai/frontend/web/public/locales/en.json | 4 + .../frontend/web/src/app/components/App.tsx | 145 +++++---- .../web/src/app/components/InvokeAIUI.tsx | 9 +- .../web/src/common/components/IAISlider.tsx | 2 +- .../store/thunks/mergeAndUploadCanvas.ts | 2 +- .../components/CurrentImagePreview.tsx | 79 ++--- .../gallery/components/HoverableImage.tsx | 2 +- .../components/ImageGalleryContent.tsx | 26 +- .../gallery/components/ImageGalleryPanel.tsx | 4 +- .../components/NextPrevImageButtons.tsx | 36 ++- .../gallery/store/gallerySelectors.ts | 33 +- .../features/gallery/store/gallerySlice.ts | 287 ++++-------------- .../components/ProgressImagePreview.tsx | 168 ++++++++++ .../parameters/store/generationSlice.ts | 6 +- .../src/features/system/store/systemSlice.ts | 6 +- .../tabs/Generate/GenerateContent.tsx | 2 + .../web/src/features/ui/store/uiSlice.ts | 14 + .../web/src/features/ui/store/uiTypes.ts | 4 +- .../frontend/web/src/services/thunks/image.ts | 14 +- invokeai/frontend/web/yarn.lock | 31 ++ 21 files changed, 466 insertions(+), 410 deletions(-) create mode 100644 invokeai/frontend/web/src/features/parameters/components/ProgressImagePreview.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 326213c4f2..0d41e06897 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -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", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index d3ccbcb395..e4e49138dc 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -645,5 +645,9 @@ "betaDarkenOutside": "Darken Outside", "betaLimitToBox": "Limit To Box", "betaPreserveMasked": "Preserve Masked" + }, + "ui": { + "showProgressImages": "Show Progress Images", + "hideProgressImages": "Hide Progress Images" } } diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 37d0c7ba72..dff59efdb1 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -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 ( - - {isLightboxEnabled && } - - - - {children || } - + + {isLightboxEnabled && } + + + - - - - - + {children || } + + + + + + - - {!isApplicationReady && !loadingOverridden && ( - - - - - - - )} - + + {!isApplicationReady && !loadingOverridden && ( + + + + + + + )} + - - - - - - - + + + + + + + + + ); }; diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 97a8be6fc1..ca48770119 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -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'; diff --git a/invokeai/frontend/web/src/common/components/IAISlider.tsx b/invokeai/frontend/web/src/common/components/IAISlider.tsx index 44f039c433..48080e8970 100644 --- a/invokeai/frontend/web/src/common/components/IAISlider.tsx +++ b/invokeai/frontend/web/src/common/components/IAISlider.tsx @@ -233,7 +233,7 @@ const IAISlider = (props: IAIFullSliderProps) => { hidden={hideTooltip} {...sliderTooltipProps} > - + diff --git a/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts b/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts index d9feba63d3..e14871e11b 100644 --- a/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts +++ b/invokeai/frontend/web/src/features/canvas/store/thunks/mergeAndUploadCanvas.ts @@ -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, diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx index 8855bf11d3..cb666dd128 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx @@ -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 && ( - ) : !imageToDisplay.isProgressImage ? ( - - ) : undefined - } + src={shouldHidePreview ? undefined : getUrl(image.url)} + width={image.metadata.width} + height={image.metadata.height} + fallback={shouldHidePreview ? : undefined} sx={{ objectFit: 'contain', maxWidth: '100%', maxHeight: '100%', height: 'auto', position: 'absolute', - imageRendering: imageToDisplay.isProgressImage - ? 'pixelated' - : 'initial', borderRadius: 'base', }} /> )} {!shouldShowImageDetails && } - {shouldShowImageDetails && - imageToDisplay && - 'metadata' in imageToDisplay.image && ( - - - - )} + {shouldShowImageDetails && image && 'metadata' in image && ( + + + + )} ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx index bd9ff92082..d0ff9aee40 100644 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx @@ -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( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index e08e934e75..6524452e90 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -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 = () => { } - onClick={() => dispatch(setCurrentCategory('result'))} + onClick={() => dispatch(setCurrentCategory('results'))} /> } - onClick={() => dispatch(setCurrentCategory('user'))} + onClick={() => dispatch(setCurrentCategory('uploads'))} /> ) : ( <> dispatch(setCurrentCategory('result'))} + isChecked={currentCategory === 'results'} + onClick={() => dispatch(setCurrentCategory('results'))} flexGrow={1} > {t('gallery.generations')} 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 ( { - 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 = () => { 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); } } ); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 4d752a151b..4a7aa8a7e7 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -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; - 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) => { - state.selectedImageName = action.payload; - }, - setCurrentImage: (state, action: PayloadAction) => { - state.currentImage = action.payload; - state.currentImageUuid = action.payload.uuid; - }, - removeImage: ( + imageSelected: ( state, - action: PayloadAction + action: PayloadAction ) => { - 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) => { - 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) => { state.galleryImageMinimumWidth = action.payload; }, @@ -267,7 +87,10 @@ export const gallerySlice = createSlice({ setShouldAutoSwitchToNewImages: (state, action: PayloadAction) => { state.shouldAutoSwitchToNewImages = action.payload; }, - setCurrentCategory: (state, action: PayloadAction) => { + setCurrentCategory: ( + state, + action: PayloadAction<'results' | 'uploads'> + ) => { state.currentCategory = action.payload; }, setGalleryWidth: (state, action: PayloadAction) => { @@ -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; diff --git a/invokeai/frontend/web/src/features/parameters/components/ProgressImagePreview.tsx b/invokeai/frontend/web/src/features/parameters/components/ProgressImagePreview.tsx new file mode 100644 index 0000000000..63771c3ecd --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/ProgressImagePreview.tsx @@ -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 ? ( + + + + + + + {progressImage ? ( + + + + ) : ( + + + + )} + + } + sx={{ + position: 'absolute', + top: 2, + insetInlineEnd: 2, + }} + variant="ghost" + /> + + + + + + ) : ( + } + /> + ); +}; + +export default memo(ProgressImagePreview); diff --git a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts index 5dbe6b2c28..9d9d689cb0 100644 --- a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts @@ -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) => { state.verticalSymmetrySteps = action.payload; }, - initialImageSelected: (state, action: PayloadAction) => { + initialImageSelected: (state, action: PayloadAction) => { state.initialImage = action.payload; state.isImageToImageEnabled = true; }, diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts index cfaffdb65c..e7f58d2f4b 100644 --- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts @@ -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'; }); diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/Generate/GenerateContent.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/Generate/GenerateContent.tsx index 53fdcb4a49..de7b738956 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/Generate/GenerateContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/Generate/GenerateContent.tsx @@ -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 ( ) => { + const { x, y } = state.floatingProgressImageCoordinates; + const { x: _x, y: _y } = action.payload; + + state.floatingProgressImageCoordinates = { x: x + _x, y: y + _y }; + }, + shouldShowProgressImageChanged: (state, action: PayloadAction) => { + state.shouldShowProgressImage = action.payload; + }, }, }); @@ -128,6 +140,8 @@ export const { toggleParametersPanel, toggleGalleryPanel, openAccordionItemsChanged, + floatingProgressImageMoved, + shouldShowProgressImageChanged, } = uiSlice.actions; export default uiSlice.reducer; diff --git a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts index 66e85ce71b..309b110d4d 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts @@ -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; } diff --git a/invokeai/frontend/web/src/services/thunks/image.ts b/invokeai/frontend/web/src/services/thunks/image.ts index c4da9d9f16..a0d8f504b7 100644 --- a/invokeai/frontend/web/src/services/thunks/image.ts +++ b/invokeai/frontend/web/src/services/thunks/image.ts @@ -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); diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock index 88fec64725..f7f9af51ef 100644 --- a/invokeai/frontend/web/yarn.lock +++ b/invokeai/frontend/web/yarn.lock @@ -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"