Merge branch 'main' into release/make-web-dist-startable

This commit is contained in:
Lincoln Stein 2023-06-06 23:24:16 -04:00
commit 49d29420c4
45 changed files with 888 additions and 495 deletions

View File

@ -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 && <Lightbox />}
<ImageUploader>
<Grid
gap={4}
p={4}
gridAutoRows="min-content auto"
w={APP_WIDTH}
h={APP_HEIGHT}
sx={{
gap: 4,
p: 4,
gridAutoRows: 'min-content auto',
w: 'full',
h: 'full',
}}
>
{headerComponent || <SiteHeader />}
<Flex
gap={4}
w={{ base: '100vw', xl: 'full' }}
h="full"
flexDir={{ base: 'column', xl: 'row' }}
sx={{
gap: 4,
w: 'full',
h: 'full',
}}
>
<InvokeTabs />
</Flex>
@ -130,6 +134,7 @@ const App = ({
<FloatingGalleryButton />
</Portal>
</Grid>
<DeleteImageModal />
<Toaster />
<GlobalHotkeys />
</>

View File

@ -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 = ({
<React.Suspense fallback={<Loading />}>
<ThemeLocaleProvider>
<ImageDndContext>
<App
config={config}
headerComponent={headerComponent}
setIsReady={setIsReady}
/>
<DeleteImageContextProvider>
<App
config={config}
headerComponent={headerComponent}
setIsReady={setIsReady}
/>
</DeleteImageContextProvider>
</ImageDndContext>
</ThemeLocaleProvider>
</React.Suspense>

View File

@ -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<DeleteImageContextValue>({
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<ImageDTO>();
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 (
<DeleteImageContext.Provider
value={{
isOpen,
image: imageToDelete,
onClose: closeAndClearImageToDelete,
onDelete,
onImmediatelyDelete,
imageUsage,
}}
>
{props.children}
</DeleteImageContext.Provider>
);
};

View File

@ -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();

View File

@ -28,6 +28,13 @@ export const addCanvasCopiedToClipboardListener = () => {
}
copyBlobToClipboard(blob);
dispatch(
addToast({
title: 'Canvas Copied to Clipboard',
status: 'success',
})
);
},
});
};

View File

@ -27,7 +27,8 @@ export const addCanvasDownloadedAsImageListener = () => {
return;
}
downloadBlob(blob, 'mergedCanvas.png');
downloadBlob(blob, 'canvas.png');
dispatch(addToast({ title: 'Canvas Downloaded', status: 'success' }));
},
});
};

View File

@ -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<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(action) &&
action.meta.arg.formData.file.name === filename
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
const mergedCanvasImage = payload;

View File

@ -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<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(action) &&
action.meta.arg.formData.file.name === filename
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
dispatch(imageUpserted(uploadedImageDTO));

View File

@ -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);
//
},
});
};

View File

@ -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));
},

View File

@ -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' }));
},
});

View File

@ -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 },
})
);
},
});
};

View File

@ -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,
})
);
});
},
});
};

View File

@ -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[];

View File

@ -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) => {
}}
>
<Image
src={getUrl(image.image_url)}
src={image.image_url}
fallbackStrategy="beforeLoadOrError"
fallback={fallback}
onError={onError}

View File

@ -1,34 +0,0 @@
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useCallback } from 'react';
import { OpenAPI } from 'services/api';
export const getUrlAlt = (url: string, shouldTransformUrls: boolean) => {
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,
};
};

View File

@ -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)) {

View File

@ -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 (
<Group {...rest}>
{shouldShowStagingImage && currentStagingAreaImage && (
<IAICanvasImage
url={getUrl(currentStagingAreaImage.image.image_url) ?? ''}
url={currentStagingAreaImage.image.image_url}
x={x}
y={y}
/>

View File

@ -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',
}}
>
<IAISelect
tooltip={`${t('unifiedCanvas.layer')} (Q)`}
tooltipProps={{ hasArrow: true, placement: 'top' }}
value={layer}
validValues={LAYER_NAMES_DICT}
onChange={handleChangeLayer}
isDisabled={isStaging}
/>
<Box w={24}>
<IAISelect
tooltip={`${t('unifiedCanvas.layer')} (Q)`}
tooltipProps={{ hasArrow: true, placement: 'top' }}
value={layer}
validValues={LAYER_NAMES_DICT}
onChange={handleChangeLayer}
isDisabled={isStaging}
/>
</Box>
<IAICanvasMaskOptions />
<IAICanvasToolChooserOptions />

View File

@ -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;
}
});
});
},
});

View File

@ -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);
};

View File

@ -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());
};

View File

@ -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<ControlNetConfig, 'controlNetId'> = {
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;

View File

@ -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')}
</IAIButton>
<Link
download={true}
href={getUrl(image?.image_url ?? '')}
target="_blank"
>
<Link download={true} href={image?.image_url} target="_blank">
<IAIButton leftIcon={<FaDownload />} size="sm" w="100%">
{t('parameters.downloadImage')}
</IAIButton>
@ -607,7 +559,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
</ButtonGroup>
<ButtonGroup isAttached={true}>
<DeleteImageButton image={image} />
<DeleteImageButton onClick={handleDelete} />
</ButtonGroup>
</Flex>
</>

View File

@ -117,7 +117,7 @@ const CurrentImagePreview = () => {
/>
</Flex>
)}
{shouldShowImageDetails && image && image.metadata && (
{shouldShowImageDetails && image && (
<Box
sx={{
position: 'absolute',

View File

@ -5,51 +5,81 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Divider,
Flex,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
DeleteImageContext,
ImageUsage,
} from 'app/contexts/DeleteImageContext';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import IAISwitch from 'common/components/IAISwitch';
import { configSelector } from 'features/system/store/configSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { isEqual } from 'lodash-es';
import { some } from 'lodash-es';
import { ChangeEvent, memo, useCallback, useRef } from 'react';
import { ChangeEvent, memo, useCallback, useContext, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
const selector = createSelector(
[systemSelector, configSelector],
(system, config) => {
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 (
<>
<Text>This image is currently in use in the following features:</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}>
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList>
<Text>
If you delete this image, those features will immediately be reset.
</Text>
</>
);
};
const DeleteImageModal = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { isOpen, onClose, onImmediatelyDelete, image, imageUsage } =
useContext(DeleteImageContext);
const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin } =
useAppSelector(selector);
const cancelRef = useRef<HTMLButtonElement>(null);
const handleChangeShouldConfirmOnDelete = useCallback(
(e: ChangeEvent<HTMLInputElement>) =>
@ -57,10 +87,7 @@ const DeleteImageModal = ({
[dispatch]
);
const handleClickDelete = useCallback(() => {
handleDelete();
onClose();
}, [handleDelete, onClose]);
const cancelRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog
@ -76,15 +103,15 @@ const DeleteImageModal = ({
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={5}>
<Flex direction="column" gap={2}>
<Text>{t('common.areYouSure')}</Text>
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
</Flex>
<Flex direction="column" gap={3}>
<ImageInUseMessage imageUsage={imageUsage} />
<Divider />
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
<Text>{t('common.areYouSure')}</Text>
<IAISwitch
label={t('common.dontAskMeAgain')}
isChecked={!shouldConfirmOnDelete}
@ -96,7 +123,7 @@ const DeleteImageModal = ({
<IAIButton ref={cancelRef} onClick={onClose}>
Cancel
</IAIButton>
<IAIButton colorScheme="error" onClick={handleClickDelete} ml={3}>
<IAIButton colorScheme="error" onClick={onImmediatelyDelete} ml={3}>
Delete
</IAIButton>
</AlertDialogFooter>
@ -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 (
<IAIIconButton
onClick={onClick}
icon={<FaTrash />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={!canDeleteImage}
colorScheme="error"
/>
);
};

View File

@ -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<boolean>(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')}
</MenuItem>
)}
<MenuItem icon={<FaTrash />} onClickCapture={onDeleteDialogOpen}>
<MenuItem
sx={{ color: 'error.300' }}
icon={<FaTrash />}
onClickCapture={handleDelete}
>
{t('gallery.deleteImage')}
</MenuItem>
</MenuList>
@ -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={<FaImage />}
sx={{
width: '100%',
@ -361,7 +324,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
}}
>
<IAIIconButton
onClickCapture={handleInitiateDelete}
onClickCapture={handleDelete}
aria-label={t('gallery.deleteImage')}
icon={<FaTrash />}
size="xs"
@ -373,11 +336,6 @@ const HoverableImage = memo((props: HoverableImageProps) => {
</Box>
)}
</ContextMenu>
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
</Box>
);
}, memoEqualityCheck);

View File

@ -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 (
<>
<IAIIconButton
onClick={handleInitiateDelete}
icon={<FaTrash />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={!image || !isConnected}
colorScheme="error"
/>
{image && (
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
)}
</>
);
};
export default memo(DeleteImageButton);

View File

@ -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) => {
>
<Flex gap={2}>
<Text fontWeight="semibold">File:</Text>
<Link
href={getUrl(image.image_url)}
isExternal
maxW="calc(100% - 3rem)"
>
<Link href={image.image_url} isExternal maxW="calc(100% - 3rem)">
{image.image_name}
<ExternalLinkIcon mx="2px" />
</Link>

View File

@ -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<RequestedImageDeletionArg>(
'gallery/requestedImageDeletion'
);
export const sentImageToCanvas = createAction('gallery/sentImageToCanvas');

View File

@ -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;
}
});
},
});

View File

@ -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<ImageDTO>({
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<ImageDTO>) => {
imagesAdapter.upsertOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string | ImageDTO>) => {
if (isString(action.payload)) {
imagesAdapter.removeOne(state, action.payload);
return;
}
imagesAdapter.removeOne(state, action.payload.image_name);
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
imagesAdapter.updateOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload);
},
imageCategoriesChanged: (state, action: PayloadAction<ImageCategory[]>) => {
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<RootState>((state) => state.images);
export const { imageUpserted, imageRemoved, imageCategoriesChanged } =
imagesSlice.actions;
export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imageCategoriesChanged,
} = imagesSlice.actions;
export default imagesSlice.reducer;

View File

@ -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 (
<TransformComponent
@ -37,7 +35,7 @@ export default function ReactPanZoomImage({
transform: `rotate(${rotation}deg) scaleX(${scaleX}) scaleY(${scaleY})`,
width: '100%',
}}
src={getUrl(image.image_url)}
src={image.image_url}
alt={alt}
ref={ref}
className={styleClass ? styleClass : ''}

View File

@ -6,7 +6,7 @@ import { memo } from 'react';
const FieldTypeLegend = () => {
return (
<Flex gap={2} flexDirection={{ base: 'column', xl: 'row' }}>
<Flex sx={{ gap: 2, flexDir: 'column' }}>
{map(FIELDS, ({ title, description, color }, key) => (
<Tooltip key={key} label={description}>
<Badge

View File

@ -11,7 +11,7 @@ const NodeEditor = () => {
sx={{
position: 'relative',
width: 'full',
height: { base: '100vh', xl: 'full' },
height: 'full',
borderRadius: 'md',
bg: 'base.850',
}}

View File

@ -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<InvocationValue>[];
@ -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;

View File

@ -25,6 +25,7 @@ const ParamNegativeConditioning = () => {
borderColor: 'error.600',
}}
fontSize="sm"
minH={16}
/>
</FormControl>
);

View File

@ -82,7 +82,7 @@ const ParamPositiveConditioning = () => {
onKeyDown={handleKeyDown}
resize="vertical"
ref={promptRef}
minH={{ base: 20, lg: 40 }}
minH={32}
/>
</FormControl>
</Box>

View File

@ -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();

View File

@ -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;
}
});
},
});

View File

@ -13,22 +13,16 @@ const InvokeAILogoComponent = () => {
<Image
src={InvokeAILogoImage}
alt="invoke-ai-logo"
w="32px"
h="32px"
minW="32px"
minH="32px"
userSelect="none"
/>
<Flex
gap={3}
display={{
base: 'inherit',
sm: 'none',
md: 'inherit',
sx={{
w: '32px',
h: '32px',
minW: '32px',
minH: '32px',
userSelect: 'none',
}}
>
<Text fontSize="xl">
/>
<Flex sx={{ gap: 3 }}>
<Text sx={{ fontSize: 'xl', userSelect: 'none' }}>
invoke <strong>ai</strong>
</Text>
<Text

View File

@ -1,66 +1,138 @@
import { Flex, Grid } from '@chakra-ui/react';
import { memo, useState } from 'react';
import { Flex, Spacer } from '@chakra-ui/react';
import { memo } from 'react';
import StatusIndicator from './StatusIndicator';
import InvokeAILogoComponent from './InvokeAILogoComponent';
import SiteHeaderMenu from './SiteHeaderMenu';
import useResolution from 'common/hooks/useResolution';
import { FaBars } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { Link } from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import { useTranslation } from 'react-i18next';
import { FaBug, FaCube, FaDiscord, FaGithub, FaKeyboard } from 'react-icons/fa';
import { MdSettings } from 'react-icons/md';
import HotkeysModal from './HotkeysModal/HotkeysModal';
import InvokeAILogoComponent from './InvokeAILogoComponent';
import LanguagePicker from './LanguagePicker';
import ModelManagerModal from './ModelManager/ModelManagerModal';
import SettingsModal from './SettingsModal/SettingsModal';
import ThemeChanger from './ThemeChanger';
import { useFeatureStatus } from '../hooks/useFeatureStatus';
/**
* Header, includes color mode toggle, settings button, status message.
*/
const SiteHeader = () => {
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 (
<Grid
gridTemplateColumns={{ base: 'auto', sm: 'auto max-content' }}
paddingRight={{ base: 10, xl: 0 }}
gap={2}
<Flex
sx={{
gap: 2,
alignItems: 'center',
}}
>
<Flex justifyContent={{ base: 'center', sm: 'start' }}>
<InvokeAILogoComponent />
</Flex>
<Flex
alignItems="center"
gap={2}
justifyContent={{ base: 'center', sm: 'start' }}
>
<StatusIndicator />
<InvokeAILogoComponent />
<Spacer />
<StatusIndicator />
{resolution === 'desktop' ? (
<SiteHeaderMenu />
) : (
{isModelManagerEnabled && (
<ModelManagerModal>
<IAIIconButton
icon={<FaBars />}
aria-label={t('accessibility.menu')}
background={menuOpened ? 'base.800' : 'none'}
_hover={{ background: menuOpened ? 'base.800' : 'none' }}
onClick={() => setMenuOpened(!menuOpened)}
p={0}
></IAIIconButton>
)}
</Flex>
{resolution !== 'desktop' && menuOpened && (
<Flex
position="absolute"
right={6}
top={{ base: 28, sm: 16 }}
backgroundColor="base.800"
padding={4}
borderRadius={4}
zIndex={{ base: 99, xl: 0 }}
>
<SiteHeaderMenu />
</Flex>
aria-label={t('modelManager.modelManager')}
tooltip={t('modelManager.modelManager')}
size="sm"
variant="link"
data-variant="link"
fontSize={20}
icon={<FaCube />}
/>
</ModelManagerModal>
)}
</Grid>
<HotkeysModal>
<IAIIconButton
aria-label={t('common.hotkeysLabel')}
tooltip={t('common.hotkeysLabel')}
size="sm"
variant="link"
data-variant="link"
fontSize={20}
icon={<FaKeyboard />}
/>
</HotkeysModal>
<ThemeChanger />
{isLocalizationEnabled && <LanguagePicker />}
{isBugLinkEnabled && (
<Link
isExternal
href="http://github.com/invoke-ai/InvokeAI/issues"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.reportBugLabel')}
tooltip={t('common.reportBugLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaBug />}
/>
</Link>
)}
{isGithubLinkEnabled && (
<Link
isExternal
href="http://github.com/invoke-ai/InvokeAI"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.githubLabel')}
tooltip={t('common.githubLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaGithub />}
/>
</Link>
)}
{isDiscordLinkEnabled && (
<Link
isExternal
href="https://discord.gg/ZmtBAhwWhy"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.discordLabel')}
tooltip={t('common.discordLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaDiscord />}
/>
</Link>
)}
<SettingsModal>
<IAIIconButton
aria-label={t('common.settingsLabel')}
tooltip={t('common.settingsLabel')}
variant="link"
data-variant="link"
fontSize={22}
size="sm"
icon={<MdSettings />}
/>
</SettingsModal>
</Flex>
);
};

View File

@ -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: [],

View File

@ -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
>
<TabList
pt={2}
gap={4}
flexDir={{ base: 'row', xl: 'column' }}
justifyContent={{ base: 'center', xl: 'start' }}
sx={{
pt: 2,
gap: 4,
flexDir: 'column',
}}
>
{tabs}
<Spacer />

View File

@ -33,7 +33,6 @@ const PinParametersPanelButton = (props: PinParametersPanelButtonProps) => {
icon={shouldPinParametersPanel ? <BsPinAngleFill /> : <BsPinAngle />}
variant="ghost"
size="sm"
px={{ base: 10, xl: 0 }}
sx={{
color: 'base.700',
_hover: {