diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 33fa57f0b3..bb2f140716 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -21,6 +21,7 @@ import { ReactNode, memo, useCallback, useEffect, useState } from 'react'; import { APP_HEIGHT, APP_WIDTH } from 'theme/util/constants'; import GlobalHotkeys from './GlobalHotkeys'; import Toaster from './Toaster'; +import DeleteImageModal from 'features/gallery/components/DeleteImageModal'; const DEFAULT_CONFIG = {}; @@ -76,18 +77,21 @@ const App = ({ {isLightboxEnabled && } {headerComponent || } @@ -130,6 +134,7 @@ const App = ({ + diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index c94f7624b2..0537d1de2a 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -17,6 +17,10 @@ import '../../i18n'; import { socketMiddleware } from 'services/events/middleware'; import { Middleware } from '@reduxjs/toolkit'; import ImageDndContext from './ImageDnd/ImageDndContext'; +import { + DeleteImageContext, + DeleteImageContextProvider, +} from 'app/contexts/DeleteImageContext'; const App = lazy(() => import('./App')); const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); @@ -71,11 +75,13 @@ const InvokeAIUI = ({ }> - + + + diff --git a/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx new file mode 100644 index 0000000000..8263b48114 --- /dev/null +++ b/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx @@ -0,0 +1,203 @@ +import { useDisclosure } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { requestedImageDeletion } from 'features/gallery/store/actions'; +import { systemSelector } from 'features/system/store/systemSelectors'; +import { + PropsWithChildren, + createContext, + useCallback, + useEffect, + useState, +} from 'react'; +import { ImageDTO } from 'services/api'; +import { RootState } from 'app/store/store'; +import { canvasSelector } from 'features/canvas/store/canvasSelectors'; +import { controlNetSelector } from 'features/controlNet/store/controlNetSlice'; +import { nodesSelecter } from 'features/nodes/store/nodesSlice'; +import { generationSelector } from 'features/parameters/store/generationSelectors'; +import { some } from 'lodash-es'; + +export type ImageUsage = { + isInitialImage: boolean; + isCanvasImage: boolean; + isNodesImage: boolean; + isControlNetImage: boolean; +}; + +export const selectImageUsage = createSelector( + [ + generationSelector, + canvasSelector, + nodesSelecter, + controlNetSelector, + (state: RootState, image_name?: string) => image_name, + ], + (generation, canvas, nodes, controlNet, image_name) => { + const isInitialImage = generation.initialImage?.image_name === image_name; + + const isCanvasImage = canvas.layerState.objects.some( + (obj) => obj.kind === 'image' && obj.image.image_name === image_name + ); + + const isNodesImage = nodes.nodes.some((node) => { + return some( + node.data.inputs, + (input) => + input.type === 'image' && input.value?.image_name === image_name + ); + }); + + const isControlNetImage = some( + controlNet.controlNets, + (c) => + c.controlImage?.image_name === image_name || + c.processedControlImage?.image_name === image_name + ); + + const imageUsage: ImageUsage = { + isInitialImage, + isCanvasImage, + isNodesImage, + isControlNetImage, + }; + + return imageUsage; + }, + defaultSelectorOptions +); + +type DeleteImageContextValue = { + /** + * Whether the delete image dialog is open. + */ + isOpen: boolean; + /** + * Closes the delete image dialog. + */ + onClose: () => void; + /** + * Opens the delete image dialog and handles all deletion-related checks. + */ + onDelete: (image?: ImageDTO) => void; + /** + * The image pending deletion + */ + image?: ImageDTO; + /** + * The features in which this image is used + */ + imageUsage?: ImageUsage; + /** + * Immediately deletes an image. + * + * You probably don't want to use this - use `onDelete` instead. + */ + onImmediatelyDelete: () => void; +}; + +export const DeleteImageContext = createContext({ + isOpen: false, + onClose: () => undefined, + onImmediatelyDelete: () => undefined, + onDelete: () => undefined, +}); + +const selector = createSelector( + [systemSelector], + (system) => { + const { isProcessing, isConnected, shouldConfirmOnDelete } = system; + + return { + canDeleteImage: isConnected && !isProcessing, + shouldConfirmOnDelete, + }; + }, + defaultSelectorOptions +); + +type Props = PropsWithChildren; + +export const DeleteImageContextProvider = (props: Props) => { + const { canDeleteImage, shouldConfirmOnDelete } = useAppSelector(selector); + const [imageToDelete, setImageToDelete] = useState(); + const dispatch = useAppDispatch(); + const { isOpen, onOpen, onClose } = useDisclosure(); + + // Check where the image to be deleted is used (eg init image, controlnet, etc.) + const imageUsage = useAppSelector((state) => + selectImageUsage(state, imageToDelete?.image_name) + ); + + // Clean up after deleting or dismissing the modal + const closeAndClearImageToDelete = useCallback(() => { + setImageToDelete(undefined); + onClose(); + }, [onClose]); + + // Dispatch the actual deletion action, to be handled by listener middleware + const handleActualDeletion = useCallback( + (image: ImageDTO) => { + dispatch(requestedImageDeletion({ image, imageUsage })); + closeAndClearImageToDelete(); + }, + [closeAndClearImageToDelete, dispatch, imageUsage] + ); + + // This is intended to be called by the delete button in the dialog + const onImmediatelyDelete = useCallback(() => { + if (canDeleteImage && imageToDelete) { + handleActualDeletion(imageToDelete); + } + closeAndClearImageToDelete(); + }, [ + canDeleteImage, + imageToDelete, + closeAndClearImageToDelete, + handleActualDeletion, + ]); + + const handleGatedDeletion = useCallback( + (image: ImageDTO) => { + if (shouldConfirmOnDelete || some(imageUsage)) { + // If we should confirm on delete, or if the image is in use, open the dialog + onOpen(); + } else { + handleActualDeletion(image); + } + }, + [imageUsage, shouldConfirmOnDelete, onOpen, handleActualDeletion] + ); + + // Consumers of the context call this to delete an image + const onDelete = useCallback((image?: ImageDTO) => { + if (!image) { + return; + } + // Set the image to delete, then let the effect call the actual deletion + setImageToDelete(image); + }, []); + + useEffect(() => { + // We need to use an effect here to trigger the image usage selector, else we get a stale value + if (imageToDelete) { + handleGatedDeletion(imageToDelete); + } + }, [handleGatedDeletion, imageToDelete]); + + return ( + + {props.children} + + ); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index a9349dc863..8c073e81d6 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -72,6 +72,7 @@ import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingA import { addImageCategoriesChangedListener } from './listeners/imageCategoriesChanged'; import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed'; import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess'; +import { addUpdateImageUrlsOnConnectListener } from './listeners/updateImageUrlsOnConnect'; export const listenerMiddleware = createListenerMiddleware(); @@ -179,3 +180,6 @@ addImageCategoriesChangedListener(); // ControlNet addControlNetImageProcessedListener(); addControlNetAutoProcessListener(); + +// Update image URLs on connect +addUpdateImageUrlsOnConnectListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasCopiedToClipboard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasCopiedToClipboard.ts index 16642f1f32..a7ddd8e917 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasCopiedToClipboard.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasCopiedToClipboard.ts @@ -28,6 +28,13 @@ export const addCanvasCopiedToClipboardListener = () => { } copyBlobToClipboard(blob); + + dispatch( + addToast({ + title: 'Canvas Copied to Clipboard', + status: 'success', + }) + ); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasDownloadedAsImage.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasDownloadedAsImage.ts index ef4c63b31c..c97df09cff 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasDownloadedAsImage.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasDownloadedAsImage.ts @@ -27,7 +27,8 @@ export const addCanvasDownloadedAsImageListener = () => { return; } - downloadBlob(blob, 'mergedCanvas.png'); + downloadBlob(blob, 'canvas.png'); + dispatch(addToast({ title: 'Canvas Downloaded', status: 'success' })); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts index 80865f3126..ed157066bb 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts @@ -1,22 +1,20 @@ import { canvasMerged } from 'features/canvas/store/actions'; import { startAppListening } from '..'; import { log } from 'app/logging/useLogger'; -import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { addToast } from 'features/system/store/systemSlice'; import { imageUploaded } from 'services/thunks/image'; -import { v4 as uuidv4 } from 'uuid'; import { setMergedCanvas } from 'features/canvas/store/canvasSlice'; import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider'; +import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob'; const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); +export const MERGED_CANVAS_FILENAME = 'mergedCanvas.png'; export const addCanvasMergedListener = () => { startAppListening({ actionCreator: canvasMerged, effect: async (action, { dispatch, getState, take }) => { - const state = getState(); - - const blob = await getBaseLayerBlob(state, true); + const blob = await getFullBaseLayerBlob(); if (!blob) { moduleLog.error('Problem getting base layer blob'); @@ -48,12 +46,12 @@ export const addCanvasMergedListener = () => { relativeTo: canvasBaseLayer.getParent(), }); - const filename = `mergedCanvas_${uuidv4()}.png`; - - dispatch( + const imageUploadedRequest = dispatch( imageUploaded({ formData: { - file: new File([blob], filename, { type: 'image/png' }), + file: new File([blob], MERGED_CANVAS_FILENAME, { + type: 'image/png', + }), }, imageCategory: 'general', isIntermediate: true, @@ -61,9 +59,11 @@ export const addCanvasMergedListener = () => { ); const [{ payload }] = await take( - (action): action is ReturnType => - imageUploaded.fulfilled.match(action) && - action.meta.arg.formData.file.name === filename + ( + uploadedImageAction + ): uploadedImageAction is ReturnType => + imageUploaded.fulfilled.match(uploadedImageAction) && + uploadedImageAction.meta.requestId === imageUploadedRequest.requestId ); const mergedCanvasImage = payload; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts index b89620775b..2ea69df179 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts @@ -4,9 +4,10 @@ import { log } from 'app/logging/useLogger'; import { imageUploaded } from 'services/thunks/image'; import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { addToast } from 'features/system/store/systemSlice'; -import { v4 as uuidv4 } from 'uuid'; import { imageUpserted } from 'features/gallery/store/imagesSlice'; +export const SAVED_CANVAS_FILENAME = 'savedCanvas.png'; + const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); export const addCanvasSavedToGalleryListener = () => { @@ -15,7 +16,7 @@ export const addCanvasSavedToGalleryListener = () => { effect: async (action, { dispatch, getState, take }) => { const state = getState(); - const blob = await getBaseLayerBlob(state, true); + const blob = await getBaseLayerBlob(state); if (!blob) { moduleLog.error('Problem getting base layer blob'); @@ -29,12 +30,12 @@ export const addCanvasSavedToGalleryListener = () => { return; } - const filename = `mergedCanvas_${uuidv4()}.png`; - - dispatch( + const imageUploadedRequest = dispatch( imageUploaded({ formData: { - file: new File([blob], filename, { type: 'image/png' }), + file: new File([blob], SAVED_CANVAS_FILENAME, { + type: 'image/png', + }), }, imageCategory: 'general', isIntermediate: false, @@ -42,9 +43,11 @@ export const addCanvasSavedToGalleryListener = () => { ); const [{ payload: uploadedImageDTO }] = await take( - (action): action is ReturnType => - imageUploaded.fulfilled.match(action) && - action.meta.arg.formData.file.name === filename + ( + uploadedImageAction + ): uploadedImageAction is ReturnType => + imageUploaded.fulfilled.match(uploadedImageAction) && + uploadedImageAction.meta.requestId === imageUploadedRequest.requestId ); dispatch(imageUpserted(uploadedImageDTO)); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts index bf7ca4020c..f4376a4959 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts @@ -6,10 +6,13 @@ import { clamp } from 'lodash-es'; import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageRemoved, - imagesAdapter, selectImagesEntities, selectImagesIds, } from 'features/gallery/store/imagesSlice'; +import { resetCanvas } from 'features/canvas/store/canvasSlice'; +import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; +import { clearInitialImage } from 'features/parameters/store/generationSlice'; +import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; const moduleLog = log.child({ namespace: 'addRequestedImageDeletionListener' }); @@ -20,11 +23,7 @@ export const addRequestedImageDeletionListener = () => { startAppListening({ actionCreator: requestedImageDeletion, effect: (action, { dispatch, getState }) => { - const image = action.payload; - if (!image) { - moduleLog.warn('No image provided'); - return; - } + const { image, imageUsage } = action.payload; const { image_name, image_origin } = image; @@ -58,8 +57,28 @@ export const addRequestedImageDeletionListener = () => { } } + // We need to reset the features where the image is in use - none of these work if their image(s) don't exist + + if (imageUsage.isCanvasImage) { + dispatch(resetCanvas()); + } + + if (imageUsage.isControlNetImage) { + dispatch(controlNetReset()); + } + + if (imageUsage.isInitialImage) { + dispatch(clearInitialImage()); + } + + if (imageUsage.isNodesImage) { + dispatch(nodeEditorReset()); + } + + // Preemptively remove from gallery dispatch(imageRemoved(image_name)); + // Delete from server dispatch( imageDeleted({ imageName: image_name, imageOrigin: image_origin }) ); @@ -74,9 +93,7 @@ export const addImageDeletedPendingListener = () => { startAppListening({ actionCreator: imageDeleted.pending, effect: (action, { dispatch, getState }) => { - const { imageName, imageOrigin } = action.meta.arg; - // Preemptively remove the image from the gallery - imagesAdapter.removeOne(getState().images, imageName); + // }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts index 63aeecb95e..016e3ec8a8 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageMetadataReceived.ts @@ -1,6 +1,6 @@ import { log } from 'app/logging/useLogger'; import { startAppListening } from '..'; -import { imageMetadataReceived } from 'services/thunks/image'; +import { imageMetadataReceived, imageUpdated } from 'services/thunks/image'; import { imageUpserted } from 'features/gallery/store/imagesSlice'; const moduleLog = log.child({ namespace: 'image' }); @@ -10,10 +10,29 @@ export const addImageMetadataReceivedFulfilledListener = () => { actionCreator: imageMetadataReceived.fulfilled, effect: (action, { getState, dispatch }) => { const image = action.payload; - if (image.is_intermediate) { + + const state = getState(); + + if ( + image.session_id === state.canvas.layerState.stagingArea.sessionId && + state.canvas.shouldAutoSave + ) { + dispatch( + imageUpdated({ + imageName: image.image_name, + imageOrigin: image.image_origin, + requestBody: { is_intermediate: false }, + }) + ); + } else if (image.is_intermediate) { // No further actions needed for intermediate images + moduleLog.trace( + { data: { image } }, + 'Image metadata received (intermediate), skipping' + ); return; } + moduleLog.debug({ data: { image } }, 'Image metadata received'); dispatch(imageUpserted(image)); }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts index 6d84431f80..bfc362e48d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -3,6 +3,8 @@ import { imageUploaded } from 'services/thunks/image'; import { addToast } from 'features/system/store/systemSlice'; import { log } from 'app/logging/useLogger'; import { imageUpserted } from 'features/gallery/store/imagesSlice'; +import { SAVED_CANVAS_FILENAME } from './canvasSavedToGallery'; +import { MERGED_CANVAS_FILENAME } from './canvasMerged'; const moduleLog = log.child({ namespace: 'image' }); @@ -19,9 +21,22 @@ export const addImageUploadedFulfilledListener = () => { return; } - const state = getState(); + const originalFileName = action.meta.arg.formData.file.name; dispatch(imageUpserted(image)); + + if (originalFileName === SAVED_CANVAS_FILENAME) { + dispatch( + addToast({ title: 'Canvas Saved to Gallery', status: 'success' }) + ); + return; + } + + if (originalFileName === MERGED_CANVAS_FILENAME) { + dispatch(addToast({ title: 'Canvas Merged', status: 'success' })); + return; + } + dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts index fd0461f893..2e365a20ac 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUrlsReceived.ts @@ -1,7 +1,7 @@ import { log } from 'app/logging/useLogger'; import { startAppListening } from '..'; import { imageUrlsReceived } from 'services/thunks/image'; -import { imagesAdapter } from 'features/gallery/store/imagesSlice'; +import { imageUpdatedOne } from 'features/gallery/store/imagesSlice'; const moduleLog = log.child({ namespace: 'image' }); @@ -14,13 +14,12 @@ export const addImageUrlsReceivedFulfilledListener = () => { const { image_name, image_url, thumbnail_url } = image; - imagesAdapter.updateOne(getState().images, { - id: image_name, - changes: { - image_url, - thumbnail_url, - }, - }); + dispatch( + imageUpdatedOne({ + id: image_name, + changes: { image_url, thumbnail_url }, + }) + ); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts new file mode 100644 index 0000000000..d02ffbe931 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts @@ -0,0 +1,93 @@ +import { socketConnected } from 'services/events/actions'; +import { startAppListening } from '..'; +import { createSelector } from '@reduxjs/toolkit'; +import { generationSelector } from 'features/parameters/store/generationSelectors'; +import { canvasSelector } from 'features/canvas/store/canvasSelectors'; +import { nodesSelecter } from 'features/nodes/store/nodesSlice'; +import { controlNetSelector } from 'features/controlNet/store/controlNetSlice'; +import { ImageDTO } from 'services/api'; +import { forEach, uniqBy } from 'lodash-es'; +import { imageUrlsReceived } from 'services/thunks/image'; +import { log } from 'app/logging/useLogger'; +import { selectImagesEntities } from 'features/gallery/store/imagesSlice'; + +const moduleLog = log.child({ namespace: 'images' }); + +const selectAllUsedImages = createSelector( + [ + generationSelector, + canvasSelector, + nodesSelecter, + controlNetSelector, + selectImagesEntities, + ], + (generation, canvas, nodes, controlNet, imageEntities) => { + const allUsedImages: ImageDTO[] = []; + + if (generation.initialImage) { + allUsedImages.push(generation.initialImage); + } + + canvas.layerState.objects.forEach((obj) => { + if (obj.kind === 'image') { + allUsedImages.push(obj.image); + } + }); + + nodes.nodes.forEach((node) => { + forEach(node.data.inputs, (input) => { + if (input.type === 'image' && input.value) { + allUsedImages.push(input.value); + } + }); + }); + + forEach(controlNet.controlNets, (c) => { + if (c.controlImage) { + allUsedImages.push(c.controlImage); + } + if (c.processedControlImage) { + allUsedImages.push(c.processedControlImage); + } + }); + + forEach(imageEntities, (image) => { + if (image) { + allUsedImages.push(image); + } + }); + + const uniqueImages = uniqBy(allUsedImages, 'image_name'); + + return uniqueImages; + } +); + +export const addUpdateImageUrlsOnConnectListener = () => { + startAppListening({ + actionCreator: socketConnected, + effect: async (action, { dispatch, getState, take }) => { + const state = getState(); + + if (!state.config.shouldUpdateImagesOnConnect) { + return; + } + + const allUsedImages = selectAllUsedImages(state); + + moduleLog.trace( + { data: allUsedImages }, + `Fetching new image URLs for ${allUsedImages.length} images` + ); + + allUsedImages.forEach(({ image_name, image_origin }) => { + dispatch( + imageUrlsReceived({ + imageName: image_name, + imageOrigin: image_origin, + }) + ); + }); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts index 304b094749..4931c498bf 100644 --- a/invokeai/frontend/web/src/app/types/invokeai.ts +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -108,12 +108,9 @@ export type SDFeature = */ export type AppConfig = { /** - * Whether or not URLs should be transformed to use a different host - */ - shouldTransformUrls: boolean; - /** - * Whether or not we need to re-fetch images + * Whether or not we should update image urls when image loading errors */ + shouldUpdateImagesOnConnect: boolean; disabledTabs: InvokeTabName[]; disabledFeatures: AppFeature[]; disabledSDFeatures: SDFeature[]; diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index 5a7f93747b..f31aebf596 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -4,7 +4,6 @@ import { useCombinedRefs } from '@dnd-kit/utilities'; import IAIIconButton from 'common/components/IAIIconButton'; import { IAIImageFallback } from 'common/components/IAIImageFallback'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; -import { useGetUrl } from 'common/util/getUrl'; import { AnimatePresence } from 'framer-motion'; import { ReactElement, SyntheticEvent } from 'react'; import { memo, useRef } from 'react'; @@ -45,7 +44,6 @@ const IAIDndImage = (props: IAIDndImageProps) => { minSize = 24, } = props; const dndId = useRef(uuidv4()); - const { getUrl } = useGetUrl(); const { isOver, setNodeRef: setDroppableRef, @@ -100,7 +98,7 @@ const IAIDndImage = (props: IAIDndImageProps) => { }} > { - if (OpenAPI.BASE && shouldTransformUrls) { - return [OpenAPI.BASE, url].join('/'); - } - - return url; -}; - -export const useGetUrl = () => { - const shouldTransformUrls = useAppSelector( - (state: RootState) => state.config.shouldTransformUrls - ); - - const getUrl = useCallback( - (url?: string) => { - if (OpenAPI.BASE && shouldTransformUrls) { - return [OpenAPI.BASE, url].join('/'); - } - - return url; - }, - [shouldTransformUrls] - ); - - return { - shouldTransformUrls, - getUrl, - }; -}; diff --git a/invokeai/frontend/web/src/features/canvas/components/IAICanvasObjectRenderer.tsx b/invokeai/frontend/web/src/features/canvas/components/IAICanvasObjectRenderer.tsx index c99465cf40..ea04aa95c8 100644 --- a/invokeai/frontend/web/src/features/canvas/components/IAICanvasObjectRenderer.tsx +++ b/invokeai/frontend/web/src/features/canvas/components/IAICanvasObjectRenderer.tsx @@ -1,6 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; -import { useGetUrl } from 'common/util/getUrl'; import { canvasSelector } from 'features/canvas/store/canvasSelectors'; import { rgbaColorToString } from 'features/canvas/util/colorToString'; import { isEqual } from 'lodash-es'; @@ -33,7 +32,6 @@ const selector = createSelector( const IAICanvasObjectRenderer = () => { const { objects } = useAppSelector(selector); - const { getUrl } = useGetUrl(); if (!objects) return null; @@ -46,7 +44,7 @@ const IAICanvasObjectRenderer = () => { key={i} x={obj.x} y={obj.y} - url={getUrl(obj.image.image_url)} + url={obj.image.image_url} /> ); } else if (isCanvasBaseLine(obj)) { diff --git a/invokeai/frontend/web/src/features/canvas/components/IAICanvasStagingArea.tsx b/invokeai/frontend/web/src/features/canvas/components/IAICanvasStagingArea.tsx index f03aeedb86..c33e0cacf5 100644 --- a/invokeai/frontend/web/src/features/canvas/components/IAICanvasStagingArea.tsx +++ b/invokeai/frontend/web/src/features/canvas/components/IAICanvasStagingArea.tsx @@ -1,6 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; -import { useGetUrl } from 'common/util/getUrl'; import { canvasSelector } from 'features/canvas/store/canvasSelectors'; import { GroupConfig } from 'konva/lib/Group'; import { isEqual } from 'lodash-es'; @@ -56,13 +55,12 @@ const IAICanvasStagingArea = (props: Props) => { width, height, } = useAppSelector(selector); - const { getUrl } = useGetUrl(); return ( {shouldShowStagingImage && currentStagingAreaImage && ( diff --git a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx index 69eed2b46a..30ff6fff81 100644 --- a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx @@ -1,4 +1,4 @@ -import { ButtonGroup, Flex } from '@chakra-ui/react'; +import { Box, ButtonGroup, Flex } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; @@ -210,16 +210,19 @@ const IAICanvasToolbar = () => { sx={{ alignItems: 'center', gap: 2, + flexWrap: 'wrap', }} > - + + + diff --git a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts index c0b73ed3ae..4742de0483 100644 --- a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts @@ -31,6 +31,7 @@ import { import { ImageDTO } from 'services/api'; import { sessionCanceled } from 'services/thunks/session'; import { setShouldUseCanvasBetaLayout } from 'features/ui/store/uiSlice'; +import { imageUrlsReceived } from 'services/thunks/image'; export const initialLayerState: CanvasLayerState = { objects: [], @@ -856,6 +857,26 @@ export const canvasSlice = createSlice({ builder.addCase(setShouldUseCanvasBetaLayout, (state, action) => { state.doesCanvasNeedScaling = true; }); + builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + const { image_name, image_origin, image_url, thumbnail_url } = + action.payload; + + state.layerState.objects.forEach((object) => { + if (object.kind === 'image') { + if (object.image.image_name === image_name) { + object.image.image_url = image_url; + object.image.thumbnail_url = thumbnail_url; + } + } + }); + + state.layerState.stagingArea.images.forEach((stagedImage) => { + if (stagedImage.image.image_name === image_name) { + stagedImage.image.image_url = image_url; + stagedImage.image.thumbnail_url = thumbnail_url; + } + }); + }); }, }); diff --git a/invokeai/frontend/web/src/features/canvas/util/getBaseLayerBlob.ts b/invokeai/frontend/web/src/features/canvas/util/getBaseLayerBlob.ts index a576551d72..20ac482710 100644 --- a/invokeai/frontend/web/src/features/canvas/util/getBaseLayerBlob.ts +++ b/invokeai/frontend/web/src/features/canvas/util/getBaseLayerBlob.ts @@ -2,10 +2,10 @@ import { getCanvasBaseLayer } from './konvaInstanceProvider'; import { RootState } from 'app/store/store'; import { konvaNodeToBlob } from './konvaNodeToBlob'; -export const getBaseLayerBlob = async ( - state: RootState, - withoutBoundingBox?: boolean -) => { +/** + * Get the canvas base layer blob, with or without bounding box according to `shouldCropToBoundingBoxOnSave` + */ +export const getBaseLayerBlob = async (state: RootState) => { const canvasBaseLayer = getCanvasBaseLayer(); if (!canvasBaseLayer) { @@ -24,15 +24,14 @@ export const getBaseLayerBlob = async ( const absPos = clonedBaseLayer.getAbsolutePosition(); - const boundingBox = - shouldCropToBoundingBoxOnSave && !withoutBoundingBox - ? { - x: boundingBoxCoordinates.x + absPos.x, - y: boundingBoxCoordinates.y + absPos.y, - width: boundingBoxDimensions.width, - height: boundingBoxDimensions.height, - } - : clonedBaseLayer.getClientRect(); + const boundingBox = shouldCropToBoundingBoxOnSave + ? { + x: boundingBoxCoordinates.x + absPos.x, + y: boundingBoxCoordinates.y + absPos.y, + width: boundingBoxDimensions.width, + height: boundingBoxDimensions.height, + } + : clonedBaseLayer.getClientRect(); return konvaNodeToBlob(clonedBaseLayer, boundingBox); }; diff --git a/invokeai/frontend/web/src/features/canvas/util/getFullBaseLayerBlob.ts b/invokeai/frontend/web/src/features/canvas/util/getFullBaseLayerBlob.ts new file mode 100644 index 0000000000..ba855723fb --- /dev/null +++ b/invokeai/frontend/web/src/features/canvas/util/getFullBaseLayerBlob.ts @@ -0,0 +1,19 @@ +import { getCanvasBaseLayer } from './konvaInstanceProvider'; +import { konvaNodeToBlob } from './konvaNodeToBlob'; + +/** + * Gets the canvas base layer blob, without bounding box + */ +export const getFullBaseLayerBlob = async () => { + const canvasBaseLayer = getCanvasBaseLayer(); + + if (!canvasBaseLayer) { + return; + } + + const clonedBaseLayer = canvasBaseLayer.clone(); + + clonedBaseLayer.scale({ x: 1, y: 1 }); + + return konvaNodeToBlob(clonedBaseLayer, clonedBaseLayer.getClientRect()); +}; diff --git a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts index 1389457aba..92d6c302e9 100644 --- a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts +++ b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts @@ -13,6 +13,8 @@ import { ControlNetModel, } from './constants'; import { controlNetImageProcessed } from './actions'; +import { imageDeleted, imageUrlsReceived } from 'services/thunks/image'; +import { forEach } from 'lodash-es'; export const initialControlNet: Omit = { isEnabled: true, @@ -185,6 +187,9 @@ export const controlNetSlice = createSlice({ processorType ].default as RequiredControlNetProcessorNode; }, + controlNetReset: () => { + return { ...initialControlNetState }; + }, }, extraReducers: (builder) => { builder.addCase(controlNetImageProcessed, (state, action) => { @@ -194,6 +199,36 @@ export const controlNetSlice = createSlice({ state.isProcessingControlImage = true; } }); + + builder.addCase(imageDeleted.pending, (state, action) => { + // Preemptively remove the image from the gallery + const { imageName } = action.meta.arg; + forEach(state.controlNets, (c) => { + if (c.controlImage?.image_name === imageName) { + c.controlImage = null; + c.processedControlImage = null; + } + if (c.processedControlImage?.image_name === imageName) { + c.processedControlImage = null; + } + }); + }); + + builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + const { image_name, image_origin, image_url, thumbnail_url } = + action.payload; + + forEach(state.controlNets, (c) => { + if (c.controlImage?.image_name === image_name) { + c.controlImage.image_url = image_url; + c.controlImage.thumbnail_url = thumbnail_url; + } + if (c.processedControlImage?.image_name === image_name) { + c.processedControlImage.image_url = image_url; + c.processedControlImage.thumbnail_url = thumbnail_url; + } + }); + }); }, }); @@ -211,6 +246,7 @@ export const { controlNetEndStepPctChanged, controlNetProcessorParamsChanged, controlNetProcessorTypeChanged, + controlNetReset, } = controlNetSlice.actions; export default controlNetSlice.reducer; diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx index 91bd1a0425..a5eaeb4c71 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx @@ -1,13 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { isEqual } from 'lodash-es'; -import { - ButtonGroup, - Flex, - FlexProps, - Link, - useDisclosure, -} from '@chakra-ui/react'; +import { ButtonGroup, Flex, FlexProps, Link } from '@chakra-ui/react'; // import { runESRGAN, runFacetool } from 'app/socketio/actions'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIButton from 'common/components/IAIButton'; @@ -45,22 +39,18 @@ import { FaShareAlt, } from 'react-icons/fa'; import { gallerySelector } from '../store/gallerySelectors'; -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; -import { useGetUrl } from 'common/util/getUrl'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; -import { - requestedImageDeletion, - sentImageToCanvas, - sentImageToImg2Img, -} from '../store/actions'; +import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceRestore/FaceRestoreSettings'; import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings'; -import DeleteImageButton from './ImageActionButtons/DeleteImageButton'; import { useAppToaster } from 'app/components/Toaster'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; +import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; +import { DeleteImageButton } from './DeleteImageModal'; const currentImageButtonsSelector = createSelector( [ @@ -123,10 +113,6 @@ const currentImageButtonsSelector = createSelector( type CurrentImageButtonsProps = FlexProps; -/** - * Row of buttons for common actions: - * Use as init image, use all params, use seed, upscale, fix faces, details, delete. - */ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const dispatch = useAppDispatch(); const { @@ -138,13 +124,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { facetoolStrength, shouldDisableToolbarButtons, shouldShowImageDetails, - // currentImage, isLightboxOpen, activeTabName, shouldHidePreview, image, - canDeleteImage, - shouldConfirmOnDelete, shouldShowProgressInViewer, } = useAppSelector(currentImageButtonsSelector); @@ -153,20 +136,14 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled; const isFaceRestoreEnabled = useFeatureStatus('faceRestore').isFeatureEnabled; - const { getUrl, shouldTransformUrls } = useGetUrl(); - - const { - isOpen: isDeleteDialogOpen, - onOpen: onDeleteDialogOpen, - onClose: onDeleteDialogClose, - } = useDisclosure(); - const toaster = useAppToaster(); const { t } = useTranslation(); const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); + const { onDelete } = useContext(DeleteImageContext); + // const handleCopyImage = useCallback(async () => { // if (!image?.url) { // return; @@ -197,10 +174,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { return; } - if (shouldTransformUrls) { - return getUrl(image.image_url); - } - if (image.image_url.startsWith('http')) { return image.image_url; } @@ -229,7 +202,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { isClosable: true, }); }); - }, [toaster, shouldTransformUrls, getUrl, t, image]); + }, [toaster, t, image]); const handleClickUseAllParameters = useCallback(() => { recallAllParameters(image); @@ -269,6 +242,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { // selectedImage && dispatch(runESRGAN(selectedImage)); }, []); + const handleDelete = useCallback(() => { + onDelete(image); + }, [image, onDelete]); + useHotkeys( 'Shift+U', () => { @@ -370,31 +347,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { [image, shouldShowImageDetails, toaster] ); - const handleDelete = useCallback(() => { - if (canDeleteImage && image) { - dispatch(requestedImageDeletion(image)); - } - }, [image, canDeleteImage, dispatch]); - - const handleInitiateDelete = useCallback(() => { - if (shouldConfirmOnDelete) { - onDeleteDialogOpen(); - } else { - handleDelete(); - } - }, [shouldConfirmOnDelete, onDeleteDialogOpen, handleDelete]); - const handleClickProgressImagesToggle = useCallback(() => { dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer)); }, [dispatch, shouldShowProgressInViewer]); - useHotkeys('delete', handleInitiateDelete, [ - image, - shouldConfirmOnDelete, - isConnected, - isProcessing, - ]); - const handleLightBox = useCallback(() => { dispatch(setIsLightboxOpen(!isLightboxOpen)); }, [dispatch, isLightboxOpen]); @@ -461,11 +417,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { {t('parameters.copyImageToLink')} - + } size="sm" w="100%"> {t('parameters.downloadImage')} @@ -607,7 +559,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { - + diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx index 12d62ead70..5e210bf4b7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx @@ -117,7 +117,7 @@ const CurrentImagePreview = () => { /> )} - {shouldShowImageDetails && image && image.metadata && ( + {shouldShowImageDetails && image && ( { const { shouldConfirmOnDelete } = system; const { canRestoreDeletedImagesFromBin } = config; - return { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin }; + + return { + shouldConfirmOnDelete, + canRestoreDeletedImagesFromBin, + }; }, - { - memoizeOptions: { - resultEqualityCheck: isEqual, - }, - } + defaultSelectorOptions ); -interface DeleteImageModalProps { - isOpen: boolean; - onClose: () => void; - handleDelete: () => void; -} +const ImageInUseMessage = (props: { imageUsage?: ImageUsage }) => { + const { imageUsage } = props; -const DeleteImageModal = ({ - isOpen, - onClose, - handleDelete, -}: DeleteImageModalProps) => { + if (!imageUsage) { + return null; + } + + if (!some(imageUsage)) { + return null; + } + + return ( + <> + This image is currently in use in the following features: + + {imageUsage.isInitialImage && Image to Image} + {imageUsage.isCanvasImage && Unified Canvas} + {imageUsage.isControlNetImage && ControlNet} + {imageUsage.isNodesImage && Node Editor} + + + If you delete this image, those features will immediately be reset. + + + ); +}; + +const DeleteImageModal = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); + + const { isOpen, onClose, onImmediatelyDelete, image, imageUsage } = + useContext(DeleteImageContext); + const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin } = useAppSelector(selector); - const cancelRef = useRef(null); const handleChangeShouldConfirmOnDelete = useCallback( (e: ChangeEvent) => @@ -57,10 +87,7 @@ const DeleteImageModal = ({ [dispatch] ); - const handleClickDelete = useCallback(() => { - handleDelete(); - onClose(); - }, [handleDelete, onClose]); + const cancelRef = useRef(null); return ( - - - {t('common.areYouSure')} - - {canRestoreDeletedImagesFromBin - ? t('gallery.deleteImageBin') - : t('gallery.deleteImagePermanent')} - - + + + + + {canRestoreDeletedImagesFromBin + ? t('gallery.deleteImageBin') + : t('gallery.deleteImagePermanent')} + + {t('common.areYouSure')} Cancel - + Delete @@ -107,3 +134,33 @@ const DeleteImageModal = ({ }; export default memo(DeleteImageModal); + +const deleteImageButtonsSelector = createSelector( + [systemSelector], + (system) => { + const { isProcessing, isConnected } = system; + + return isConnected && !isProcessing; + } +); + +type DeleteImageButtonProps = { + onClick: () => void; +}; + +export const DeleteImageButton = (props: DeleteImageButtonProps) => { + const { onClick } = props; + const { t } = useTranslation(); + const canDeleteImage = useAppSelector(deleteImageButtonsSelector); + + return ( + } + tooltip={`${t('gallery.deleteImage')} (Del)`} + aria-label={`${t('gallery.deleteImage')} (Del)`} + isDisabled={!canDeleteImage} + colorScheme="error" + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx index 4dad27d4e8..2b8f72101d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx @@ -1,17 +1,8 @@ -import { - Box, - Flex, - Icon, - Image, - MenuItem, - MenuList, - useDisclosure, -} from '@chakra-ui/react'; +import { Box, Flex, Icon, Image, MenuItem, MenuList } from '@chakra-ui/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { DragEvent, MouseEvent, memo, useCallback, useState } from 'react'; +import { memo, useCallback, useContext, useState } from 'react'; import { FaCheck, FaExpand, FaImage, FaShare, FaTrash } from 'react-icons/fa'; -import DeleteImageModal from './DeleteImageModal'; import { ContextMenu } from 'chakra-ui-contextmenu'; import { resizeAndScaleCanvas, @@ -21,7 +12,6 @@ import { gallerySelector } from 'features/gallery/store/gallerySelectors'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { useTranslation } from 'react-i18next'; import IAIIconButton from 'common/components/IAIIconButton'; -import { useGetUrl } from 'common/util/getUrl'; import { ExternalLinkIcon } from '@chakra-ui/icons'; import { IoArrowUndoCircleOutline } from 'react-icons/io5'; import { createSelector } from '@reduxjs/toolkit'; @@ -32,14 +22,11 @@ import { isEqual } from 'lodash-es'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; -import { - requestedImageDeletion, - sentImageToCanvas, - sentImageToImg2Img, -} from '../store/actions'; +import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; import { useAppToaster } from 'app/components/Toaster'; import { ImageDTO } from 'services/api'; import { useDraggable } from '@dnd-kit/core'; +import { DeleteImageContext } from 'app/contexts/DeleteImageContext'; export const selector = createSelector( [gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector], @@ -93,28 +80,22 @@ const HoverableImage = memo((props: HoverableImageProps) => { galleryImageMinimumWidth, canDeleteImage, shouldUseSingleGalleryColumn, - shouldConfirmOnDelete, } = useAppSelector(selector); - const { - isOpen: isDeleteDialogOpen, - onOpen: onDeleteDialogOpen, - onClose: onDeleteDialogClose, - } = useDisclosure(); - const { image, isSelected } = props; const { image_url, thumbnail_url, image_name } = image; - const { getUrl } = useGetUrl(); const [isHovered, setIsHovered] = useState(false); - const toaster = useAppToaster(); const { t } = useTranslation(); - const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled; const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled; + const { onDelete } = useContext(DeleteImageContext); + const handleDelete = useCallback(() => { + onDelete(image); + }, [image, onDelete]); const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); @@ -128,26 +109,6 @@ const HoverableImage = memo((props: HoverableImageProps) => { const handleMouseOver = () => setIsHovered(true); const handleMouseOut = () => setIsHovered(false); - // Immediately deletes an image - const handleDelete = useCallback(() => { - if (canDeleteImage && image) { - dispatch(requestedImageDeletion(image)); - } - }, [dispatch, image, canDeleteImage]); - - // Opens the alert dialog to check if user is sure they want to delete - const handleInitiateDelete = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - if (shouldConfirmOnDelete) { - onDeleteDialogOpen(); - } else { - handleDelete(); - } - }, - [handleDelete, onDeleteDialogOpen, shouldConfirmOnDelete] - ); - const handleSelectImage = useCallback(() => { dispatch(imageSelected(image)); }, [image, dispatch]); @@ -208,7 +169,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { }; const handleOpenInNewTab = () => { - window.open(getUrl(image.image_url), '_blank'); + window.open(image.image_url, '_blank'); }; return ( @@ -283,7 +244,11 @@ const HoverableImage = memo((props: HoverableImageProps) => { {t('parameters.sendToUnifiedCanvas')} )} - } onClickCapture={onDeleteDialogOpen}> + } + onClickCapture={handleDelete} + > {t('gallery.deleteImage')} @@ -296,8 +261,6 @@ const HoverableImage = memo((props: HoverableImageProps) => { onMouseOver={handleMouseOver} onMouseOut={handleMouseOut} userSelect="none" - // draggable={true} - // onDragStart={handleDragStart} onClick={handleSelectImage} ref={ref} sx={{ @@ -317,7 +280,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit } rounded="md" - src={getUrl(thumbnail_url || image_url)} + src={thumbnail_url || image_url} fallback={} sx={{ width: '100%', @@ -361,7 +324,7 @@ const HoverableImage = memo((props: HoverableImageProps) => { }} > } size="xs" @@ -373,11 +336,6 @@ const HoverableImage = memo((props: HoverableImageProps) => { )} - ); }, memoEqualityCheck); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageActionButtons/DeleteImageButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageActionButtons/DeleteImageButton.tsx deleted file mode 100644 index 4b0f6e60dd..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageActionButtons/DeleteImageButton.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; - -import { useDisclosure } from '@chakra-ui/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { systemSelector } from 'features/system/store/systemSelectors'; - -import { useHotkeys } from 'react-hotkeys-hook'; -import { useTranslation } from 'react-i18next'; -import { FaTrash } from 'react-icons/fa'; -import { memo, useCallback } from 'react'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import DeleteImageModal from '../DeleteImageModal'; -import { requestedImageDeletion } from 'features/gallery/store/actions'; -import { ImageDTO } from 'services/api'; - -const selector = createSelector( - [systemSelector], - (system) => { - const { isProcessing, isConnected, shouldConfirmOnDelete } = system; - - return { - canDeleteImage: isConnected && !isProcessing, - shouldConfirmOnDelete, - isProcessing, - isConnected, - }; - }, - defaultSelectorOptions -); - -type DeleteImageButtonProps = { - image: ImageDTO | undefined; -}; - -const DeleteImageButton = (props: DeleteImageButtonProps) => { - const { image } = props; - const dispatch = useAppDispatch(); - const { isProcessing, isConnected, canDeleteImage, shouldConfirmOnDelete } = - useAppSelector(selector); - - const { - isOpen: isDeleteDialogOpen, - onOpen: onDeleteDialogOpen, - onClose: onDeleteDialogClose, - } = useDisclosure(); - - const { t } = useTranslation(); - - const handleDelete = useCallback(() => { - if (canDeleteImage && image) { - dispatch(requestedImageDeletion(image)); - } - }, [image, canDeleteImage, dispatch]); - - const handleInitiateDelete = useCallback(() => { - if (shouldConfirmOnDelete) { - onDeleteDialogOpen(); - } else { - handleDelete(); - } - }, [shouldConfirmOnDelete, onDeleteDialogOpen, handleDelete]); - - useHotkeys('delete', handleInitiateDelete, [ - image, - shouldConfirmOnDelete, - isConnected, - isProcessing, - ]); - - return ( - <> - } - tooltip={`${t('gallery.deleteImage')} (Del)`} - aria-label={`${t('gallery.deleteImage')} (Del)`} - isDisabled={!image || !isConnected} - colorScheme="error" - /> - {image && ( - - )} - - ); -}; - -export default memo(DeleteImageButton); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx index 1619680ec5..1a8801fa52 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer.tsx @@ -9,19 +9,6 @@ import { Tooltip, } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; -import { useGetUrl } from 'common/util/getUrl'; -import promptToString from 'common/util/promptToString'; -import { - setCfgScale, - setHeight, - setImg2imgStrength, - setNegativePrompt, - setPositivePrompt, - setScheduler, - setSeed, - setSteps, - setWidth, -} from 'features/parameters/store/generationSlice'; import { setShouldShowImageDetails } from 'features/ui/store/uiSlice'; import { memo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -30,7 +17,6 @@ import { FaCopy } from 'react-icons/fa'; import { IoArrowUndoCircleOutline } from 'react-icons/io5'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import { ImageDTO } from 'services/api'; -import { Scheduler } from 'app/constants'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; type MetadataItemProps = { @@ -146,7 +132,6 @@ const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => { const metadata = image?.metadata; const { t } = useTranslation(); - const { getUrl } = useGetUrl(); const metadataJSON = JSON.stringify(image, null, 2); @@ -168,11 +153,7 @@ const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => { > File: - + {image.image_name} diff --git a/invokeai/frontend/web/src/features/gallery/store/actions.ts b/invokeai/frontend/web/src/features/gallery/store/actions.ts index 7c00201da9..8b2beb9c13 100644 --- a/invokeai/frontend/web/src/features/gallery/store/actions.ts +++ b/invokeai/frontend/web/src/features/gallery/store/actions.ts @@ -1,10 +1,15 @@ import { createAction } from '@reduxjs/toolkit'; -import { ImageNameAndOrigin } from 'features/parameters/store/actions'; +import { ImageUsage } from 'app/contexts/DeleteImageContext'; import { ImageDTO } from 'services/api'; -export const requestedImageDeletion = createAction< - ImageDTO | ImageNameAndOrigin | undefined ->('gallery/requestedImageDeletion'); +export type RequestedImageDeletionArg = { + image: ImageDTO; + imageUsage: ImageUsage; +}; + +export const requestedImageDeletion = createAction( + 'gallery/requestedImageDeletion' +); export const sentImageToCanvas = createAction('gallery/sentImageToCanvas'); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 8e5ecf64fa..b9d091305a 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -2,6 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import { ImageDTO } from 'services/api'; import { imageUpserted } from './imagesSlice'; +import { imageUrlsReceived } from 'services/thunks/image'; type GalleryImageObjectFitType = 'contain' | 'cover'; @@ -57,6 +58,15 @@ export const gallerySlice = createSlice({ state.selectedImage = action.payload; } }); + builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + const { image_name, image_origin, image_url, thumbnail_url } = + action.payload; + + if (state.selectedImage?.image_name === image_name) { + state.selectedImage.image_url = image_url; + state.selectedImage.thumbnail_url = thumbnail_url; + } + }); }, }); diff --git a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts b/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts index cb6469aeb4..c9fc61d10d 100644 --- a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts @@ -1,5 +1,6 @@ import { PayloadAction, + Update, createEntityAdapter, createSelector, createSlice, @@ -7,12 +8,17 @@ import { import { RootState } from 'app/store/store'; import { ImageCategory, ImageDTO } from 'services/api'; import { dateComparator } from 'common/util/dateComparator'; -import { isString, keyBy } from 'lodash-es'; -import { receivedPageOfImages } from 'services/thunks/image'; +import { keyBy } from 'lodash-es'; +import { + imageDeleted, + imageMetadataReceived, + imageUrlsReceived, + receivedPageOfImages, +} from 'services/thunks/image'; export const imagesAdapter = createEntityAdapter({ selectId: (image) => image.image_name, - sortComparer: (a, b) => dateComparator(b.created_at, a.created_at), + sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at), }); export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; @@ -49,13 +55,11 @@ const imagesSlice = createSlice({ imageUpserted: (state, action: PayloadAction) => { imagesAdapter.upsertOne(state, action.payload); }, - imageRemoved: (state, action: PayloadAction) => { - if (isString(action.payload)) { - imagesAdapter.removeOne(state, action.payload); - return; - } - - imagesAdapter.removeOne(state, action.payload.image_name); + imageUpdatedOne: (state, action: PayloadAction>) => { + imagesAdapter.updateOne(state, action.payload); + }, + imageRemoved: (state, action: PayloadAction) => { + imagesAdapter.removeOne(state, action.payload); }, imageCategoriesChanged: (state, action: PayloadAction) => { state.categories = action.payload; @@ -76,6 +80,20 @@ const imagesSlice = createSlice({ state.total = total; imagesAdapter.upsertMany(state, items); }); + builder.addCase(imageDeleted.pending, (state, action) => { + // Image deleted + const { imageName } = action.meta.arg; + imagesAdapter.removeOne(state, imageName); + }); + builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + const { image_name, image_origin, image_url, thumbnail_url } = + action.payload; + + imagesAdapter.updateOne(state, { + id: image_name, + changes: { image_url, thumbnail_url }, + }); + }); }, }); @@ -87,8 +105,12 @@ export const { selectTotal: selectImagesTotal, } = imagesAdapter.getSelectors((state) => state.images); -export const { imageUpserted, imageRemoved, imageCategoriesChanged } = - imagesSlice.actions; +export const { + imageUpserted, + imageUpdatedOne, + imageRemoved, + imageCategoriesChanged, +} = imagesSlice.actions; export default imagesSlice.reducer; diff --git a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx b/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx index b1e822c309..7ec7d23371 100644 --- a/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx +++ b/invokeai/frontend/web/src/features/lightbox/components/ReactPanZoomImage.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { TransformComponent, useTransformContext } from 'react-zoom-pan-pinch'; -import { useGetUrl } from 'common/util/getUrl'; import { ImageDTO } from 'services/api'; type ReactPanZoomProps = { @@ -23,7 +22,6 @@ export default function ReactPanZoomImage({ scaleY, }: ReactPanZoomProps) { const { centerView } = useTransformContext(); - const { getUrl } = useGetUrl(); return ( { return ( - + {map(FIELDS, ({ title, description, color }, key) => ( { sx={{ position: 'relative', width: 'full', - height: { base: '100vh', xl: 'full' }, + height: 'full', borderRadius: 'md', bg: 'base.850', }} diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 3c93be7ac5..403a9292e1 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -16,9 +16,10 @@ import { receivedOpenAPISchema } from 'services/thunks/schema'; import { InvocationTemplate, InvocationValue } from '../types/types'; import { parseSchema } from '../util/parseSchema'; import { log } from 'app/logging/useLogger'; -import { size } from 'lodash-es'; -import { isAnyGraphBuilt } from './actions'; +import { forEach, size } from 'lodash-es'; import { RgbaColor } from 'react-colorful'; +import { imageUrlsReceived } from 'services/thunks/image'; +import { RootState } from 'app/store/store'; export type NodesState = { nodes: Node[]; @@ -92,15 +93,29 @@ const nodesSlice = createSlice({ console.error(err); } }, + nodeEditorReset: () => { + return { ...initialNodesState }; + }, }, extraReducers(builder) { builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => { state.schema = action.payload; }); - builder.addMatcher(isAnyGraphBuilt, (state, action) => { - // TODO: Achtung! Side effect in a reducer! - log.info({ namespace: 'nodes', data: action.payload }, 'Graph built'); + builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + const { image_name, image_origin, image_url, thumbnail_url } = + action.payload; + + state.nodes.forEach((node) => { + forEach(node.data.inputs, (input) => { + if (input.type === 'image') { + if (input.value?.image_name === image_name) { + input.value.image_url = image_url; + input.value.thumbnail_url = thumbnail_url; + } + } + }); + }); }); }, }); @@ -115,6 +130,9 @@ export const { connectionEnded, shouldShowGraphOverlayChanged, parsedOpenAPISchema, + nodeEditorReset, } = nodesSlice.actions; export default nodesSlice.reducer; + +export const nodesSelecter = (state: RootState) => state.nodes; diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamNegativeConditioning.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamNegativeConditioning.tsx index 28ab50ff82..70c342cc3b 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamNegativeConditioning.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamNegativeConditioning.tsx @@ -25,6 +25,7 @@ const ParamNegativeConditioning = () => { borderColor: 'error.600', }} fontSize="sm" + minH={16} /> ); diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamPositiveConditioning.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamPositiveConditioning.tsx index 0980b84ab3..82b43517f8 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamPositiveConditioning.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Core/ParamPositiveConditioning.tsx @@ -82,7 +82,7 @@ const ParamPositiveConditioning = () => { onKeyDown={handleKeyDown} resize="vertical" ref={promptRef} - minH={{ base: 20, lg: 40 }} + minH={32} /> diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx index 72dec6c149..c006215256 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx @@ -1,7 +1,6 @@ import { Flex } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useGetUrl } from 'common/util/getUrl'; import { clearInitialImage, initialImageChanged, @@ -30,7 +29,6 @@ const selector = createSelector( const InitialImagePreview = () => { const { initialImage } = useAppSelector(selector); const { shouldFetchImages } = useAppSelector(configSelector); - const { getUrl } = useGetUrl(); const dispatch = useAppDispatch(); const { t } = useTranslation(); const toaster = useAppToaster(); diff --git a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts index 6420950e4a..3512ded3ab 100644 --- a/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/generationSlice.ts @@ -17,6 +17,7 @@ import { StrengthParam, WidthParam, } from './parameterZodSchemas'; +import { imageUrlsReceived } from 'services/thunks/image'; export interface GenerationState { cfgScale: CfgScaleParam; @@ -231,6 +232,16 @@ export const generationSlice = createSlice({ state.model = defaultModel; } }); + + builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { + const { image_name, image_origin, image_url, thumbnail_url } = + action.payload; + + if (state.initialImage?.image_name === image_name) { + state.initialImage.image_url = image_url; + state.initialImage.thumbnail_url = thumbnail_url; + } + }); }, }); diff --git a/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx b/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx index f6017d02f0..bec2c32b61 100644 --- a/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx +++ b/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx @@ -13,22 +13,16 @@ const InvokeAILogoComponent = () => { invoke-ai-logo - - + /> + + invoke ai { - const [menuOpened, setMenuOpened] = useState(false); - const resolution = useResolution(); const { t } = useTranslation(); + const isModelManagerEnabled = + useFeatureStatus('modelManager').isFeatureEnabled; + const isLocalizationEnabled = + useFeatureStatus('localization').isFeatureEnabled; + const isBugLinkEnabled = useFeatureStatus('bugLink').isFeatureEnabled; + const isDiscordLinkEnabled = useFeatureStatus('discordLink').isFeatureEnabled; + const isGithubLinkEnabled = useFeatureStatus('githubLink').isFeatureEnabled; + return ( - - - - - - + + + - {resolution === 'desktop' ? ( - - ) : ( + {isModelManagerEnabled && ( + } - aria-label={t('accessibility.menu')} - background={menuOpened ? 'base.800' : 'none'} - _hover={{ background: menuOpened ? 'base.800' : 'none' }} - onClick={() => setMenuOpened(!menuOpened)} - p={0} - > - )} - - - {resolution !== 'desktop' && menuOpened && ( - - - + aria-label={t('modelManager.modelManager')} + tooltip={t('modelManager.modelManager')} + size="sm" + variant="link" + data-variant="link" + fontSize={20} + icon={} + /> + )} - + + + } + /> + + + + + {isLocalizationEnabled && } + + {isBugLinkEnabled && ( + + } + /> + + )} + + {isGithubLinkEnabled && ( + + } + /> + + )} + + {isDiscordLinkEnabled && ( + + } + /> + + )} + + + } + /> + + ); }; diff --git a/invokeai/frontend/web/src/features/system/store/configSlice.ts b/invokeai/frontend/web/src/features/system/store/configSlice.ts index f8cb3a483c..5f4dd68959 100644 --- a/invokeai/frontend/web/src/features/system/store/configSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/configSlice.ts @@ -4,7 +4,7 @@ import { AppConfig, PartialAppConfig } from 'app/types/invokeai'; import { merge } from 'lodash-es'; export const initialConfigState: AppConfig = { - shouldTransformUrls: false, + shouldUpdateImagesOnConnect: false, disabledTabs: [], disabledFeatures: [], disabledSDFeatures: [], diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index 23fc6bd192..c164b87515 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -152,16 +152,18 @@ const InvokeTabs = () => { onChange={(index: number) => { dispatch(setActiveTab(index)); }} - flexGrow={1} - flexDir={{ base: 'column', xl: 'row' }} - gap={{ base: 4 }} + sx={{ + flexGrow: 1, + gap: 4, + }} isLazy > {tabs} diff --git a/invokeai/frontend/web/src/features/ui/components/PinParametersPanelButton.tsx b/invokeai/frontend/web/src/features/ui/components/PinParametersPanelButton.tsx index 46d0fa3f93..a742e2a587 100644 --- a/invokeai/frontend/web/src/features/ui/components/PinParametersPanelButton.tsx +++ b/invokeai/frontend/web/src/features/ui/components/PinParametersPanelButton.tsx @@ -33,7 +33,6 @@ const PinParametersPanelButton = (props: PinParametersPanelButtonProps) => { icon={shouldPinParametersPanel ? : } variant="ghost" size="sm" - px={{ base: 10, xl: 0 }} sx={{ color: 'base.700', _hover: {