From 90aa97edd40d465773b2b68a70d20008dba25436 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 4 Jul 2023 00:09:18 +1000
Subject: [PATCH] feat(ui): add multi-select and batch capabilities
This introduces the core functionality for batch operations on images and multiple selection in the gallery/batch manager.
A number of other substantial changes are included:
- `imagesSlice` is consolidated into `gallerySlice`, allowing for simpler selection of filtered images
- `batchSlice` is added to manage the batch
- The wonky context pattern for image deletion has been changed, much simpler now using a `imageDeletionSlice` and redux listeners; this needs to be implemented still for the other image modals
- Minimum gallery size in px implemented as a hook
- Many style fixes & several bug fixes
TODO:
- The UI and UX need to be figured out, especially for controlnet
- Batch processing is not hooked up; generation does not do anything with batch
- Routes to support batch image operations, specifically delete and add/remove to/from boards
---
invokeai/frontend/web/.eslintrc.js | 6 +
invokeai/frontend/web/package.json | 2 +-
invokeai/frontend/web/public/locales/en.json | 1 +
.../frontend/web/src/app/components/App.tsx | 27 +-
.../app/components/ImageDnd/DragPreview.tsx | 82 ++++
.../components/ImageDnd/ImageDndContext.tsx | 44 ++-
.../components/ImageDnd/OverlayDragImage.tsx | 36 --
.../app/components/ImageDnd/typesafeDnd.tsx | 195 +++++++++
.../web/src/app/components/InvokeAIUI.tsx | 24 +-
.../app/contexts/DeleteBoardImagesContext.tsx | 12 +-
.../src/app/contexts/DeleteImageContext.tsx | 201 ----------
.../enhancers/reduxRemember/unserialize.ts | 2 -
.../middleware/listenerMiddleware/index.ts | 11 +-
.../listeners/boardIdSelected.ts | 14 +-
.../listeners/boardImagesDeleted.ts | 13 +-
.../listeners/canvasSavedToGallery.ts | 2 +-
.../listeners/imageCategoriesChanged.ts | 6 +-
.../listeners/imageDeleted.ts | 26 +-
.../listeners/imageDropped.ts | 188 +++++++++
.../listeners/imageMetadataReceived.ts | 2 +-
.../listeners/imageToDeleteSelected.ts | 40 ++
.../listeners/imageUploaded.ts | 8 +-
.../listeners/imageUrlsReceived.ts | 2 +-
.../listeners/initialImageSelected.ts | 2 +-
.../listeners/receivedPageOfImages.ts | 11 +-
.../listeners/selectionAddedToBatch.ts | 19 +
.../listeners/socketio/socketConnected.ts | 4 +-
.../listeners/stagingAreaImageSaved.ts | 2 +-
.../listeners/updateImageUrlsOnConnect.ts | 4 +-
invokeai/frontend/web/src/app/store/store.ts | 9 +-
.../web/src/common/components/IAIButton.tsx | 21 +-
.../web/src/common/components/IAIDndImage.tsx | 158 +++++---
.../src/common/components/IAIDropOverlay.tsx | 8 +-
.../src/common/components/IAIIconButton.tsx | 2 +-
.../common/components/IAIImageFallback.tsx | 73 ++--
.../src/common/hooks/useIsReadyToInvoke.ts | 16 +-
.../batch/components/BatchControlNet.tsx | 67 ++++
.../features/batch/components/BatchImage.tsx | 115 ++++++
.../batch/components/BatchImageContainer.tsx | 31 ++
.../batch/components/BatchImageGrid.tsx | 54 +++
.../batch/components/BatchManager.tsx | 103 +++++
.../src/features/batch/store/batchSlice.ts | 142 +++++++
.../components/ControlNetImagePreview.tsx | 96 +++--
.../components/Boards/AllImagesBoard.tsx | 74 ++--
.../gallery/components/Boards/BoardsList.tsx | 36 +-
.../components/Boards/HoverableBoard.tsx | 75 ++--
.../components/CurrentImageButtons.tsx | 67 ++--
.../components/CurrentImageDisplay.tsx | 22 +-
.../components/CurrentImagePreview.tsx | 67 ++--
.../gallery/components/DeleteImageModal.tsx | 166 --------
.../gallery/components/GalleryImage.tsx | 131 +++++++
.../gallery/components/HoverableImage.tsx | 371 ------------------
.../gallery/components/ImageContextMenu.tsx | 278 +++++++++++++
.../components/ImageGalleryContent.tsx | 224 +++--------
.../components/NextPrevImageButtons.tsx | 50 ++-
.../components/SelectedItemOverlay.tsx | 40 --
.../gallery/hooks/useGetImageByName.ts | 18 -
.../web/src/features/gallery/store/actions.ts | 13 +-
.../src/features/gallery/store/boardSlice.ts | 17 +-
.../gallery/store/galleryPersistDenylist.ts | 13 +-
.../features/gallery/store/gallerySlice.ts | 285 +++++++++++---
.../src/features/gallery/store/imagesSlice.ts | 182 ---------
.../components/DeleteImageButton.tsx | 37 ++
.../components/DeleteImageModal.tsx | 122 ++++++
.../components/ImageUsageMessage.tsx | 33 ++
.../imageDeletion/store/imageDeletionSlice.ts | 99 +++++
.../nodes/components/InputFieldComponent.tsx | 11 +
.../nodes/components/InvocationComponent.tsx | 2 +-
.../ImageCollectionInputFieldComponent.tsx | 103 +++++
.../fields/ImageInputFieldComponent.tsx | 53 ++-
.../src/features/nodes/store/nodesSlice.ts | 40 +-
.../web/src/features/nodes/types/constants.ts | 10 +-
.../web/src/features/nodes/types/types.ts | 19 +-
.../nodes/util/fieldTemplateBuilders.ts | 20 +
.../features/nodes/util/fieldValueBuilders.ts | 4 +
.../buildLinearImageToImageGraph.ts | 50 ++-
.../nodes/util/graphBuilders/constants.ts | 2 +
.../Parameters/ImageToImage/InitialImage.tsx | 76 ++++
.../ImageToImage/InitialImageDisplay.tsx | 141 ++++++-
.../ImageToImage/InitialImagePreview.tsx | 126 ------
.../Parameters/Seed/ParamSeedRandomize.tsx | 28 --
.../Parameters/Seed/ParamSeedShuffle.tsx | 14 -
.../ProcessButtons/InvokeButton.tsx | 41 +-
.../system/components/ModelSelect.tsx | 5 +-
.../src/features/ui/components/InvokeTabs.tsx | 8 +-
.../ui/components/ParametersDrawer.tsx | 23 +-
.../ui/components/ParametersPinnedWrapper.tsx | 12 +-
.../ui/components/tabs/Batch/BatchTab.tsx | 43 ++
.../UnifiedCanvas/UnifiedCanvasContent.tsx | 36 +-
.../web/src/features/ui/store/tabMap.ts | 1 +
.../src/services/api/endpoints/boardImages.ts | 33 +-
.../frontend/web/src/services/api/schema.d.ts | 233 +++++++++--
.../web/src/services/api/thunks/image.ts | 13 +-
.../frontend/web/src/services/api/types.d.ts | 1 +
.../web/src/theme/components/button.ts | 57 +--
.../frontend/web/src/theme/components/menu.ts | 2 +
.../web/src/theme/components/progress.ts | 9 +-
.../web/src/theme/components/skeleton.ts | 25 ++
invokeai/frontend/web/src/theme/theme.ts | 7 +
invokeai/frontend/web/yarn.lock | 2 +-
100 files changed, 3476 insertions(+), 2075 deletions(-)
create mode 100644 invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx
delete mode 100644 invokeai/frontend/web/src/app/components/ImageDnd/OverlayDragImage.tsx
create mode 100644 invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx
delete mode 100644 invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx
create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts
create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts
create mode 100644 invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx
create mode 100644 invokeai/frontend/web/src/features/batch/components/BatchImage.tsx
create mode 100644 invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx
create mode 100644 invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx
create mode 100644 invokeai/frontend/web/src/features/batch/components/BatchManager.tsx
create mode 100644 invokeai/frontend/web/src/features/batch/store/batchSlice.ts
delete mode 100644 invokeai/frontend/web/src/features/gallery/components/DeleteImageModal.tsx
create mode 100644 invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx
delete mode 100644 invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx
create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx
delete mode 100644 invokeai/frontend/web/src/features/gallery/components/SelectedItemOverlay.tsx
delete mode 100644 invokeai/frontend/web/src/features/gallery/hooks/useGetImageByName.ts
delete mode 100644 invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts
create mode 100644 invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx
create mode 100644 invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx
create mode 100644 invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx
create mode 100644 invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/fields/ImageCollectionInputFieldComponent.tsx
create mode 100644 invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx
delete mode 100644 invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx
create mode 100644 invokeai/frontend/web/src/features/ui/components/tabs/Batch/BatchTab.tsx
create mode 100644 invokeai/frontend/web/src/theme/components/skeleton.ts
diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js
index b1a2b6a7e4..34db9d466b 100644
--- a/invokeai/frontend/web/.eslintrc.js
+++ b/invokeai/frontend/web/.eslintrc.js
@@ -36,6 +36,12 @@ module.exports = {
],
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'@typescript-eslint/ban-ts-comment': 'warn',
+ '@typescript-eslint/no-empty-interface': [
+ 'error',
+ {
+ allowSingleExtends: true,
+ },
+ ],
},
settings: {
react: {
diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index 786a721d5c..19cd5e935b 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -82,7 +82,7 @@
"konva": "^9.2.0",
"lodash-es": "^4.17.21",
"nanostores": "^0.9.2",
- "openapi-fetch": "^0.4.0",
+ "openapi-fetch": "0.4.0",
"overlayscrollbars": "^2.2.0",
"overlayscrollbars-react": "^0.5.0",
"patch-package": "^7.0.0",
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 1b3b790222..ab5d536f0c 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -52,6 +52,7 @@
"unifiedCanvas": "Unified Canvas",
"linear": "Linear",
"nodes": "Node Editor",
+ "batch": "Batch Manager",
"postprocessing": "Post Processing",
"nodesDesc": "A node based system for the generation of images is under development currently. Stay tuned for updates about this amazing feature.",
"postProcessing": "Post Processing",
diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx
index 67d2bb2a4b..2b0e247d48 100644
--- a/invokeai/frontend/web/src/app/components/App.tsx
+++ b/invokeai/frontend/web/src/app/components/App.tsx
@@ -7,7 +7,6 @@ import GalleryDrawer from 'features/gallery/components/GalleryPanel';
import Lightbox from 'features/lightbox/components/Lightbox';
import SiteHeader from 'features/system/components/SiteHeader';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
-import { useIsApplicationReady } from 'features/system/hooks/useIsApplicationReady';
import { configChanged } from 'features/system/store/configSlice';
import { languageSelector } from 'features/system/store/systemSelectors';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
@@ -15,47 +14,27 @@ import FloatingParametersPanelButtons from 'features/ui/components/FloatingParam
import InvokeTabs from 'features/ui/components/InvokeTabs';
import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import i18n from 'i18n';
-import { ReactNode, memo, useCallback, useEffect, useState } from 'react';
+import { ReactNode, memo, useEffect } from 'react';
import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster';
-import DeleteImageModal from 'features/gallery/components/DeleteImageModal';
-import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal';
-import { useListModelsQuery } from 'services/api/endpoints/models';
import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal';
+import DeleteImageModal from 'features/imageDeletion/components/DeleteImageModal';
const DEFAULT_CONFIG = {};
interface Props {
config?: PartialAppConfig;
headerComponent?: ReactNode;
- setIsReady?: (isReady: boolean) => void;
}
-const App = ({
- config = DEFAULT_CONFIG,
- headerComponent,
- setIsReady,
-}: Props) => {
+const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
const language = useAppSelector(languageSelector);
const log = useLogger();
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
- const isApplicationReady = useIsApplicationReady();
-
- const { data: pipelineModels } = useListModelsQuery({
- model_type: 'main',
- });
- const { data: controlnetModels } = useListModelsQuery({
- model_type: 'controlnet',
- });
- const { data: vaeModels } = useListModelsQuery({ model_type: 'vae' });
- const { data: loraModels } = useListModelsQuery({ model_type: 'lora' });
- const { data: embeddingModels } = useListModelsQuery({
- model_type: 'embedding',
- });
const dispatch = useAppDispatch();
useEffect(() => {
diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx
new file mode 100644
index 0000000000..5b6142d748
--- /dev/null
+++ b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx
@@ -0,0 +1,82 @@
+import { Box, ChakraProps, Flex, Heading, Image } from '@chakra-ui/react';
+import { memo } from 'react';
+import { TypesafeDraggableData } from './typesafeDnd';
+
+type OverlayDragImageProps = {
+ dragData: TypesafeDraggableData | null;
+};
+
+const BOX_SIZE = 28;
+
+const STYLES: ChakraProps['sx'] = {
+ w: BOX_SIZE,
+ h: BOX_SIZE,
+ maxW: BOX_SIZE,
+ maxH: BOX_SIZE,
+ shadow: 'dark-lg',
+ borderRadius: 'lg',
+ borderWidth: 2,
+ borderStyle: 'dashed',
+ borderColor: 'base.100',
+ opacity: 0.5,
+ bg: 'base.800',
+ color: 'base.50',
+ _dark: {
+ borderColor: 'base.200',
+ bg: 'base.900',
+ color: 'base.100',
+ },
+};
+
+const DragPreview = (props: OverlayDragImageProps) => {
+ if (!props.dragData) {
+ return;
+ }
+
+ if (props.dragData.payloadType === 'IMAGE_DTO') {
+ return (
+
+
+
+ );
+ }
+
+ if (props.dragData.payloadType === 'IMAGE_NAMES') {
+ return (
+
+ {props.dragData.payload.imageNames.length}
+ Images
+
+ );
+ }
+
+ return null;
+};
+
+export default memo(DragPreview);
diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx
index 6150259f66..1b8687bf8e 100644
--- a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx
+++ b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx
@@ -1,8 +1,5 @@
import {
- DndContext,
- DragEndEvent,
DragOverlay,
- DragStartEvent,
MouseSensor,
TouchSensor,
pointerWithin,
@@ -10,33 +7,45 @@ import {
useSensors,
} from '@dnd-kit/core';
import { PropsWithChildren, memo, useCallback, useState } from 'react';
-import OverlayDragImage from './OverlayDragImage';
-import { ImageDTO } from 'services/api/types';
-import { isImageDTO } from 'services/api/guards';
+import DragPreview from './DragPreview';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { AnimatePresence, motion } from 'framer-motion';
+import {
+ DndContext,
+ DragEndEvent,
+ DragStartEvent,
+ TypesafeDraggableData,
+} from './typesafeDnd';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
type ImageDndContextProps = PropsWithChildren;
const ImageDndContext = (props: ImageDndContextProps) => {
- const [draggedImage, setDraggedImage] = useState(null);
+ const [activeDragData, setActiveDragData] =
+ useState(null);
+
+ const dispatch = useAppDispatch();
const handleDragStart = useCallback((event: DragStartEvent) => {
- const dragData = event.active.data.current;
- if (dragData && 'image' in dragData && isImageDTO(dragData.image)) {
- setDraggedImage(dragData.image);
+ const activeData = event.active.data.current;
+ if (!activeData) {
+ return;
}
+ setActiveDragData(activeData);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
- const handleDrop = event.over?.data.current?.handleDrop;
- if (handleDrop && typeof handleDrop === 'function' && draggedImage) {
- handleDrop(draggedImage);
+ const activeData = event.active.data.current;
+ const overData = event.over?.data.current;
+ if (!activeData || !overData) {
+ return;
}
- setDraggedImage(null);
+ dispatch(imageDropped({ overData, activeData }));
+ setActiveDragData(null);
},
- [draggedImage]
+ [dispatch]
);
const mouseSensor = useSensor(MouseSensor, {
@@ -46,6 +55,7 @@ const ImageDndContext = (props: ImageDndContextProps) => {
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 5 },
});
+
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos
// Alternatively, fix `rectIntersection` collection detection to work with the drag overlay
// (currently the drag element collision rect is not correctly calculated)
@@ -63,7 +73,7 @@ const ImageDndContext = (props: ImageDndContextProps) => {
{props.children}
- {draggedImage && (
+ {activeDragData && (
{
transition: { duration: 0.1 },
}}
>
-
+
)}
diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/OverlayDragImage.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/OverlayDragImage.tsx
deleted file mode 100644
index 611d1ceee9..0000000000
--- a/invokeai/frontend/web/src/app/components/ImageDnd/OverlayDragImage.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Box, Image } from '@chakra-ui/react';
-import { memo } from 'react';
-import { ImageDTO } from 'services/api/types';
-
-type OverlayDragImageProps = {
- image: ImageDTO;
-};
-
-const OverlayDragImage = (props: OverlayDragImageProps) => {
- return (
-
-
-
- );
-};
-
-export default memo(OverlayDragImage);
diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx
new file mode 100644
index 0000000000..e744a70750
--- /dev/null
+++ b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx
@@ -0,0 +1,195 @@
+// type-safe dnd from https://github.com/clauderic/dnd-kit/issues/935
+import {
+ Active,
+ Collision,
+ DndContextProps,
+ DndContext as OriginalDndContext,
+ Over,
+ Translate,
+ UseDraggableArguments,
+ UseDroppableArguments,
+ useDraggable as useOriginalDraggable,
+ useDroppable as useOriginalDroppable,
+} from '@dnd-kit/core';
+import { ImageDTO } from 'services/api/types';
+
+type BaseDropData = {
+ id: string;
+};
+
+export type CurrentImageDropData = BaseDropData & {
+ actionType: 'SET_CURRENT_IMAGE';
+};
+
+export type InitialImageDropData = BaseDropData & {
+ actionType: 'SET_INITIAL_IMAGE';
+};
+
+export type ControlNetDropData = BaseDropData & {
+ actionType: 'SET_CONTROLNET_IMAGE';
+ context: {
+ controlNetId: string;
+ };
+};
+
+export type CanvasInitialImageDropData = BaseDropData & {
+ actionType: 'SET_CANVAS_INITIAL_IMAGE';
+};
+
+export type NodesImageDropData = BaseDropData & {
+ actionType: 'SET_NODES_IMAGE';
+ context: {
+ nodeId: string;
+ fieldName: string;
+ };
+};
+
+export type NodesMultiImageDropData = BaseDropData & {
+ actionType: 'SET_MULTI_NODES_IMAGE';
+ context: { nodeId: string; fieldName: string };
+};
+
+export type AddToBatchDropData = BaseDropData & {
+ actionType: 'ADD_TO_BATCH';
+};
+
+export type MoveBoardDropData = BaseDropData & {
+ actionType: 'MOVE_BOARD';
+ context: { boardId: string | null };
+};
+
+export type TypesafeDroppableData =
+ | CurrentImageDropData
+ | InitialImageDropData
+ | ControlNetDropData
+ | CanvasInitialImageDropData
+ | NodesImageDropData
+ | AddToBatchDropData
+ | NodesMultiImageDropData
+ | MoveBoardDropData;
+
+type BaseDragData = {
+ id: string;
+};
+
+export type ImageDraggableData = BaseDragData & {
+ payloadType: 'IMAGE_DTO';
+ payload: { imageDTO: ImageDTO };
+};
+
+export type ImageNamesDraggableData = BaseDragData & {
+ payloadType: 'IMAGE_NAMES';
+ payload: { imageNames: string[] };
+};
+
+export type TypesafeDraggableData =
+ | ImageDraggableData
+ | ImageNamesDraggableData;
+
+interface UseDroppableTypesafeArguments
+ extends Omit {
+ data?: TypesafeDroppableData;
+}
+
+type UseDroppableTypesafeReturnValue = Omit<
+ ReturnType,
+ 'active' | 'over'
+> & {
+ active: TypesafeActive | null;
+ over: TypesafeOver | null;
+};
+
+export function useDroppable(props: UseDroppableTypesafeArguments) {
+ return useOriginalDroppable(props) as UseDroppableTypesafeReturnValue;
+}
+
+interface UseDraggableTypesafeArguments
+ extends Omit {
+ data?: TypesafeDraggableData;
+}
+
+type UseDraggableTypesafeReturnValue = Omit<
+ ReturnType,
+ 'active' | 'over'
+> & {
+ active: TypesafeActive | null;
+ over: TypesafeOver | null;
+};
+
+export function useDraggable(props: UseDraggableTypesafeArguments) {
+ return useOriginalDraggable(props) as UseDraggableTypesafeReturnValue;
+}
+
+interface TypesafeActive extends Omit {
+ data: React.MutableRefObject;
+}
+
+interface TypesafeOver extends Omit {
+ data: React.MutableRefObject;
+}
+
+export const isValidDrop = (
+ overData: TypesafeDroppableData | undefined,
+ active: TypesafeActive | null
+) => {
+ if (!overData || !active?.data.current) {
+ return false;
+ }
+
+ const { actionType } = overData;
+ const { payloadType } = active.data.current;
+
+ if (overData.id === active.data.current.id) {
+ return false;
+ }
+
+ switch (actionType) {
+ case 'SET_CURRENT_IMAGE':
+ return payloadType === 'IMAGE_DTO';
+ case 'SET_INITIAL_IMAGE':
+ return payloadType === 'IMAGE_DTO';
+ case 'SET_CONTROLNET_IMAGE':
+ return payloadType === 'IMAGE_DTO';
+ case 'SET_CANVAS_INITIAL_IMAGE':
+ return payloadType === 'IMAGE_DTO';
+ case 'SET_NODES_IMAGE':
+ return payloadType === 'IMAGE_DTO';
+ case 'SET_MULTI_NODES_IMAGE':
+ return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
+ case 'ADD_TO_BATCH':
+ return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
+ case 'MOVE_BOARD':
+ return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
+ default:
+ return false;
+ }
+};
+
+interface DragEvent {
+ activatorEvent: Event;
+ active: TypesafeActive;
+ collisions: Collision[] | null;
+ delta: Translate;
+ over: TypesafeOver | null;
+}
+
+export interface DragStartEvent extends Pick {}
+export interface DragMoveEvent extends DragEvent {}
+export interface DragOverEvent extends DragMoveEvent {}
+export interface DragEndEvent extends DragEvent {}
+export interface DragCancelEvent extends DragEndEvent {}
+
+export interface DndContextTypesafeProps
+ extends Omit<
+ DndContextProps,
+ 'onDragStart' | 'onDragMove' | 'onDragOver' | 'onDragEnd' | 'onDragCancel'
+ > {
+ onDragStart?(event: DragStartEvent): void;
+ onDragMove?(event: DragMoveEvent): void;
+ onDragOver?(event: DragOverEvent): void;
+ onDragEnd?(event: DragEndEvent): void;
+ onDragCancel?(event: DragCancelEvent): void;
+}
+export function DndContext(props: DndContextTypesafeProps) {
+ return ;
+}
diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
index 7259f6105d..105f8f18d7 100644
--- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
+++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
@@ -7,7 +7,6 @@ import React, {
} from 'react';
import { Provider } from 'react-redux';
import { store } from 'app/store/store';
-// import { OpenAPI } from 'services/api/types';
import Loading from '../../common/components/Loading/Loading';
import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares';
@@ -17,11 +16,6 @@ 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';
-import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal';
import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext';
import { $authToken, $baseUrl } from 'services/api/client';
import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext';
@@ -34,7 +28,6 @@ interface Props extends PropsWithChildren {
token?: string;
config?: PartialAppConfig;
headerComponent?: ReactNode;
- setIsReady?: (isReady: boolean) => void;
middleware?: Middleware[];
}
@@ -43,7 +36,6 @@ const InvokeAIUI = ({
token,
config,
headerComponent,
- setIsReady,
middleware,
}: Props) => {
useEffect(() => {
@@ -85,17 +77,11 @@ const InvokeAIUI = ({
}>
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx
index 38c89bfcf9..15f9fab282 100644
--- a/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx
+++ b/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx
@@ -5,15 +5,15 @@ import { useDeleteBoardMutation } from '../../services/api/endpoints/boards';
import { defaultSelectorOptions } from '../store/util/defaultMemoizeOptions';
import { createSelector } from '@reduxjs/toolkit';
import { some } from 'lodash-es';
-import { canvasSelector } from '../../features/canvas/store/canvasSelectors';
-import { controlNetSelector } from '../../features/controlNet/store/controlNetSlice';
-import { selectImagesById } from '../../features/gallery/store/imagesSlice';
-import { nodesSelector } from '../../features/nodes/store/nodesSlice';
-import { generationSelector } from '../../features/parameters/store/generationSelectors';
+import { canvasSelector } from 'features/canvas/store/canvasSelectors';
+import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
+import { selectImagesById } from 'features/gallery/store/gallerySlice';
+import { nodesSelector } from 'features/nodes/store/nodesSlice';
+import { generationSelector } from 'features/parameters/store/generationSelectors';
import { RootState } from '../store/store';
import { useAppDispatch, useAppSelector } from '../store/storeHooks';
import { ImageUsage } from './DeleteImageContext';
-import { requestedBoardImagesDeletion } from '../../features/gallery/store/actions';
+import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
export const selectBoardImagesUsage = createSelector(
[
diff --git a/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx
deleted file mode 100644
index 6f4af7608f..0000000000
--- a/invokeai/frontend/web/src/app/contexts/DeleteImageContext.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-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/types';
-import { RootState } from 'app/store/store';
-import { canvasSelector } from 'features/canvas/store/canvasSelectors';
-import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
-import { nodesSelector } 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,
- nodesSelector,
- controlNetSelector,
- (state: RootState, image_name?: string) => image_name,
- ],
- (generation, canvas, nodes, controlNet, image_name) => {
- const isInitialImage = generation.initialImage?.imageName === image_name;
-
- const isCanvasImage = canvas.layerState.objects.some(
- (obj) => obj.kind === 'image' && obj.imageName === image_name
- );
-
- const isNodesImage = nodes.nodes.some((node) => {
- return some(
- node.data.inputs,
- (input) => input.type === 'image' && input.value === image_name
- );
- });
-
- const isControlNetImage = some(
- controlNet.controlNets,
- (c) =>
- c.controlImage === image_name || c.processedControlImage === 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/enhancers/reduxRemember/unserialize.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/unserialize.ts
index 8f40b0bb59..23e6448987 100644
--- a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/unserialize.ts
+++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/unserialize.ts
@@ -1,7 +1,6 @@
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlNetState } from 'features/controlNet/store/controlNetSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
-import { initialImagesState } from 'features/gallery/store/imagesSlice';
import { initialLightboxState } from 'features/lightbox/store/lightboxSlice';
import { initialNodesState } from 'features/nodes/store/nodesSlice';
import { initialGenerationState } from 'features/parameters/store/generationSlice';
@@ -26,7 +25,6 @@ const initialStates: {
config: initialConfigState,
ui: initialUIState,
hotkeys: initialHotkeysState,
- images: initialImagesState,
controlNet: initialControlNetState,
};
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 a36141fafc..900fabfee9 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
@@ -72,7 +72,6 @@ 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';
import {
addImageAddedToBoardFulfilledListener,
addImageAddedToBoardRejectedListener,
@@ -84,6 +83,9 @@ import {
} from './listeners/imageRemovedFromBoard';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted';
+import { addSelectionAddedToBatchListener } from './listeners/selectionAddedToBatch';
+import { addImageDroppedListener } from './listeners/imageDropped';
+import { addImageToDeleteSelectedListener } from './listeners/imageToDeleteSelected';
export const listenerMiddleware = createListenerMiddleware();
@@ -126,6 +128,7 @@ addImageDeletedPendingListener();
addImageDeletedFulfilledListener();
addImageDeletedRejectedListener();
addRequestedBoardImageDeletionListener();
+addImageToDeleteSelectedListener();
// Image metadata
addImageMetadataReceivedFulfilledListener();
@@ -211,3 +214,9 @@ addBoardIdSelectedListener();
// Node schemas
addReceivedOpenAPISchemaListener();
+
+// Batches
+addSelectionAddedToBatchListener();
+
+// DND
+addImageDroppedListener();
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts
index 1c96c5700d..6ce6665cc5 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts
@@ -1,12 +1,14 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
-import { boardIdSelected } from 'features/gallery/store/boardSlice';
-import { selectImagesAll } from 'features/gallery/store/imagesSlice';
+import {
+ imageSelected,
+ selectImagesAll,
+ boardIdSelected,
+} from 'features/gallery/store/gallerySlice';
import {
IMAGES_PER_PAGE,
receivedPageOfImages,
} from 'services/api/thunks/image';
-import { imageSelected } from 'features/gallery/store/gallerySlice';
import { boardsApi } from 'services/api/endpoints/boards';
const moduleLog = log.child({ namespace: 'boards' });
@@ -28,7 +30,7 @@ export const addBoardIdSelectedListener = () => {
return;
}
- const { categories } = state.images;
+ const { categories } = state.gallery;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
@@ -47,7 +49,7 @@ export const addBoardIdSelectedListener = () => {
return;
}
- dispatch(imageSelected(board.cover_image_name));
+ dispatch(imageSelected(board.cover_image_name ?? null));
// if we haven't loaded one full page of images from this board, load more
if (
@@ -77,7 +79,7 @@ export const addBoardIdSelected_changeSelectedImage_listener = () => {
return;
}
- const { categories } = state.images;
+ const { categories } = state.gallery;
const filteredImages = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts
index c4d3c5f0ba..4b48aa4626 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts
@@ -1,11 +1,11 @@
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
-import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
+ imageSelected,
imagesRemoved,
selectImagesAll,
selectImagesById,
-} from 'features/gallery/store/imagesSlice';
+} from 'features/gallery/store/gallerySlice';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
@@ -22,12 +22,15 @@ export const addRequestedBoardImageDeletionListener = () => {
const { board_id } = board;
const state = getState();
- const selectedImage = state.gallery.selectedImage
- ? selectImagesById(state, state.gallery.selectedImage)
+ const selectedImageName =
+ state.gallery.selection[state.gallery.selection.length - 1];
+
+ const selectedImage = selectedImageName
+ ? selectImagesById(state, selectedImageName)
: undefined;
if (selectedImage && selectedImage.board_id === board_id) {
- dispatch(imageSelected());
+ dispatch(imageSelected(null));
}
// We need to reset the features where the board images are in use - none of these work if their image(s) don't exist
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 af55a1382e..610d89873f 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,7 +4,7 @@ import { log } from 'app/logging/useLogger';
import { imageUploaded } from 'services/api/thunks/image';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice';
-import { imageUpserted } from 'features/gallery/store/imagesSlice';
+import { imageUpserted } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts
index 25b7b7c11f..178cb3c835 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageCategoriesChanged.ts
@@ -3,8 +3,8 @@ import { startAppListening } from '..';
import { receivedPageOfImages } from 'services/api/thunks/image';
import {
imageCategoriesChanged,
- selectFilteredImagesAsArray,
-} from 'features/gallery/store/imagesSlice';
+ selectFilteredImages,
+} from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'gallery' });
@@ -13,7 +13,7 @@ export const addImageCategoriesChangedListener = () => {
actionCreator: imageCategoriesChanged,
effect: (action, { getState, dispatch }) => {
const state = getState();
- const filteredImagesCount = selectFilteredImagesAsArray(state).length;
+ const filteredImagesCount = selectFilteredImages(state).length;
if (!filteredImagesCount) {
dispatch(
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 91cd509ca6..ca20170c5d 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
@@ -1,18 +1,21 @@
-import { requestedImageDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import { imageDeleted } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger';
import { clamp } from 'lodash-es';
-import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
+ imageSelected,
imageRemoved,
selectImagesIds,
-} from 'features/gallery/store/imagesSlice';
+} from 'features/gallery/store/gallerySlice';
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';
import { api } from 'services/api';
+import {
+ imageDeletionConfirmed,
+ isModalOpenChanged,
+} from 'features/imageDeletion/store/imageDeletionSlice';
const moduleLog = log.child({ namespace: 'image' });
@@ -21,16 +24,19 @@ const moduleLog = log.child({ namespace: 'image' });
*/
export const addRequestedImageDeletionListener = () => {
startAppListening({
- actionCreator: requestedImageDeletion,
+ actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState, condition }) => {
- const { image, imageUsage } = action.payload;
+ const { imageDTO, imageUsage } = action.payload;
- const { image_name } = image;
+ dispatch(isModalOpenChanged(false));
+
+ const { image_name } = imageDTO;
const state = getState();
- const selectedImage = state.gallery.selectedImage;
+ const lastSelectedImage =
+ state.gallery.selection[state.gallery.selection.length - 1];
- if (selectedImage === image_name) {
+ if (lastSelectedImage === image_name) {
const ids = selectImagesIds(state);
const deletedImageIndex = ids.findIndex(
@@ -50,7 +56,7 @@ export const addRequestedImageDeletionListener = () => {
if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImageId as string));
} else {
- dispatch(imageSelected());
+ dispatch(imageSelected(null));
}
}
@@ -88,7 +94,7 @@ export const addRequestedImageDeletionListener = () => {
if (wasImageDeleted) {
dispatch(
- api.util.invalidateTags([{ type: 'Board', id: image.board_id }])
+ api.util.invalidateTags([{ type: 'Board', id: imageDTO.board_id }])
);
}
},
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
new file mode 100644
index 0000000000..56f660a653
--- /dev/null
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
@@ -0,0 +1,188 @@
+import { createAction } from '@reduxjs/toolkit';
+import { startAppListening } from '../';
+import { log } from 'app/logging/useLogger';
+import {
+ TypesafeDraggableData,
+ TypesafeDroppableData,
+} from 'app/components/ImageDnd/typesafeDnd';
+import { imageSelected } from 'features/gallery/store/gallerySlice';
+import { initialImageChanged } from 'features/parameters/store/generationSlice';
+import {
+ imageAddedToBatch,
+ imagesAddedToBatch,
+} from 'features/batch/store/batchSlice';
+import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
+import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
+import {
+ fieldValueChanged,
+ imageCollectionFieldValueChanged,
+} from 'features/nodes/store/nodesSlice';
+import { boardsApi } from 'services/api/endpoints/boards';
+import { boardImagesApi } from 'services/api/endpoints/boardImages';
+
+const moduleLog = log.child({ namespace: 'dnd' });
+
+export const imageDropped = createAction<{
+ overData: TypesafeDroppableData;
+ activeData: TypesafeDraggableData;
+}>('dnd/imageDropped');
+
+export const addImageDroppedListener = () => {
+ startAppListening({
+ actionCreator: imageDropped,
+ effect: (action, { dispatch, getState }) => {
+ const { activeData, overData } = action.payload;
+ const { actionType } = overData;
+
+ // set current image
+ if (
+ actionType === 'SET_CURRENT_IMAGE' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO
+ ) {
+ dispatch(imageSelected(activeData.payload.imageDTO.image_name));
+ }
+
+ // set initial image
+ if (
+ actionType === 'SET_INITIAL_IMAGE' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO
+ ) {
+ dispatch(initialImageChanged(activeData.payload.imageDTO));
+ }
+
+ // add image to batch
+ if (
+ actionType === 'ADD_TO_BATCH' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO
+ ) {
+ dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name));
+ }
+
+ // add multiple images to batch
+ if (
+ actionType === 'ADD_TO_BATCH' &&
+ activeData.payloadType === 'IMAGE_NAMES'
+ ) {
+ dispatch(imagesAddedToBatch(activeData.payload.imageNames));
+ }
+
+ // set control image
+ if (
+ actionType === 'SET_CONTROLNET_IMAGE' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO
+ ) {
+ const { controlNetId } = overData.context;
+ dispatch(
+ controlNetImageChanged({
+ controlImage: activeData.payload.imageDTO.image_name,
+ controlNetId,
+ })
+ );
+ }
+
+ // set canvas image
+ if (
+ actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO
+ ) {
+ dispatch(setInitialCanvasImage(activeData.payload.imageDTO));
+ }
+
+ // set nodes image
+ if (
+ actionType === 'SET_NODES_IMAGE' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO
+ ) {
+ const { fieldName, nodeId } = overData.context;
+ dispatch(
+ fieldValueChanged({
+ nodeId,
+ fieldName,
+ value: activeData.payload.imageDTO,
+ })
+ );
+ }
+
+ // set multiple nodes images (single image handler)
+ if (
+ actionType === 'SET_MULTI_NODES_IMAGE' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO
+ ) {
+ const { fieldName, nodeId } = overData.context;
+ dispatch(
+ fieldValueChanged({
+ nodeId,
+ fieldName,
+ value: [activeData.payload.imageDTO],
+ })
+ );
+ }
+
+ // set multiple nodes images (multiple images handler)
+ if (
+ actionType === 'SET_MULTI_NODES_IMAGE' &&
+ activeData.payloadType === 'IMAGE_NAMES'
+ ) {
+ const { fieldName, nodeId } = overData.context;
+ dispatch(
+ imageCollectionFieldValueChanged({
+ nodeId,
+ fieldName,
+ value: activeData.payload.imageNames.map((image_name) => ({
+ image_name,
+ })),
+ })
+ );
+ }
+
+ // remove image from board
+ // TODO: remove board_id from `removeImageFromBoard()` endpoint
+ // TODO: handle multiple images
+ // if (
+ // actionType === 'MOVE_BOARD' &&
+ // activeData.payloadType === 'IMAGE_DTO' &&
+ // activeData.payload.imageDTO &&
+ // overData.boardId !== null
+ // ) {
+ // const { image_name } = activeData.payload.imageDTO;
+ // dispatch(
+ // boardImagesApi.endpoints.removeImageFromBoard.initiate({ image_name })
+ // );
+ // }
+
+ // add image to board
+ if (
+ actionType === 'MOVE_BOARD' &&
+ activeData.payloadType === 'IMAGE_DTO' &&
+ activeData.payload.imageDTO &&
+ overData.context.boardId
+ ) {
+ const { image_name } = activeData.payload.imageDTO;
+ const { boardId } = overData.context;
+ dispatch(
+ boardImagesApi.endpoints.addImageToBoard.initiate({
+ image_name,
+ board_id: boardId,
+ })
+ );
+ }
+
+ // add multiple images to board
+ // TODO: add endpoint
+ // if (
+ // actionType === 'ADD_TO_BATCH' &&
+ // activeData.payloadType === 'IMAGE_NAMES' &&
+ // activeData.payload.imageDTONames
+ // ) {
+ // dispatch(boardImagesApi.endpoints.addImagesToBoard.intiate({}));
+ // }
+ },
+ });
+};
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 24265faaa9..19af5b24c3 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,7 +1,7 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived, imageUpdated } from 'services/api/thunks/image';
-import { imageUpserted } from 'features/gallery/store/imagesSlice';
+import { imageUpserted } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts
new file mode 100644
index 0000000000..531981126a
--- /dev/null
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts
@@ -0,0 +1,40 @@
+import { startAppListening } from '..';
+import { log } from 'app/logging/useLogger';
+import {
+ imageDeletionConfirmed,
+ imageToDeleteSelected,
+ isModalOpenChanged,
+ selectImageUsage,
+} from 'features/imageDeletion/store/imageDeletionSlice';
+
+const moduleLog = log.child({ namespace: 'image' });
+
+export const addImageToDeleteSelectedListener = () => {
+ startAppListening({
+ actionCreator: imageToDeleteSelected,
+ effect: async (action, { dispatch, getState, condition }) => {
+ const imageDTO = action.payload;
+ const state = getState();
+ const { shouldConfirmOnDelete } = state.system;
+ const imageUsage = selectImageUsage(getState());
+
+ if (!imageUsage) {
+ // should never happen
+ return;
+ }
+
+ const isImageInUse =
+ imageUsage.isCanvasImage ||
+ imageUsage.isInitialImage ||
+ imageUsage.isControlNetImage ||
+ imageUsage.isNodesImage;
+
+ if (shouldConfirmOnDelete || isImageInUse) {
+ dispatch(isModalOpenChanged(true));
+ return;
+ }
+
+ dispatch(imageDeletionConfirmed({ imageDTO, imageUsage }));
+ },
+ });
+};
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 f55ed11c8f..0cd852c3de 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
@@ -2,11 +2,12 @@ import { startAppListening } from '..';
import { imageUploaded } from 'services/api/thunks/image';
import { addToast } from 'features/system/store/systemSlice';
import { log } from 'app/logging/useLogger';
-import { imageUpserted } from 'features/gallery/store/imagesSlice';
+import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
+import { imageAddedToBatch } from 'features/batch/store/batchSlice';
const moduleLog = log.child({ namespace: 'image' });
@@ -70,6 +71,11 @@ export const addImageUploadedFulfilledListener = () => {
dispatch(addToast({ title: 'Image Uploaded', status: 'success' }));
return;
}
+
+ if (postUploadAction?.type === 'ADD_TO_BATCH') {
+ dispatch(imageAddedToBatch(image.image_name));
+ return;
+ }
},
});
};
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 c663c64361..0d8aa3d7c9 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/api/thunks/image';
-import { imageUpdatedOne } from 'features/gallery/store/imagesSlice';
+import { imageUpdatedOne } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts
index 9aca82a32b..fe1a9bd806 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts
@@ -4,7 +4,7 @@ import { addToast } from 'features/system/store/systemSlice';
import { startAppListening } from '..';
import { initialImageSelected } from 'features/parameters/store/actions';
import { makeToast } from 'app/components/Toaster';
-import { selectImagesById } from 'features/gallery/store/imagesSlice';
+import { selectImagesById } from 'features/gallery/store/gallerySlice';
import { isImageDTO } from 'services/api/guards';
export const addInitialImageSelectedListener = () => {
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts
index e357d38dc3..3c11916be0 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/receivedPageOfImages.ts
@@ -2,6 +2,7 @@ import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { serializeError } from 'serialize-error';
import { receivedPageOfImages } from 'services/api/thunks/image';
+import { imagesApi } from 'services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'gallery' });
@@ -9,11 +10,17 @@ export const addReceivedPageOfImagesFulfilledListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
- const page = action.payload;
+ const { items } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
- `Received ${page.items.length} images`
+ `Received ${items.length} images`
);
+
+ items.forEach((image) => {
+ dispatch(
+ imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
+ );
+ });
},
});
};
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts
new file mode 100644
index 0000000000..dae72d92e7
--- /dev/null
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/selectionAddedToBatch.ts
@@ -0,0 +1,19 @@
+import { startAppListening } from '..';
+import { log } from 'app/logging/useLogger';
+import {
+ imagesAddedToBatch,
+ selectionAddedToBatch,
+} from 'features/batch/store/batchSlice';
+
+const moduleLog = log.child({ namespace: 'batch' });
+
+export const addSelectionAddedToBatchListener = () => {
+ startAppListening({
+ actionCreator: selectionAddedToBatch,
+ effect: (action, { dispatch, getState }) => {
+ const { selection } = getState().gallery;
+
+ dispatch(imagesAddedToBatch(selection));
+ },
+ });
+};
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts
index 976c1558d0..cab4738373 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketConnected.ts
@@ -14,11 +14,11 @@ export const addSocketConnectedEventListener = () => {
moduleLog.debug({ timestamp }, 'Connected');
- const { nodes, config, images } = getState();
+ const { nodes, config, gallery } = getState();
const { disabledTabs } = config;
- if (!images.ids.length) {
+ if (!gallery.ids.length) {
dispatch(
receivedPageOfImages({
categories: ['general'],
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts
index bc2c1d1c27..36840e5de1 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/stagingAreaImageSaved.ts
@@ -2,7 +2,7 @@ import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger';
import { imageUpdated } from 'services/api/thunks/image';
-import { imageUpserted } from 'features/gallery/store/imagesSlice';
+import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { addToast } from 'features/system/store/systemSlice';
const moduleLog = log.child({ namespace: 'canvas' });
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
index 670d762d24..490d99290d 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateImageUrlsOnConnect.ts
@@ -8,7 +8,7 @@ import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
import { forEach, uniqBy } from 'lodash-es';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger';
-import { selectImagesEntities } from 'features/gallery/store/imagesSlice';
+import { selectImagesEntities } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'images' });
@@ -36,7 +36,7 @@ const selectAllUsedImages = createSelector(
nodes.nodes.forEach((node) => {
forEach(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
- allUsedImages.push(input.value);
+ allUsedImages.push(input.value.image_name);
}
});
});
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index e92a422d68..2fd071bd23 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -11,18 +11,18 @@ import { rememberEnhancer, rememberReducer } from 'redux-remember';
import canvasReducer from 'features/canvas/store/canvasSlice';
import controlNetReducer from 'features/controlNet/store/controlNetSlice';
import galleryReducer from 'features/gallery/store/gallerySlice';
-import imagesReducer from 'features/gallery/store/imagesSlice';
import lightboxReducer from 'features/lightbox/store/lightboxSlice';
import generationReducer from 'features/parameters/store/generationSlice';
import postprocessingReducer from 'features/parameters/store/postprocessingSlice';
import systemReducer from 'features/system/store/systemSlice';
-// import sessionReducer from 'features/system/store/sessionSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import boardsReducer from 'features/gallery/store/boardSlice';
import configReducer from 'features/system/store/configSlice';
import hotkeysReducer from 'features/ui/store/hotkeysSlice';
import uiReducer from 'features/ui/store/uiSlice';
import dynamicPromptsReducer from 'features/dynamicPrompts/store/slice';
+import batchReducer from 'features/batch/store/batchSlice';
+import imageDeletionReducer from 'features/imageDeletion/store/imageDeletionSlice';
import { listenerMiddleware } from './middleware/listenerMiddleware';
@@ -45,11 +45,11 @@ const allReducers = {
config: configReducer,
ui: uiReducer,
hotkeys: hotkeysReducer,
- images: imagesReducer,
controlNet: controlNetReducer,
boards: boardsReducer,
- // session: sessionReducer,
dynamicPrompts: dynamicPromptsReducer,
+ batch: batchReducer,
+ imageDeletion: imageDeletionReducer,
[api.reducerPath]: api.reducer,
};
@@ -68,6 +68,7 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
'ui',
'controlNet',
'dynamicPrompts',
+ 'batch',
// 'boards',
// 'hotkeys',
// 'config',
diff --git a/invokeai/frontend/web/src/common/components/IAIButton.tsx b/invokeai/frontend/web/src/common/components/IAIButton.tsx
index 3efae76d1e..d1e77537cc 100644
--- a/invokeai/frontend/web/src/common/components/IAIButton.tsx
+++ b/invokeai/frontend/web/src/common/components/IAIButton.tsx
@@ -15,10 +15,25 @@ export interface IAIButtonProps extends ButtonProps {
}
const IAIButton = forwardRef((props: IAIButtonProps, forwardedRef) => {
- const { children, tooltip = '', tooltipProps, isChecked, ...rest } = props;
+ const {
+ children,
+ tooltip = '',
+ tooltipProps: { placement = 'top', hasArrow = true, ...tooltipProps } = {},
+ isChecked,
+ ...rest
+ } = props;
return (
-
-
diff --git a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx
index 4cff351aee..a07071ee79 100644
--- a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx
+++ b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx
@@ -1,73 +1,82 @@
import {
As,
+ ChakraProps,
Flex,
- FlexProps,
Icon,
- IconProps,
+ Skeleton,
Spinner,
- SpinnerProps,
- useColorMode,
+ StyleProps,
+ Text,
} from '@chakra-ui/react';
import { FaImage } from 'react-icons/fa';
-import { mode } from 'theme/util/mode';
+import { ImageDTO } from 'services/api/types';
-type Props = FlexProps & {
- spinnerProps?: SpinnerProps;
-};
+type Props = { image: ImageDTO | undefined };
+
+export const IAILoadingImageFallback = (props: Props) => {
+ if (props.image) {
+ return (
+
+ );
+ }
-export const IAIImageLoadingFallback = (props: Props) => {
- const { spinnerProps, ...rest } = props;
- const { sx, ...restFlexProps } = rest;
- const { colorMode } = useColorMode();
return (
-
+
);
};
type IAINoImageFallbackProps = {
- flexProps?: FlexProps;
- iconProps?: IconProps;
- as?: As;
+ label?: string;
+ icon?: As;
+ boxSize?: StyleProps['boxSize'];
+ sx?: ChakraProps['sx'];
};
-export const IAINoImageFallback = (props: IAINoImageFallbackProps) => {
- const { sx: flexSx, ...restFlexProps } = props.flexProps ?? { sx: {} };
- const { sx: iconSx, ...restIconProps } = props.iconProps ?? { sx: {} };
- const { colorMode } = useColorMode();
+export const IAINoContentFallback = (props: IAINoImageFallbackProps) => {
+ const { icon = FaImage, boxSize = 16 } = props;
return (
-
+
+ {props.label && {props.label}}
);
};
diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts
index d410c3917c..c75041eb6c 100644
--- a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts
+++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts
@@ -1,4 +1,5 @@
import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { validateSeedWeights } from 'common/util/seedWeightPairs';
@@ -7,17 +8,26 @@ import { systemSelector } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
const readinessSelector = createSelector(
- [generationSelector, systemSelector, activeTabNameSelector],
- (generation, system, activeTabName) => {
+ [stateSelector, activeTabNameSelector],
+ ({ generation, system, batch }, activeTabName) => {
const { shouldGenerateVariations, seedWeights, initialImage, seed } =
generation;
const { isProcessing, isConnected } = system;
+ const {
+ isEnabled: isBatchEnabled,
+ asInitialImage,
+ imageNames: batchImageNames,
+ } = batch;
let isReady = true;
const reasonsWhyNotReady: string[] = [];
- if (activeTabName === 'img2img' && !initialImage) {
+ if (
+ activeTabName === 'img2img' &&
+ !initialImage &&
+ !(asInitialImage && batchImageNames.length > 1)
+ ) {
isReady = false;
reasonsWhyNotReady.push('No initial image selected');
}
diff --git a/invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx b/invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx
new file mode 100644
index 0000000000..4231c84bec
--- /dev/null
+++ b/invokeai/frontend/web/src/features/batch/components/BatchControlNet.tsx
@@ -0,0 +1,67 @@
+import {
+ Flex,
+ FormControl,
+ FormLabel,
+ Heading,
+ Spacer,
+ Switch,
+ Text,
+} from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import IAISwitch from 'common/components/IAISwitch';
+import { ControlNetConfig } from 'features/controlNet/store/controlNetSlice';
+import { ChangeEvent, memo, useCallback } from 'react';
+import { controlNetToggled } from '../store/batchSlice';
+
+type Props = {
+ controlNet: ControlNetConfig;
+};
+
+const selector = createSelector(
+ [stateSelector, (state, controlNetId: string) => controlNetId],
+ (state, controlNetId) => {
+ const isControlNetEnabled = state.batch.controlNets.includes(controlNetId);
+ return { isControlNetEnabled };
+ },
+ defaultSelectorOptions
+);
+
+const BatchControlNet = (props: Props) => {
+ const dispatch = useAppDispatch();
+ const { isControlNetEnabled } = useAppSelector((state) =>
+ selector(state, props.controlNet.controlNetId)
+ );
+ const { processorType, model } = props.controlNet;
+
+ const handleChangeAsControlNet = useCallback(() => {
+ dispatch(controlNetToggled(props.controlNet.controlNetId));
+ }, [dispatch, props.controlNet.controlNetId]);
+
+ return (
+
+
+
+
+ ControlNet
+
+
+
+
+
+
+ Model: {model}
+
+
+ Processor: {processorType}
+
+
+ );
+};
+
+export default memo(BatchControlNet);
diff --git a/invokeai/frontend/web/src/features/batch/components/BatchImage.tsx b/invokeai/frontend/web/src/features/batch/components/BatchImage.tsx
new file mode 100644
index 0000000000..822b1cf183
--- /dev/null
+++ b/invokeai/frontend/web/src/features/batch/components/BatchImage.tsx
@@ -0,0 +1,115 @@
+import { Box, Icon, Skeleton } from '@chakra-ui/react';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { FaExclamationCircle } from 'react-icons/fa';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import { MouseEvent, memo, useCallback, useMemo } from 'react';
+import {
+ batchImageRangeEndSelected,
+ batchImageSelected,
+ batchImageSelectionToggled,
+ imageRemovedFromBatch,
+} from 'features/batch/store/batchSlice';
+import IAIDndImage from 'common/components/IAIDndImage';
+import { createSelector } from '@reduxjs/toolkit';
+import { RootState, stateSelector } from 'app/store/store';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
+
+const isSelectedSelector = createSelector(
+ [stateSelector, (state: RootState, imageName: string) => imageName],
+ (state, imageName) => ({
+ selection: state.batch.selection,
+ isSelected: state.batch.selection.includes(imageName),
+ }),
+ defaultSelectorOptions
+);
+
+type BatchImageProps = {
+ imageName: string;
+};
+
+const BatchImage = (props: BatchImageProps) => {
+ const {
+ currentData: imageDTO,
+ isFetching,
+ isError,
+ isSuccess,
+ } = useGetImageDTOQuery(props.imageName);
+ const dispatch = useAppDispatch();
+
+ const { isSelected, selection } = useAppSelector((state) =>
+ isSelectedSelector(state, props.imageName)
+ );
+
+ const handleClickRemove = useCallback(() => {
+ dispatch(imageRemovedFromBatch(props.imageName));
+ }, [dispatch, props.imageName]);
+
+ const handleClick = useCallback(
+ (e: MouseEvent) => {
+ if (e.shiftKey) {
+ dispatch(batchImageRangeEndSelected(props.imageName));
+ } else if (e.ctrlKey || e.metaKey) {
+ dispatch(batchImageSelectionToggled(props.imageName));
+ } else {
+ dispatch(batchImageSelected(props.imageName));
+ }
+ },
+ [dispatch, props.imageName]
+ );
+
+ const draggableData = useMemo(() => {
+ if (selection.length > 1) {
+ return {
+ id: 'batch',
+ payloadType: 'IMAGE_NAMES',
+ payload: {
+ imageNames: selection,
+ },
+ };
+ }
+
+ if (imageDTO) {
+ return {
+ id: 'batch',
+ payloadType: 'IMAGE_DTO',
+ payload: { imageDTO },
+ };
+ }
+ }, [imageDTO, selection]);
+
+ if (isError) {
+ return ;
+ }
+
+ if (isFetching) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default memo(BatchImage);
diff --git a/invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx b/invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx
new file mode 100644
index 0000000000..09e6b8afd7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/batch/components/BatchImageContainer.tsx
@@ -0,0 +1,31 @@
+import { Box } from '@chakra-ui/react';
+import BatchImageGrid from './BatchImageGrid';
+import IAIDropOverlay from 'common/components/IAIDropOverlay';
+import {
+ AddToBatchDropData,
+ isValidDrop,
+ useDroppable,
+} from 'app/components/ImageDnd/typesafeDnd';
+
+const droppableData: AddToBatchDropData = {
+ id: 'batch',
+ actionType: 'ADD_TO_BATCH',
+};
+
+const BatchImageContainer = () => {
+ const { isOver, setNodeRef, active } = useDroppable({
+ id: 'batch-manager',
+ data: droppableData,
+ });
+
+ return (
+
+
+ {isValidDrop(droppableData, active) && (
+
+ )}
+
+ );
+};
+
+export default BatchImageContainer;
diff --git a/invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx b/invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx
new file mode 100644
index 0000000000..f61d27d4cf
--- /dev/null
+++ b/invokeai/frontend/web/src/features/batch/components/BatchImageGrid.tsx
@@ -0,0 +1,54 @@
+import { FaImages } from 'react-icons/fa';
+import { Grid, GridItem } from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppSelector } from 'app/store/storeHooks';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import BatchImage from './BatchImage';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
+
+const selector = createSelector(
+ stateSelector,
+ (state) => {
+ const imageNames = state.batch.imageNames.concat().reverse();
+
+ return { imageNames };
+ },
+ defaultSelectorOptions
+);
+
+const BatchImageGrid = () => {
+ const { imageNames } = useAppSelector(selector);
+
+ if (imageNames.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {imageNames.map((imageName) => (
+
+
+
+ ))}
+
+ );
+};
+
+export default BatchImageGrid;
diff --git a/invokeai/frontend/web/src/features/batch/components/BatchManager.tsx b/invokeai/frontend/web/src/features/batch/components/BatchManager.tsx
new file mode 100644
index 0000000000..d7855dd4e2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/batch/components/BatchManager.tsx
@@ -0,0 +1,103 @@
+import { Flex, Heading, Spacer } from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useCallback } from 'react';
+import IAISwitch from 'common/components/IAISwitch';
+import {
+ asInitialImageToggled,
+ batchReset,
+ isEnabledChanged,
+} from 'features/batch/store/batchSlice';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import IAIButton from 'common/components/IAIButton';
+import BatchImageContainer from './BatchImageGrid';
+import { map } from 'lodash-es';
+import BatchControlNet from './BatchControlNet';
+
+const selector = createSelector(
+ stateSelector,
+ (state) => {
+ const { controlNets } = state.controlNet;
+ const {
+ imageNames,
+ asInitialImage,
+ controlNets: batchControlNets,
+ isEnabled,
+ } = state.batch;
+
+ return {
+ imageCount: imageNames.length,
+ asInitialImage,
+ controlNets,
+ batchControlNets,
+ isEnabled,
+ };
+ },
+ defaultSelectorOptions
+);
+
+const BatchManager = () => {
+ const dispatch = useAppDispatch();
+ const { imageCount, isEnabled, controlNets, batchControlNets } =
+ useAppSelector(selector);
+
+ const handleResetBatch = useCallback(() => {
+ dispatch(batchReset());
+ }, [dispatch]);
+
+ const handleToggle = useCallback(() => {
+ dispatch(isEnabledChanged(!isEnabled));
+ }, [dispatch, isEnabled]);
+
+ const handleChangeAsInitialImage = useCallback(() => {
+ dispatch(asInitialImageToggled());
+ }, [dispatch]);
+
+ return (
+
+
+
+ {imageCount || 'No'} images
+
+
+ Reset
+
+
+
+ {map(controlNets, (controlNet) => {
+ return (
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default BatchManager;
diff --git a/invokeai/frontend/web/src/features/batch/store/batchSlice.ts b/invokeai/frontend/web/src/features/batch/store/batchSlice.ts
new file mode 100644
index 0000000000..6a96361d3f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/batch/store/batchSlice.ts
@@ -0,0 +1,142 @@
+import { PayloadAction, createAction, createSlice } from '@reduxjs/toolkit';
+import { uniq } from 'lodash-es';
+import { imageDeleted } from 'services/api/thunks/image';
+
+type BatchState = {
+ isEnabled: boolean;
+ imageNames: string[];
+ asInitialImage: boolean;
+ controlNets: string[];
+ selection: string[];
+};
+
+export const initialBatchState: BatchState = {
+ isEnabled: false,
+ imageNames: [],
+ asInitialImage: false,
+ controlNets: [],
+ selection: [],
+};
+
+const batch = createSlice({
+ name: 'batch',
+ initialState: initialBatchState,
+ reducers: {
+ isEnabledChanged: (state, action: PayloadAction) => {
+ state.isEnabled = action.payload;
+ },
+ imageAddedToBatch: (state, action: PayloadAction) => {
+ state.imageNames = uniq(state.imageNames.concat(action.payload));
+ },
+ imagesAddedToBatch: (state, action: PayloadAction) => {
+ state.imageNames = uniq(state.imageNames.concat(action.payload));
+ },
+ imageRemovedFromBatch: (state, action: PayloadAction) => {
+ state.imageNames = state.imageNames.filter(
+ (imageName) => action.payload !== imageName
+ );
+ state.selection = state.selection.filter(
+ (imageName) => action.payload !== imageName
+ );
+ },
+ imagesRemovedFromBatch: (state, action: PayloadAction) => {
+ state.imageNames = state.imageNames.filter(
+ (imageName) => !action.payload.includes(imageName)
+ );
+ state.selection = state.selection.filter(
+ (imageName) => !action.payload.includes(imageName)
+ );
+ },
+ batchImageRangeEndSelected: (state, action: PayloadAction) => {
+ const rangeEndImageName = action.payload;
+ const lastSelectedImage = state.selection[state.selection.length - 1];
+ const lastClickedIndex = state.imageNames.findIndex(
+ (n) => n === lastSelectedImage
+ );
+ const currentClickedIndex = state.imageNames.findIndex(
+ (n) => n === rangeEndImageName
+ );
+ if (lastClickedIndex > -1 && currentClickedIndex > -1) {
+ // We have a valid range!
+ const start = Math.min(lastClickedIndex, currentClickedIndex);
+ const end = Math.max(lastClickedIndex, currentClickedIndex);
+
+ const imagesToSelect = state.imageNames.slice(start, end + 1);
+ state.selection = uniq(state.selection.concat(imagesToSelect));
+ }
+ },
+ batchImageSelectionToggled: (state, action: PayloadAction) => {
+ if (
+ state.selection.includes(action.payload) &&
+ state.selection.length > 1
+ ) {
+ state.selection = state.selection.filter(
+ (imageName) => imageName !== action.payload
+ );
+ } else {
+ state.selection = uniq(state.selection.concat(action.payload));
+ }
+ },
+ batchImageSelected: (state, action: PayloadAction) => {
+ state.selection = action.payload
+ ? [action.payload]
+ : [String(state.imageNames[0])];
+ },
+ batchReset: (state) => {
+ state.imageNames = [];
+ state.selection = [];
+ },
+ asInitialImageToggled: (state) => {
+ state.asInitialImage = !state.asInitialImage;
+ },
+ controlNetAddedToBatch: (state, action: PayloadAction) => {
+ state.controlNets = uniq(state.controlNets.concat(action.payload));
+ },
+ controlNetRemovedFromBatch: (state, action: PayloadAction) => {
+ state.controlNets = state.controlNets.filter(
+ (controlNetId) => controlNetId !== action.payload
+ );
+ },
+ controlNetToggled: (state, action: PayloadAction) => {
+ if (state.controlNets.includes(action.payload)) {
+ state.controlNets = state.controlNets.filter(
+ (controlNetId) => controlNetId !== action.payload
+ );
+ } else {
+ state.controlNets = uniq(state.controlNets.concat(action.payload));
+ }
+ },
+ },
+ extraReducers: (builder) => {
+ builder.addCase(imageDeleted.fulfilled, (state, action) => {
+ state.imageNames = state.imageNames.filter(
+ (imageName) => imageName !== action.meta.arg.image_name
+ );
+ state.selection = state.selection.filter(
+ (imageName) => imageName !== action.meta.arg.image_name
+ );
+ });
+ },
+});
+
+export const {
+ isEnabledChanged,
+ imageAddedToBatch,
+ imagesAddedToBatch,
+ imageRemovedFromBatch,
+ imagesRemovedFromBatch,
+ asInitialImageToggled,
+ controlNetAddedToBatch,
+ controlNetRemovedFromBatch,
+ batchReset,
+ controlNetToggled,
+ batchImageRangeEndSelected,
+ batchImageSelectionToggled,
+ batchImageSelected,
+} = batch.actions;
+
+export default batch.reducer;
+
+export const selectionAddedToBatch = createAction(
+ 'batch/selectionAddedToBatch'
+);
diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx
index 36d82dc2ee..df73f1141d 100644
--- a/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback, useState } from 'react';
+import { memo, useCallback, useMemo, useState } from 'react';
import { ImageDTO } from 'services/api/types';
import {
ControlNetConfig,
@@ -10,11 +10,16 @@ import { Box, Flex, SystemStyleObject } from '@chakra-ui/react';
import IAIDndImage from 'common/components/IAIDndImage';
import { createSelector } from '@reduxjs/toolkit';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
-import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
+import { IAILoadingImageFallback } from 'common/components/IAIImageFallback';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaUndo } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
+import {
+ TypesafeDraggableData,
+ TypesafeDroppableData,
+} from 'app/components/ImageDnd/typesafeDnd';
+import { PostUploadAction } from 'services/api/thunks/image';
const selector = createSelector(
controlNetSelector,
@@ -57,22 +62,6 @@ const ControlNetImagePreview = (props: Props) => {
isSuccess: isSuccessProcessedControlImage,
} = useGetImageDTOQuery(processedControlImageName ?? skipToken);
- const handleDrop = useCallback(
- (droppedImage: ImageDTO) => {
- if (controlImageName === droppedImage.image_name) {
- return;
- }
- setIsMouseOverImage(false);
- dispatch(
- controlNetImageChanged({
- controlNetId,
- controlImage: droppedImage.image_name,
- })
- );
- },
- [controlImageName, controlNetId, dispatch]
- );
-
const handleResetControlImage = useCallback(() => {
dispatch(controlNetImageChanged({ controlNetId, controlImage: null }));
}, [controlNetId, dispatch]);
@@ -84,6 +73,31 @@ const ControlNetImagePreview = (props: Props) => {
setIsMouseOverImage(false);
}, []);
+ const draggableData = useMemo(() => {
+ if (controlImage) {
+ return {
+ id: controlNetId,
+ payloadType: 'IMAGE_DTO',
+ payload: { imageDTO: controlImage },
+ };
+ }
+ }, [controlImage, controlNetId]);
+
+ const droppableData = useMemo(() => {
+ if (controlNetId) {
+ return {
+ id: controlNetId,
+ actionType: 'SET_CONTROLNET_IMAGE',
+ context: { controlNetId },
+ };
+ }
+ }, [controlNetId]);
+
+ const postUploadAction = useMemo(
+ () => ({ type: 'SET_CONTROLNET_IMAGE', controlNetId }),
+ [controlNetId]
+ );
+
const shouldShowProcessedImage =
controlImage &&
processedControlImage &&
@@ -104,14 +118,14 @@ const ControlNetImagePreview = (props: Props) => {
}}
>
{
}}
>
{pendingControlImages.includes(controlNetId) && (
@@ -145,27 +158,12 @@ const ControlNetImagePreview = (props: Props) => {
insetInlineStart: 0,
w: 'full',
h: 'full',
+ objectFit: 'contain',
}}
>
-
+
)}
- {controlImage && (
-
- }
- variant="link"
- sx={{
- p: 2,
- color: 'base.50',
- }}
- />
-
- )}
);
};
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx
index 858329ead6..918e9390f9 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/AllImagesBoard.tsx
@@ -1,16 +1,16 @@
-import { Flex, Text, useColorMode } from '@chakra-ui/react';
+import { Flex, useColorMode } from '@chakra-ui/react';
import { FaImages } from 'react-icons/fa';
-import { boardIdSelected } from '../../store/boardSlice';
+import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useDispatch } from 'react-redux';
-import { IAINoImageFallback } from 'common/components/IAIImageFallback';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AnimatePresence } from 'framer-motion';
-import { SelectedItemOverlay } from '../SelectedItemOverlay';
-import { useCallback } from 'react';
-import { ImageDTO } from 'services/api/types';
-import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
-import { useDroppable } from '@dnd-kit/core';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { mode } from 'theme/util/mode';
+import {
+ MoveBoardDropData,
+ isValidDrop,
+ useDroppable,
+} from 'app/components/ImageDnd/typesafeDnd';
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
@@ -20,31 +20,15 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
dispatch(boardIdSelected());
};
- const [removeImageFromBoard, { isLoading }] =
- useRemoveImageFromBoardMutation();
+ const droppableData: MoveBoardDropData = {
+ id: 'all-images-board',
+ actionType: 'MOVE_BOARD',
+ context: { boardId: null },
+ };
- const handleDrop = useCallback(
- (droppedImage: ImageDTO) => {
- if (!droppedImage.board_id) {
- return;
- }
- removeImageFromBoard({
- board_id: droppedImage.board_id,
- image_name: droppedImage.image_name,
- });
- },
- [removeImageFromBoard]
- );
-
- const {
- isOver,
- setNodeRef,
- active: isDropActive,
- } = useDroppable({
+ const { isOver, setNodeRef, active } = useDroppable({
id: `board_droppable_all_images`,
- data: {
- handleDrop,
- },
+ data: droppableData,
});
return (
@@ -58,10 +42,10 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
h: 'full',
borderRadius: 'base',
}}
- onClick={handleAllImagesBoardClick}
>
{
borderRadius: 'base',
w: 'full',
aspectRatio: '1/1',
+ overflow: 'hidden',
+ shadow: isSelected ? 'selected.light' : undefined,
+ _dark: { shadow: isSelected ? 'selected.dark' : undefined },
+ flexShrink: 0,
}}
>
-
+
- {isSelected && }
-
-
- {isDropActive && }
+ {isValidDrop(droppableData, active) && (
+
+ )}
- {
}}
>
All Images
-
+
);
};
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx
index fb095b9f42..5618c5c5c2 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList.tsx
@@ -2,6 +2,7 @@ import {
Collapse,
Flex,
Grid,
+ GridItem,
IconButton,
Input,
InputGroup,
@@ -10,10 +11,7 @@ import {
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
-import {
- boardsSelector,
- setBoardSearchText,
-} from 'features/gallery/store/boardSlice';
+import { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { memo, useState } from 'react';
import HoverableBoard from './HoverableBoard';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
@@ -21,11 +19,13 @@ import AddBoardButton from './AddBoardButton';
import AllImagesBoard from './AllImagesBoard';
import { CloseIcon } from '@chakra-ui/icons';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
+import { stateSelector } from 'app/store/store';
const selector = createSelector(
- [boardsSelector],
- (boardsState) => {
- const { selectedBoardId, searchText } = boardsState;
+ [stateSelector],
+ ({ boards, gallery }) => {
+ const { searchText } = boards;
+ const { selectedBoardId } = gallery;
return { selectedBoardId, searchText };
},
defaultSelectorOptions
@@ -109,20 +109,24 @@ const BoardsList = (props: Props) => {
- {!searchMode && }
+ {!searchMode && (
+
+
+
+ )}
{filteredBoards &&
filteredBoards.map((board) => (
-
+
+
+
))}
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx
index 118484f305..035ee77f18 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx
@@ -15,10 +15,9 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { memo, useCallback, useContext } from 'react';
import { FaFolder, FaTrash } from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu';
-import { BoardDTO, ImageDTO } from 'services/api/types';
-import { IAINoImageFallback } from 'common/components/IAIImageFallback';
-import { boardIdSelected } from 'features/gallery/store/boardSlice';
-import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages';
+import { BoardDTO } from 'services/api/types';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
+import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import {
useDeleteBoardMutation,
useUpdateBoardMutation,
@@ -26,12 +25,15 @@ import {
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
-import { useDroppable } from '@dnd-kit/core';
import { AnimatePresence } from 'framer-motion';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
-import { SelectedItemOverlay } from '../SelectedItemOverlay';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
import { mode } from 'theme/util/mode';
+import {
+ MoveBoardDropData,
+ isValidDrop,
+ useDroppable,
+} from 'app/components/ImageDnd/typesafeDnd';
interface HoverableBoardProps {
board: BoardDTO;
@@ -61,9 +63,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
const [deleteBoard, { isLoading: isDeleteBoardLoading }] =
useDeleteBoardMutation();
- const [addImageToBoard, { isLoading: isAddImageToBoardLoading }] =
- useAddImageToBoardMutation();
-
const handleUpdateBoardName = (newBoardName: string) => {
updateBoard({ board_id, changes: { board_name: newBoardName } });
};
@@ -77,29 +76,19 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]);
- const handleDrop = useCallback(
- (droppedImage: ImageDTO) => {
- if (droppedImage.board_id === board_id) {
- return;
- }
- addImageToBoard({ board_id, image_name: droppedImage.image_name });
- },
- [addImageToBoard, board_id]
- );
+ const droppableData: MoveBoardDropData = {
+ id: board_id,
+ actionType: 'MOVE_BOARD',
+ context: { boardId: board_id },
+ };
- const {
- isOver,
- setNodeRef,
- active: isDropActive,
- } = useDroppable({
+ const { isOver, setNodeRef, active } = useDroppable({
id: `board_droppable_${board_id}`,
- data: {
- handleDrop,
- },
+ data: droppableData,
});
return (
-
+
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
@@ -148,13 +137,25 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
w: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
+ shadow: isSelected ? 'selected.light' : undefined,
+ _dark: { shadow: isSelected ? 'selected.dark' : undefined },
+ flexShrink: 0,
}}
>
{board.cover_image_name && coverImage?.image_url && (
)}
{!(board.cover_image_name && coverImage?.image_url) && (
-
+
)}
{
{board.image_count}
- {isSelected && }
-
-
- {isDropActive && }
+ {isValidDrop(droppableData, active) && (
+
+ )}
-
+
{
}}
/>
-
+
)}
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx
index 169a965be0..b4a3296f04 100644
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageButtons.tsx
@@ -38,8 +38,7 @@ import {
FaShare,
FaShareAlt,
} from 'react-icons/fa';
-import { gallerySelector } from '../store/gallerySelectors';
-import { useCallback, useContext } from 'react';
+import { useCallback } from 'react';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
@@ -49,22 +48,15 @@ import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceR
import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings';
import { useAppToaster } from 'app/components/Toaster';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
-import { DeleteImageContext } from 'app/contexts/DeleteImageContext';
-import { DeleteImageButton } from './DeleteImageModal';
-import { selectImagesById } from '../store/imagesSlice';
-import { RootState } from 'app/store/store';
+import { stateSelector } from 'app/store/store';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import { skipToken } from '@reduxjs/toolkit/dist/query';
+import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
+import { DeleteImageButton } from 'features/imageDeletion/components/DeleteImageButton';
const currentImageButtonsSelector = createSelector(
- [
- (state: RootState) => state,
- systemSelector,
- gallerySelector,
- postprocessingSelector,
- uiSelector,
- lightboxSelector,
- activeTabNameSelector,
- ],
- (state, system, gallery, postprocessing, ui, lightbox, activeTabName) => {
+ [stateSelector, activeTabNameSelector],
+ ({ gallery, system, postprocessing, ui, lightbox }, activeTabName) => {
const {
isProcessing,
isConnected,
@@ -84,9 +76,7 @@ const currentImageButtonsSelector = createSelector(
shouldShowProgressInViewer,
} = ui;
- const imageDTO = selectImagesById(state, gallery.selectedImage ?? '');
-
- const { selectedImage } = gallery;
+ const lastSelectedImage = gallery.selection[gallery.selection.length - 1];
return {
canDeleteImage: isConnected && !isProcessing,
@@ -97,16 +87,13 @@ const currentImageButtonsSelector = createSelector(
isESRGANAvailable,
upscalingLevel,
facetoolStrength,
- shouldDisableToolbarButtons: Boolean(progressImage) || !selectedImage,
+ shouldDisableToolbarButtons: Boolean(progressImage) || !lastSelectedImage,
shouldShowImageDetails,
activeTabName,
isLightboxOpen,
shouldHidePreview,
- image: imageDTO,
- seed: imageDTO?.metadata?.seed,
- prompt: imageDTO?.metadata?.positive_conditioning,
- negativePrompt: imageDTO?.metadata?.negative_conditioning,
shouldShowProgressInViewer,
+ lastSelectedImage,
};
},
{
@@ -132,7 +119,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
isLightboxOpen,
activeTabName,
shouldHidePreview,
- image,
+ lastSelectedImage,
shouldShowProgressInViewer,
} = useAppSelector(currentImageButtonsSelector);
@@ -147,7 +134,9 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
- const { onDelete } = useContext(DeleteImageContext);
+ const { currentData: image } = useGetImageDTOQuery(
+ lastSelectedImage ?? skipToken
+ );
// const handleCopyImage = useCallback(async () => {
// if (!image?.url) {
@@ -248,8 +237,11 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
}, []);
const handleDelete = useCallback(() => {
- onDelete(image);
- }, [image, onDelete]);
+ if (!image) {
+ return;
+ }
+ dispatch(imageToDeleteSelected(image));
+ }, [dispatch, image]);
useHotkeys(
'Shift+U',
@@ -371,7 +363,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
}}
{...props}
>
-
+
{
}
isChecked={isLightboxOpen}
onClick={handleLightBox}
+ isDisabled={shouldDisableToolbarButtons}
/>
)}
-
+
}
tooltip={`${t('parameters.usePrompt')} (P)`}
@@ -478,7 +471,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
{(isUpscalingEnabled || isFaceRestoreEnabled) && (
-
+
{isFaceRestoreEnabled && (
{
)}
-
+
}
tooltip={`${t('parameters.info')} (I)`}
@@ -553,7 +549,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
/>
-
+
{
-
+
>
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx
index 2da5185fe5..1d8863f4d8 100644
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImageDisplay.tsx
@@ -1,29 +1,9 @@
import { Flex } from '@chakra-ui/react';
-import { createSelector } from '@reduxjs/toolkit';
-import { useAppSelector } from 'app/store/storeHooks';
-import { systemSelector } from 'features/system/store/systemSelectors';
-import { gallerySelector } from '../store/gallerySelectors';
import CurrentImageButtons from './CurrentImageButtons';
import CurrentImagePreview from './CurrentImagePreview';
-import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
-
-export const currentImageDisplaySelector = createSelector(
- [systemSelector, gallerySelector],
- (system, gallery) => {
- const { progressImage } = system;
-
- return {
- hasSelectedImage: Boolean(gallery.selectedImage),
- hasProgressImage: Boolean(progressImage),
- };
- },
- defaultSelectorOptions
-);
const CurrentImageDisplay = () => {
- const { hasSelectedImage } = useAppSelector(currentImageDisplaySelector);
-
return (
{
justifyContent: 'center',
}}
>
- {hasSelectedImage && }
+
);
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx
index fac19b347e..112129ffa2 100644
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImagePreview.tsx
@@ -1,35 +1,33 @@
import { Box, Flex, Image } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { uiSelector } from 'features/ui/store/uiSelectors';
+import { useAppSelector } from 'app/store/storeHooks';
import { isEqual } from 'lodash-es';
-
-import { gallerySelector } from '../store/gallerySelectors';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
import NextPrevImageButtons from './NextPrevImageButtons';
-import { memo, useCallback } from 'react';
-import { systemSelector } from 'features/system/store/systemSelectors';
-import { imageSelected } from '../store/gallerySlice';
+import { memo, useMemo } from 'react';
import IAIDndImage from 'common/components/IAIDndImage';
-import { ImageDTO } from 'services/api/types';
-import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
+import { stateSelector } from 'app/store/store';
+import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice';
+import {
+ TypesafeDraggableData,
+ TypesafeDroppableData,
+} from 'app/components/ImageDnd/typesafeDnd';
export const imagesSelector = createSelector(
- [uiSelector, gallerySelector, systemSelector],
- (ui, gallery, system) => {
+ [stateSelector, selectLastSelectedImage],
+ ({ ui, system }, lastSelectedImage) => {
const {
shouldShowImageDetails,
shouldHidePreview,
shouldShowProgressInViewer,
} = ui;
- const { selectedImage } = gallery;
const { progressImage, shouldAntialiasProgressImage } = system;
return {
shouldShowImageDetails,
shouldHidePreview,
- selectedImage,
+ imageName: lastSelectedImage,
progressImage,
shouldShowProgressInViewer,
shouldAntialiasProgressImage,
@@ -45,29 +43,35 @@ export const imagesSelector = createSelector(
const CurrentImagePreview = () => {
const {
shouldShowImageDetails,
- selectedImage,
+ imageName,
progressImage,
shouldShowProgressInViewer,
shouldAntialiasProgressImage,
} = useAppSelector(imagesSelector);
const {
- currentData: image,
+ currentData: imageDTO,
isLoading,
isError,
isSuccess,
- } = useGetImageDTOQuery(selectedImage ?? skipToken);
+ } = useGetImageDTOQuery(imageName ?? skipToken);
- const dispatch = useAppDispatch();
+ const draggableData = useMemo(() => {
+ if (imageDTO) {
+ return {
+ id: 'current-image',
+ payloadType: 'IMAGE_DTO',
+ payload: { imageDTO },
+ };
+ }
+ }, [imageDTO]);
- const handleDrop = useCallback(
- (droppedImage: ImageDTO) => {
- if (droppedImage.image_name === image?.image_name) {
- return;
- }
- dispatch(imageSelected(droppedImage.image_name));
- },
- [dispatch, image?.image_name]
+ const droppableData = useMemo(
+ () => ({
+ id: 'current-image',
+ actionType: 'SET_CURRENT_IMAGE',
+ }),
+ []
);
return (
@@ -98,14 +102,15 @@ const CurrentImagePreview = () => {
/>
) : (
}
+ imageDTO={imageDTO}
+ droppableData={droppableData}
+ draggableData={draggableData}
isUploadDisabled={true}
fitContainer
+ dropLabel="Set as Current Image"
/>
)}
- {shouldShowImageDetails && image && (
+ {shouldShowImageDetails && imageDTO && (
{
overflow: 'scroll',
}}
>
-
+
)}
- {!shouldShowImageDetails && image && (
+ {!shouldShowImageDetails && imageDTO && (
{
- const { shouldConfirmOnDelete } = system;
- const { canRestoreDeletedImagesFromBin } = config;
-
- return {
- shouldConfirmOnDelete,
- canRestoreDeletedImagesFromBin,
- };
- },
- defaultSelectorOptions
-);
-
-const ImageInUseMessage = (props: { imageUsage?: ImageUsage }) => {
- const { imageUsage } = props;
-
- 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 handleChangeShouldConfirmOnDelete = useCallback(
- (e: ChangeEvent) =>
- dispatch(setShouldConfirmOnDelete(!e.target.checked)),
- [dispatch]
- );
-
- const cancelRef = useRef(null);
-
- return (
-
-
-
-
- {t('gallery.deleteImage')}
-
-
-
-
-
-
-
- {canRestoreDeletedImagesFromBin
- ? t('gallery.deleteImageBin')
- : t('gallery.deleteImagePermanent')}
-
- {t('common.areYouSure')}
-
-
-
-
-
- Cancel
-
-
- Delete
-
-
-
-
-
- );
-};
-
-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/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx
new file mode 100644
index 0000000000..30e1c5abf3
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImage.tsx
@@ -0,0 +1,131 @@
+import { Box } from '@chakra-ui/react';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { MouseEvent, memo, useCallback, useMemo } from 'react';
+import { FaTrash } from 'react-icons/fa';
+import { useTranslation } from 'react-i18next';
+import { createSelector } from '@reduxjs/toolkit';
+import { ImageDTO } from 'services/api/types';
+import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
+import { stateSelector } from 'app/store/store';
+import ImageContextMenu from './ImageContextMenu';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import IAIDndImage from 'common/components/IAIDndImage';
+import {
+ imageRangeEndSelected,
+ imageSelected,
+ imageSelectionToggled,
+} from '../store/gallerySlice';
+import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
+
+export const selector = createSelector(
+ [stateSelector, (state, { image_name }: ImageDTO) => image_name],
+ ({ gallery }, image_name) => {
+ const isSelected = gallery.selection.includes(image_name);
+ const selection = gallery.selection;
+ return {
+ isSelected,
+ selection,
+ };
+ },
+ defaultSelectorOptions
+);
+
+interface HoverableImageProps {
+ imageDTO: ImageDTO;
+}
+
+/**
+ * Gallery image component with delete/use all/use seed buttons on hover.
+ */
+const GalleryImage = (props: HoverableImageProps) => {
+ const { isSelected, selection } = useAppSelector((state) =>
+ selector(state, props.imageDTO)
+ );
+
+ const { imageDTO } = props;
+ const { image_url, thumbnail_url, image_name } = imageDTO;
+
+ const dispatch = useAppDispatch();
+
+ const { t } = useTranslation();
+
+ const handleClick = useCallback(
+ (e: MouseEvent) => {
+ if (e.shiftKey) {
+ dispatch(imageRangeEndSelected(props.imageDTO.image_name));
+ } else if (e.ctrlKey || e.metaKey) {
+ dispatch(imageSelectionToggled(props.imageDTO.image_name));
+ } else {
+ dispatch(imageSelected(props.imageDTO.image_name));
+ }
+ },
+ [dispatch, props.imageDTO.image_name]
+ );
+
+ const handleDelete = useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation();
+ if (!imageDTO) {
+ return;
+ }
+ dispatch(imageToDeleteSelected(imageDTO));
+ },
+ [dispatch, imageDTO]
+ );
+
+ const draggableData = useMemo(() => {
+ if (selection.length > 1) {
+ return {
+ id: 'gallery-image',
+ payloadType: 'IMAGE_NAMES',
+ payload: { imageNames: selection },
+ };
+ }
+
+ if (imageDTO) {
+ return {
+ id: 'gallery-image',
+ payloadType: 'IMAGE_DTO',
+ payload: { imageDTO },
+ };
+ }
+ }, [imageDTO, selection]);
+
+ return (
+
+
+ {(ref) => (
+
+ }
+ resetTooltip="Delete image"
+ imageSx={{ w: 'full', h: 'full' }}
+ withResetIcon
+ isDropDisabled={true}
+ isUploadDisabled={true}
+ />
+
+ )}
+
+
+ );
+};
+
+export default memo(GalleryImage);
diff --git a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx b/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx
deleted file mode 100644
index 91648d8df0..0000000000
--- a/invokeai/frontend/web/src/features/gallery/components/HoverableImage.tsx
+++ /dev/null
@@ -1,371 +0,0 @@
-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 { memo, useCallback, useContext, useState } from 'react';
-import {
- FaCheck,
- FaExpand,
- FaFolder,
- FaImage,
- FaShare,
- FaTrash,
-} from 'react-icons/fa';
-import { ContextMenu } from 'chakra-ui-contextmenu';
-import {
- resizeAndScaleCanvas,
- setInitialCanvasImage,
-} from 'features/canvas/store/canvasSlice';
-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 { ExternalLinkIcon } from '@chakra-ui/icons';
-import { IoArrowUndoCircleOutline } from 'react-icons/io5';
-import { createSelector } from '@reduxjs/toolkit';
-import { systemSelector } from 'features/system/store/systemSelectors';
-import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
-import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
-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 { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
-import { useAppToaster } from 'app/components/Toaster';
-import { ImageDTO } from 'services/api/types';
-import { useDraggable } from '@dnd-kit/core';
-import { DeleteImageContext } from 'app/contexts/DeleteImageContext';
-import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext';
-import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
-
-export const selector = createSelector(
- [gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector],
- (gallery, system, lightbox, activeTabName) => {
- const {
- galleryImageObjectFit,
- galleryImageMinimumWidth,
- shouldUseSingleGalleryColumn,
- } = gallery;
-
- const { isLightboxOpen } = lightbox;
- const { isConnected, isProcessing, shouldConfirmOnDelete } = system;
-
- return {
- canDeleteImage: isConnected && !isProcessing,
- shouldConfirmOnDelete,
- galleryImageObjectFit,
- galleryImageMinimumWidth,
- shouldUseSingleGalleryColumn,
- activeTabName,
- isLightboxOpen,
- };
- },
- {
- memoizeOptions: {
- resultEqualityCheck: isEqual,
- },
- }
-);
-
-interface HoverableImageProps {
- image: ImageDTO;
- isSelected: boolean;
-}
-
-/**
- * Gallery image component with delete/use all/use seed buttons on hover.
- */
-const HoverableImage = (props: HoverableImageProps) => {
- const dispatch = useAppDispatch();
- const {
- activeTabName,
- galleryImageObjectFit,
- galleryImageMinimumWidth,
- canDeleteImage,
- shouldUseSingleGalleryColumn,
- } = useAppSelector(selector);
-
- const { image, isSelected } = props;
- const { image_url, thumbnail_url, image_name } = image;
-
- 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 { onClickAddToBoard } = useContext(AddImageToBoardContext);
- const handleDelete = useCallback(() => {
- onDelete(image);
- }, [image, onDelete]);
- const { recallBothPrompts, recallSeed, recallAllParameters } =
- useRecallParameters();
-
- const { attributes, listeners, setNodeRef } = useDraggable({
- id: `galleryImage_${image_name}`,
- data: {
- image,
- },
- });
-
- const [removeFromBoard] = useRemoveImageFromBoardMutation();
-
- const handleMouseOver = () => setIsHovered(true);
- const handleMouseOut = () => setIsHovered(false);
-
- const handleSelectImage = useCallback(() => {
- dispatch(imageSelected(image.image_name));
- }, [image, dispatch]);
-
- // Recall parameters handlers
- const handleRecallPrompt = useCallback(() => {
- recallBothPrompts(
- image.metadata?.positive_conditioning,
- image.metadata?.negative_conditioning
- );
- }, [
- image.metadata?.negative_conditioning,
- image.metadata?.positive_conditioning,
- recallBothPrompts,
- ]);
-
- const handleRecallSeed = useCallback(() => {
- recallSeed(image.metadata?.seed);
- }, [image, recallSeed]);
-
- const handleSendToImageToImage = useCallback(() => {
- dispatch(sentImageToImg2Img());
- dispatch(initialImageSelected(image));
- }, [dispatch, image]);
-
- // const handleRecallInitialImage = useCallback(() => {
- // recallInitialImage(image.metadata.invokeai?.node?.image);
- // }, [image, recallInitialImage]);
-
- /**
- * TODO: the rest of these
- */
- const handleSendToCanvas = () => {
- dispatch(sentImageToCanvas());
- dispatch(setInitialCanvasImage(image));
-
- dispatch(resizeAndScaleCanvas());
-
- if (activeTabName !== 'unifiedCanvas') {
- dispatch(setActiveTab('unifiedCanvas'));
- }
-
- toaster({
- title: t('toast.sentToUnifiedCanvas'),
- status: 'success',
- duration: 2500,
- isClosable: true,
- });
- };
-
- const handleUseAllParameters = useCallback(() => {
- recallAllParameters(image);
- }, [image, recallAllParameters]);
-
- const handleLightBox = () => {
- // dispatch(setCurrentImage(image));
- // dispatch(setIsLightboxOpen(true));
- };
-
- const handleAddToBoard = useCallback(() => {
- onClickAddToBoard(image);
- }, [image, onClickAddToBoard]);
-
- const handleRemoveFromBoard = useCallback(() => {
- if (!image.board_id) {
- return;
- }
- removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
- }, [image.board_id, image.image_name, removeFromBoard]);
-
- const handleOpenInNewTab = () => {
- window.open(image.image_url, '_blank');
- };
-
- return (
-
-
- menuProps={{ size: 'sm', isLazy: true }}
- renderMenu={() => (
-
- }
- onClickCapture={handleOpenInNewTab}
- >
- {t('common.openInNewTab')}
-
- {isLightboxEnabled && (
- } onClickCapture={handleLightBox}>
- {t('parameters.openInViewer')}
-
- )}
- }
- onClickCapture={handleRecallPrompt}
- isDisabled={image?.metadata?.positive_conditioning === undefined}
- >
- {t('parameters.usePrompt')}
-
-
- }
- onClickCapture={handleRecallSeed}
- isDisabled={image?.metadata?.seed === undefined}
- >
- {t('parameters.useSeed')}
-
- {/* }
- onClickCapture={handleRecallInitialImage}
- isDisabled={image?.metadata?.type !== 'img2img'}
- >
- {t('parameters.useInitImg')}
- */}
- }
- onClickCapture={handleUseAllParameters}
- isDisabled={
- // what should these be
- !['t2l', 'l2l', 'inpaint'].includes(
- String(image?.metadata?.type)
- )
- }
- >
- {t('parameters.useAll')}
-
- }
- onClickCapture={handleSendToImageToImage}
- id="send-to-img2img"
- >
- {t('parameters.sendToImg2Img')}
-
- {isCanvasEnabled && (
- }
- onClickCapture={handleSendToCanvas}
- id="send-to-canvas"
- >
- {t('parameters.sendToUnifiedCanvas')}
-
- )}
- } onClickCapture={handleAddToBoard}>
- {image.board_id ? 'Change Board' : 'Add to Board'}
-
- {image.board_id && (
- }
- onClickCapture={handleRemoveFromBoard}
- >
- Remove from Board
-
- )}
- }
- onClickCapture={handleDelete}
- >
- {t('gallery.deleteImage')}
-
-
- )}
- >
- {(ref) => (
-
- }
- sx={{
- width: '100%',
- height: '100%',
- maxWidth: '100%',
- maxHeight: '100%',
- }}
- />
- {isSelected && (
-
-
-
- )}
- {isHovered && galleryImageMinimumWidth >= 100 && (
-
- }
- size="xs"
- fontSize={14}
- isDisabled={!canDeleteImage}
- />
-
- )}
-
- )}
-
-
- );
-};
-
-export default memo(HoverableImage);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx
new file mode 100644
index 0000000000..48eff4c1b4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu.tsx
@@ -0,0 +1,278 @@
+import { MenuItem, MenuList } from '@chakra-ui/react';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { memo, useCallback, useContext } from 'react';
+import {
+ FaExpand,
+ FaFolder,
+ FaFolderPlus,
+ FaShare,
+ FaTrash,
+} from 'react-icons/fa';
+import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
+import {
+ resizeAndScaleCanvas,
+ setInitialCanvasImage,
+} from 'features/canvas/store/canvasSlice';
+import { setActiveTab } from 'features/ui/store/uiSlice';
+import { useTranslation } from 'react-i18next';
+import { ExternalLinkIcon } from '@chakra-ui/icons';
+import { IoArrowUndoCircleOutline } from 'react-icons/io5';
+import { createSelector } from '@reduxjs/toolkit';
+import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
+import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
+import { initialImageSelected } from 'features/parameters/store/actions';
+import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
+import { useAppToaster } from 'app/components/Toaster';
+import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext';
+import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
+import { ImageDTO } from 'services/api/types';
+import { RootState, stateSelector } from 'app/store/store';
+import {
+ imagesAddedToBatch,
+ selectionAddedToBatch,
+} from 'features/batch/store/batchSlice';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
+
+const selector = createSelector(
+ [stateSelector, (state: RootState, imageDTO: ImageDTO) => imageDTO],
+ ({ gallery, batch }, imageDTO) => {
+ const selectionCount = gallery.selection.length;
+ const isInBatch = batch.imageNames.includes(imageDTO.image_name);
+
+ return { selectionCount, isInBatch };
+ },
+ defaultSelectorOptions
+);
+
+type Props = {
+ image: ImageDTO;
+ children: ContextMenuProps['children'];
+};
+
+const ImageContextMenu = ({ image, children }: Props) => {
+ const { selectionCount, isInBatch } = useAppSelector((state) =>
+ selector(state, image)
+ );
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+
+ const toaster = useAppToaster();
+
+ const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
+ const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
+
+ const { onClickAddToBoard } = useContext(AddImageToBoardContext);
+
+ const handleDelete = useCallback(() => {
+ if (!image) {
+ return;
+ }
+ dispatch(imageToDeleteSelected(image));
+ }, [dispatch, image]);
+
+ const { recallBothPrompts, recallSeed, recallAllParameters } =
+ useRecallParameters();
+
+ const [removeFromBoard] = useRemoveImageFromBoardMutation();
+
+ // Recall parameters handlers
+ const handleRecallPrompt = useCallback(() => {
+ recallBothPrompts(
+ image.metadata?.positive_conditioning,
+ image.metadata?.negative_conditioning
+ );
+ }, [
+ image.metadata?.negative_conditioning,
+ image.metadata?.positive_conditioning,
+ recallBothPrompts,
+ ]);
+
+ const handleRecallSeed = useCallback(() => {
+ recallSeed(image.metadata?.seed);
+ }, [image, recallSeed]);
+
+ const handleSendToImageToImage = useCallback(() => {
+ dispatch(sentImageToImg2Img());
+ dispatch(initialImageSelected(image));
+ }, [dispatch, image]);
+
+ // const handleRecallInitialImage = useCallback(() => {
+ // recallInitialImage(image.metadata.invokeai?.node?.image);
+ // }, [image, recallInitialImage]);
+
+ const handleSendToCanvas = () => {
+ dispatch(sentImageToCanvas());
+ dispatch(setInitialCanvasImage(image));
+ dispatch(resizeAndScaleCanvas());
+ dispatch(setActiveTab('unifiedCanvas'));
+
+ toaster({
+ title: t('toast.sentToUnifiedCanvas'),
+ status: 'success',
+ duration: 2500,
+ isClosable: true,
+ });
+ };
+
+ const handleUseAllParameters = useCallback(() => {
+ recallAllParameters(image);
+ }, [image, recallAllParameters]);
+
+ const handleLightBox = () => {
+ // dispatch(setCurrentImage(image));
+ // dispatch(setIsLightboxOpen(true));
+ };
+
+ const handleAddToBoard = useCallback(() => {
+ onClickAddToBoard(image);
+ }, [image, onClickAddToBoard]);
+
+ const handleRemoveFromBoard = useCallback(() => {
+ if (!image.board_id) {
+ return;
+ }
+ removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
+ }, [image.board_id, image.image_name, removeFromBoard]);
+
+ const handleOpenInNewTab = () => {
+ window.open(image.image_url, '_blank');
+ };
+
+ const handleAddSelectionToBatch = useCallback(() => {
+ dispatch(selectionAddedToBatch());
+ }, [dispatch]);
+
+ const handleAddToBatch = useCallback(() => {
+ dispatch(imagesAddedToBatch([image.image_name]));
+ }, [dispatch, image.image_name]);
+
+ return (
+
+ menuProps={{ size: 'sm', isLazy: true }}
+ renderMenu={() => (
+
+ {selectionCount === 1 ? (
+ <>
+ }
+ onClickCapture={handleOpenInNewTab}
+ >
+ {t('common.openInNewTab')}
+
+ {isLightboxEnabled && (
+ } onClickCapture={handleLightBox}>
+ {t('parameters.openInViewer')}
+
+ )}
+ }
+ onClickCapture={handleRecallPrompt}
+ isDisabled={
+ image?.metadata?.positive_conditioning === undefined
+ }
+ >
+ {t('parameters.usePrompt')}
+
+
+ }
+ onClickCapture={handleRecallSeed}
+ isDisabled={image?.metadata?.seed === undefined}
+ >
+ {t('parameters.useSeed')}
+
+ {/* }
+ onClickCapture={handleRecallInitialImage}
+ isDisabled={image?.metadata?.type !== 'img2img'}
+ >
+ {t('parameters.useInitImg')}
+ */}
+ }
+ onClickCapture={handleUseAllParameters}
+ isDisabled={
+ // what should these be
+ !['t2l', 'l2l', 'inpaint'].includes(
+ String(image?.metadata?.type)
+ )
+ }
+ >
+ {t('parameters.useAll')}
+
+ }
+ onClickCapture={handleSendToImageToImage}
+ id="send-to-img2img"
+ >
+ {t('parameters.sendToImg2Img')}
+
+ {isCanvasEnabled && (
+ }
+ onClickCapture={handleSendToCanvas}
+ id="send-to-canvas"
+ >
+ {t('parameters.sendToUnifiedCanvas')}
+
+ )}
+ }
+ isDisabled={isInBatch}
+ onClickCapture={handleAddToBatch}
+ >
+ Add to Batch
+
+ } onClickCapture={handleAddToBoard}>
+ {image.board_id ? 'Change Board' : 'Add to Board'}
+
+ {image.board_id && (
+ }
+ onClickCapture={handleRemoveFromBoard}
+ >
+ Remove from Board
+
+ )}
+ }
+ onClickCapture={handleDelete}
+ >
+ {t('gallery.deleteImage')}
+
+ >
+ ) : (
+ <>
+ }
+ onClickCapture={handleAddToBoard}
+ >
+ Move Selection to Board
+
+ }
+ onClickCapture={handleAddSelectionToBatch}
+ >
+ Add Selection to Batch
+
+ }
+ onClickCapture={handleDelete}
+ >
+ Delete Selection
+
+ >
+ )}
+
+ )}
+ >
+ {children}
+
+ );
+};
+
+export default memo(ImageContextMenu);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx
index 5052782266..33edb303e3 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx
@@ -5,7 +5,6 @@ import {
Flex,
FlexProps,
Grid,
- Icon,
Skeleton,
Text,
VStack,
@@ -19,12 +18,8 @@ import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISlider from 'common/components/IAISlider';
-import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import {
setGalleryImageMinimumWidth,
- setGalleryImageObjectFit,
- setShouldAutoSwitchToNewImages,
- setShouldUseSingleGalleryColumn,
setGalleryView,
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
@@ -43,46 +38,45 @@ import {
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaServer, FaWrench } from 'react-icons/fa';
-import { MdPhotoLibrary } from 'react-icons/md';
-import HoverableImage from './HoverableImage';
+import GalleryImage from './GalleryImage';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { createSelector } from '@reduxjs/toolkit';
-import { RootState } from 'app/store/store';
-import { Virtuoso, VirtuosoGrid } from 'react-virtuoso';
+import { RootState, stateSelector } from 'app/store/store';
+import { VirtuosoGrid } from 'react-virtuoso';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
-import { uiSelector } from 'features/ui/store/uiSelectors';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
imageCategoriesChanged,
- selectImagesAll,
-} from '../store/imagesSlice';
+ shouldAutoSwitchChanged,
+ selectFilteredImages,
+} from 'features/gallery/store/gallerySlice';
import { receivedPageOfImages } from 'services/api/thunks/image';
import BoardsList from './Boards/BoardsList';
-import { boardsSelector } from '../store/boardSlice';
import { ChevronUpIcon } from '@chakra-ui/icons';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode';
import { ImageDTO } from 'services/api/types';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
const LOADING_IMAGE_ARRAY = Array(20).fill('loading');
-const itemSelector = createSelector(
- [(state: RootState) => state],
- (state) => {
- const { categories, total: allImagesTotal, isLoading } = state.images;
- const { selectedBoardId } = state.boards;
+const selector = createSelector(
+ [stateSelector, selectFilteredImages],
+ (state, filteredImages) => {
+ const {
+ categories,
+ total: allImagesTotal,
+ isLoading,
+ selectedBoardId,
+ galleryImageMinimumWidth,
+ galleryView,
+ shouldAutoSwitch,
+ } = state.gallery;
+ const { shouldPinGallery } = state.ui;
- const allImages = selectImagesAll(state);
-
- const images = allImages.filter((i) => {
- const isInCategory = categories.includes(i.image_category);
- const isInSelectedBoard = selectedBoardId
- ? i.board_id === selectedBoardId
- : true;
- return isInCategory && isInSelectedBoard;
- }) as (ImageDTO | string)[];
+ const images = filteredImages as (ImageDTO | string)[];
return {
images: isLoading ? images.concat(LOADING_IMAGE_ARRAY) : images,
@@ -90,33 +84,10 @@ const itemSelector = createSelector(
isLoading,
categories,
selectedBoardId,
- };
- },
- defaultSelectorOptions
-);
-
-const mainSelector = createSelector(
- [gallerySelector, uiSelector, boardsSelector],
- (gallery, ui, boards) => {
- const {
- galleryImageMinimumWidth,
- galleryImageObjectFit,
- shouldAutoSwitchToNewImages,
- shouldUseSingleGalleryColumn,
- selectedImage,
- galleryView,
- } = gallery;
-
- const { shouldPinGallery } = ui;
- return {
shouldPinGallery,
galleryImageMinimumWidth,
- galleryImageObjectFit,
- shouldAutoSwitchToNewImages,
- shouldUseSingleGalleryColumn,
- selectedImage,
+ shouldAutoSwitch,
galleryView,
- selectedBoardId: boards.selectedBoardId,
};
},
defaultSelectorOptions
@@ -144,17 +115,16 @@ const ImageGalleryContent = () => {
const { colorMode } = useColorMode();
const {
+ images,
+ isLoading,
+ allImagesTotal,
+ categories,
+ selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
- galleryImageObjectFit,
- shouldAutoSwitchToNewImages,
- shouldUseSingleGalleryColumn,
- selectedImage,
+ shouldAutoSwitch,
galleryView,
- } = useAppSelector(mainSelector);
-
- const { images, isLoading, allImagesTotal, categories, selectedBoardId } =
- useAppSelector(itemSelector);
+ } = useAppSelector(selector);
const { selectedBoard } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => ({
@@ -212,12 +182,6 @@ const ImageGalleryContent = () => {
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
- const setScrollerRef = useCallback((ref: HTMLElement | Window | null) => {
- if (ref instanceof HTMLElement) {
- setScroller(ref);
- }
- }, []);
-
const handleClickImagesCategory = useCallback(() => {
dispatch(imageCategoriesChanged(IMAGE_CATEGORIES));
dispatch(setGalleryView('images'));
@@ -318,29 +282,11 @@ const ImageGalleryContent = () => {
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
-
- dispatch(
- setGalleryImageObjectFit(
- galleryImageObjectFit === 'contain' ? 'cover' : 'contain'
- )
- )
- }
- />
) =>
- dispatch(setShouldAutoSwitchToNewImages(e.target.checked))
- }
- />
- ) =>
- dispatch(setShouldUseSingleGalleryColumn(e.target.checked))
+ dispatch(shouldAutoSwitchChanged(e.target.checked))
}
/>
@@ -362,55 +308,28 @@ const ImageGalleryContent = () => {
{images.length || areMoreAvailable ? (
<>
- {shouldUseSingleGalleryColumn ? (
- setScrollerRef(ref)}
- itemContent={(index, item) => (
-
- {typeof item === 'string' ? (
-
- ) : (
-
- )}
-
- )}
- />
- ) : (
- (
-
- {typeof item === 'string' ? (
-
- ) : (
-
- )}
-
- )}
- />
- )}
+
+ typeof item === 'string' ? (
+
+ ) : (
+
+ )
+ }
+ />
{
>
) : (
-
+
)}
@@ -434,7 +356,7 @@ const ImageGalleryContent = () => {
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
-
+
{props.children}
));
@@ -451,8 +373,7 @@ const ListContainer = forwardRef((props: ListContainerProps, ref) => {
className="list-container"
ref={ref}
sx={{
- gap: 2,
- gridTemplateColumns: `repeat(auto-fit, minmax(${galleryImageMinimumWidth}px, 1fr));`,
+ gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
@@ -460,31 +381,4 @@ const ListContainer = forwardRef((props: ListContainerProps, ref) => {
);
});
-const EmptyGallery = () => {
- const { t } = useTranslation();
- return (
-
-
- {t('gallery.noImagesInGallery')}
-
- );
-};
-
export default memo(ImageGalleryContent);
diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx
index b1f06ad433..69dc1b2b19 100644
--- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx
@@ -5,14 +5,13 @@ import { clamp, isEqual } from 'lodash-es';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
-import { gallerySelector } from '../store/gallerySelectors';
-import { RootState } from 'app/store/store';
-import { imageSelected } from '../store/gallerySlice';
-import { useHotkeys } from 'react-hotkeys-hook';
+import { stateSelector } from 'app/store/store';
import {
- selectFilteredImagesAsObject,
- selectFilteredImagesIds,
-} from '../store/imagesSlice';
+ imageSelected,
+ selectImagesById,
+} from 'features/gallery/store/gallerySlice';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { selectFilteredImages } from 'features/gallery/store/gallerySlice';
const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = {
height: '100%',
@@ -25,45 +24,40 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
};
export const nextPrevImageButtonsSelector = createSelector(
- [
- (state: RootState) => state,
- gallerySelector,
- selectFilteredImagesAsObject,
- selectFilteredImagesIds,
- ],
- (state, gallery, filteredImagesAsObject, filteredImageIds) => {
- const { selectedImage } = gallery;
+ [stateSelector, selectFilteredImages],
+ (state, filteredImages) => {
+ const lastSelectedImage =
+ state.gallery.selection[state.gallery.selection.length - 1];
- if (!selectedImage) {
+ if (!lastSelectedImage || filteredImages.length === 0) {
return {
isOnFirstImage: true,
isOnLastImage: true,
};
}
- const currentImageIndex = filteredImageIds.findIndex(
- (i) => i === selectedImage
+ const currentImageIndex = filteredImages.findIndex(
+ (i) => i.image_name === lastSelectedImage
);
-
const nextImageIndex = clamp(
currentImageIndex + 1,
0,
- filteredImageIds.length - 1
+ filteredImages.length - 1
);
const prevImageIndex = clamp(
currentImageIndex - 1,
0,
- filteredImageIds.length - 1
+ filteredImages.length - 1
);
- const nextImageId = filteredImageIds[nextImageIndex];
- const prevImageId = filteredImageIds[prevImageIndex];
+ const nextImageId = filteredImages[nextImageIndex].image_name;
+ const prevImageId = filteredImages[prevImageIndex].image_name;
- const nextImage = filteredImagesAsObject[nextImageId];
- const prevImage = filteredImagesAsObject[prevImageId];
+ const nextImage = selectImagesById(state, nextImageId);
+ const prevImage = selectImagesById(state, prevImageId);
- const imagesLength = filteredImageIds.length;
+ const imagesLength = filteredImages.length;
return {
isOnFirstImage: currentImageIndex === 0,
@@ -101,11 +95,11 @@ const NextPrevImageButtons = () => {
}, []);
const handlePrevImage = useCallback(() => {
- dispatch(imageSelected(prevImageId));
+ prevImageId && dispatch(imageSelected(prevImageId));
}, [dispatch, prevImageId]);
const handleNextImage = useCallback(() => {
- dispatch(imageSelected(nextImageId));
+ nextImageId && dispatch(imageSelected(nextImageId));
}, [dispatch, nextImageId]);
useHotkeys(
diff --git a/invokeai/frontend/web/src/features/gallery/components/SelectedItemOverlay.tsx b/invokeai/frontend/web/src/features/gallery/components/SelectedItemOverlay.tsx
deleted file mode 100644
index 3fabe706d6..0000000000
--- a/invokeai/frontend/web/src/features/gallery/components/SelectedItemOverlay.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useColorMode, useToken } from '@chakra-ui/react';
-import { motion } from 'framer-motion';
-import { mode } from 'theme/util/mode';
-
-export const SelectedItemOverlay = () => {
- const [accent400, accent500] = useToken('colors', [
- 'accent.400',
- 'accent.500',
- ]);
-
- const { colorMode } = useColorMode();
-
- return (
-
- );
-};
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGetImageByName.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGetImageByName.ts
deleted file mode 100644
index 89709b322a..0000000000
--- a/invokeai/frontend/web/src/features/gallery/hooks/useGetImageByName.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useAppSelector } from 'app/store/storeHooks';
-import { selectImagesEntities } from '../store/imagesSlice';
-import { useCallback } from 'react';
-
-const useGetImageByName = () => {
- const images = useAppSelector(selectImagesEntities);
- return useCallback(
- (name: string | undefined) => {
- if (!name) {
- return;
- }
- return images[name];
- },
- [images]
- );
-};
-
-export default useGetImageByName;
diff --git a/invokeai/frontend/web/src/features/gallery/store/actions.ts b/invokeai/frontend/web/src/features/gallery/store/actions.ts
index 4234778120..0e1b1ef2a0 100644
--- a/invokeai/frontend/web/src/features/gallery/store/actions.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/actions.ts
@@ -1,15 +1,6 @@
import { createAction } from '@reduxjs/toolkit';
-import { ImageUsage } from 'app/contexts/DeleteImageContext';
-import { ImageDTO, BoardDTO } from 'services/api/types';
-
-export type RequestedImageDeletionArg = {
- image: ImageDTO;
- imageUsage: ImageUsage;
-};
-
-export const requestedImageDeletion = createAction(
- 'gallery/requestedImageDeletion'
-);
+import { ImageUsage } from 'app/contexts/AddImageToBoardContext';
+import { BoardDTO } from 'services/api/types';
export type RequestedBoardImagesDeletionArg = {
board: BoardDTO;
diff --git a/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts b/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts
index 7ec74dc4bf..e6b59eee9a 100644
--- a/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts
@@ -1,10 +1,8 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
-import { boardsApi } from 'services/api/endpoints/boards';
type BoardsState = {
searchText: string;
- selectedBoardId?: string;
updateBoardModalOpen: boolean;
};
@@ -17,9 +15,6 @@ const boardsSlice = createSlice({
name: 'boards',
initialState: initialBoardsState,
reducers: {
- boardIdSelected: (state, action: PayloadAction) => {
- state.selectedBoardId = action.payload;
- },
setBoardSearchText: (state, action: PayloadAction) => {
state.searchText = action.payload;
},
@@ -27,19 +22,9 @@ const boardsSlice = createSlice({
state.updateBoardModalOpen = action.payload;
},
},
- extraReducers: (builder) => {
- builder.addMatcher(
- boardsApi.endpoints.deleteBoard.matchFulfilled,
- (state, action) => {
- if (action.meta.arg.originalArgs === state.selectedBoardId) {
- state.selectedBoardId = undefined;
- }
- }
- );
- },
});
-export const { boardIdSelected, setBoardSearchText, setUpdateBoardModalOpen } =
+export const { setBoardSearchText, setUpdateBoardModalOpen } =
boardsSlice.actions;
export const boardsSelector = (state: RootState) => state.boards;
diff --git a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts
index 44e03f9f71..201cffa70e 100644
--- a/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/galleryPersistDenylist.ts
@@ -1,8 +1,15 @@
-import { GalleryState } from './gallerySlice';
+import { initialGalleryState } from './gallerySlice';
/**
* Gallery slice persist denylist
*/
-export const galleryPersistDenylist: (keyof GalleryState)[] = [
- 'shouldAutoSwitchToNewImages',
+export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
+ 'selection',
+ 'entities',
+ 'ids',
+ 'isLoading',
+ 'limit',
+ 'offset',
+ 'selectedBoardId',
+ 'total',
];
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
index b7fc0809a6..f4d2babf38 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -1,87 +1,266 @@
-import type { PayloadAction } from '@reduxjs/toolkit';
-import { createSlice } from '@reduxjs/toolkit';
-import { imageUpserted } from './imagesSlice';
+import type { PayloadAction, Update } from '@reduxjs/toolkit';
+import {
+ createEntityAdapter,
+ createSelector,
+ createSlice,
+} from '@reduxjs/toolkit';
+import { RootState } from 'app/store/store';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { dateComparator } from 'common/util/dateComparator';
+import { imageDeletionConfirmed } from 'features/imageDeletion/store/imageDeletionSlice';
+import { keyBy, uniq } from 'lodash-es';
+import { boardsApi } from 'services/api/endpoints/boards';
+import {
+ imageUrlsReceived,
+ receivedPageOfImages,
+} from 'services/api/thunks/image';
+import { ImageCategory, ImageDTO } from 'services/api/types';
-type GalleryImageObjectFitType = 'contain' | 'cover';
+export const imagesAdapter = createEntityAdapter({
+ selectId: (image) => image.image_name,
+ sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
+});
-export interface GalleryState {
- selectedImage?: string;
+export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
+export const ASSETS_CATEGORIES: ImageCategory[] = [
+ 'control',
+ 'mask',
+ 'user',
+ 'other',
+];
+
+type AdditionaGalleryState = {
+ offset: number;
+ limit: number;
+ total: number;
+ isLoading: boolean;
+ categories: ImageCategory[];
+ selectedBoardId?: string;
+ selection: string[];
+ shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number;
- galleryImageObjectFit: GalleryImageObjectFitType;
- shouldAutoSwitchToNewImages: boolean;
- shouldUseSingleGalleryColumn: boolean;
galleryView: 'images' | 'assets' | 'boards';
-}
-
-export const initialGalleryState: GalleryState = {
- galleryImageMinimumWidth: 64,
- galleryImageObjectFit: 'cover',
- shouldAutoSwitchToNewImages: true,
- shouldUseSingleGalleryColumn: false,
- galleryView: 'images',
};
+export const initialGalleryState =
+ imagesAdapter.getInitialState({
+ offset: 0,
+ limit: 0,
+ total: 0,
+ isLoading: true,
+ categories: IMAGE_CATEGORIES,
+ selection: [],
+ shouldAutoSwitch: true,
+ galleryImageMinimumWidth: 64,
+ galleryView: 'images',
+ });
+
export const gallerySlice = createSlice({
name: 'gallery',
initialState: initialGalleryState,
reducers: {
- imageSelected: (state, action: PayloadAction) => {
- state.selectedImage = action.payload;
- // TODO: if the user selects an image, disable the auto switch?
- // state.shouldAutoSwitchToNewImages = false;
+ imageUpserted: (state, action: PayloadAction) => {
+ imagesAdapter.upsertOne(state, action.payload);
+ if (
+ state.shouldAutoSwitch &&
+ action.payload.image_category === 'general'
+ ) {
+ state.selection = [action.payload.image_name];
+ }
+ },
+ imageUpdatedOne: (state, action: PayloadAction>) => {
+ imagesAdapter.updateOne(state, action.payload);
+ },
+ imageRemoved: (state, action: PayloadAction) => {
+ imagesAdapter.removeOne(state, action.payload);
+ },
+ imagesRemoved: (state, action: PayloadAction) => {
+ imagesAdapter.removeMany(state, action.payload);
+ },
+ imageCategoriesChanged: (state, action: PayloadAction) => {
+ state.categories = action.payload;
+ },
+ imageRangeEndSelected: (state, action: PayloadAction) => {
+ const rangeEndImageName = action.payload;
+ const lastSelectedImage = state.selection[state.selection.length - 1];
+
+ const filteredImages = selectFilteredImagesLocal(state);
+
+ const lastClickedIndex = filteredImages.findIndex(
+ (n) => n.image_name === lastSelectedImage
+ );
+
+ const currentClickedIndex = filteredImages.findIndex(
+ (n) => n.image_name === rangeEndImageName
+ );
+
+ if (lastClickedIndex > -1 && currentClickedIndex > -1) {
+ // We have a valid range!
+ const start = Math.min(lastClickedIndex, currentClickedIndex);
+ const end = Math.max(lastClickedIndex, currentClickedIndex);
+
+ const imagesToSelect = filteredImages
+ .slice(start, end + 1)
+ .map((i) => i.image_name);
+
+ state.selection = uniq(state.selection.concat(imagesToSelect));
+ }
+ },
+ imageSelectionToggled: (state, action: PayloadAction) => {
+ if (
+ state.selection.includes(action.payload) &&
+ state.selection.length > 1
+ ) {
+ state.selection = state.selection.filter(
+ (imageName) => imageName !== action.payload
+ );
+ } else {
+ state.selection = uniq(state.selection.concat(action.payload));
+ }
+ },
+ imageSelected: (state, action: PayloadAction) => {
+ state.selection = action.payload
+ ? [action.payload]
+ : [String(state.ids[0])];
+ },
+ shouldAutoSwitchChanged: (state, action: PayloadAction) => {
+ state.shouldAutoSwitch = action.payload;
},
setGalleryImageMinimumWidth: (state, action: PayloadAction) => {
state.galleryImageMinimumWidth = action.payload;
},
- setGalleryImageObjectFit: (
- state,
- action: PayloadAction
- ) => {
- state.galleryImageObjectFit = action.payload;
- },
- setShouldAutoSwitchToNewImages: (state, action: PayloadAction) => {
- state.shouldAutoSwitchToNewImages = action.payload;
- },
- setShouldUseSingleGalleryColumn: (
- state,
- action: PayloadAction
- ) => {
- state.shouldUseSingleGalleryColumn = action.payload;
- },
setGalleryView: (
state,
action: PayloadAction<'images' | 'assets' | 'boards'>
) => {
state.galleryView = action.payload;
},
+ boardIdSelected: (state, action: PayloadAction) => {
+ state.selectedBoardId = action.payload;
+ },
},
extraReducers: (builder) => {
- builder.addCase(imageUpserted, (state, action) => {
- if (
- state.shouldAutoSwitchToNewImages &&
- action.payload.image_category === 'general'
- ) {
- state.selectedImage = action.payload.image_name;
- }
+ builder.addCase(receivedPageOfImages.pending, (state) => {
+ state.isLoading = true;
});
- // builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
- // const { image_name, image_url, thumbnail_url } = action.payload;
+ builder.addCase(receivedPageOfImages.rejected, (state) => {
+ state.isLoading = false;
+ });
+ builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
+ state.isLoading = false;
+ const { board_id, categories, image_origin, is_intermediate } =
+ action.meta.arg;
- // if (state.selectedImage?.image_name === image_name) {
- // state.selectedImage.image_url = image_url;
- // state.selectedImage.thumbnail_url = thumbnail_url;
- // }
- // });
+ const { items, offset, limit, total } = action.payload;
+
+ const transformedItems = items.map((item) => ({
+ ...item,
+ isSelected: false,
+ }));
+
+ imagesAdapter.upsertMany(state, transformedItems);
+
+ if (state.selection.length === 0) {
+ state.selection = [items[0].image_name];
+ }
+
+ if (!categories?.includes('general') || board_id) {
+ // need to skip updating the total images count if the images recieved were for a specific board
+ // TODO: this doesn't work when on the Asset tab/category...
+ return;
+ }
+
+ state.offset = offset;
+ state.limit = limit;
+ state.total = total;
+ });
+ builder.addCase(imageDeletionConfirmed, (state, action) => {
+ // Image deleted
+ const { image_name } = action.payload.imageDTO;
+ imagesAdapter.removeOne(state, image_name);
+ });
+ builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
+ const { image_name, image_url, thumbnail_url } = action.payload;
+
+ imagesAdapter.updateOne(state, {
+ id: image_name,
+ changes: { image_url, thumbnail_url },
+ });
+ });
+ builder.addMatcher(
+ boardsApi.endpoints.deleteBoard.matchFulfilled,
+ (state, action) => {
+ if (action.meta.arg.originalArgs === state.selectedBoardId) {
+ state.selectedBoardId = undefined;
+ }
+ }
+ );
},
});
export const {
+ selectAll: selectImagesAll,
+ selectById: selectImagesById,
+ selectEntities: selectImagesEntities,
+ selectIds: selectImagesIds,
+ selectTotal: selectImagesTotal,
+} = imagesAdapter.getSelectors((state) => state.gallery);
+
+export const {
+ imageUpserted,
+ imageUpdatedOne,
+ imageRemoved,
+ imagesRemoved,
+ imageCategoriesChanged,
+ imageRangeEndSelected,
+ imageSelectionToggled,
imageSelected,
+ shouldAutoSwitchChanged,
setGalleryImageMinimumWidth,
- setGalleryImageObjectFit,
- setShouldAutoSwitchToNewImages,
- setShouldUseSingleGalleryColumn,
setGalleryView,
+ boardIdSelected,
} = gallerySlice.actions;
export default gallerySlice.reducer;
+
+export const selectFilteredImagesLocal = createSelector(
+ (state: typeof initialGalleryState) => state,
+ (galleryState) => {
+ const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
+ const { categories, selectedBoardId } = galleryState;
+
+ const filteredImages = allImages.filter((i) => {
+ const isInCategory = categories.includes(i.image_category);
+ const isInSelectedBoard = selectedBoardId
+ ? i.board_id === selectedBoardId
+ : true;
+ return isInCategory && isInSelectedBoard;
+ });
+
+ return filteredImages;
+ }
+);
+
+export const selectFilteredImages = createSelector(
+ (state: RootState) => state,
+ (state) => {
+ return selectFilteredImagesLocal(state.gallery);
+ },
+ defaultSelectorOptions
+);
+
+export const selectFilteredImagesAsObject = createSelector(
+ selectFilteredImages,
+ (filteredImages) => keyBy(filteredImages, 'image_name')
+);
+
+export const selectFilteredImagesIds = createSelector(
+ selectFilteredImages,
+ (filteredImages) => filteredImages.map((i) => i.image_name)
+);
+
+export const selectLastSelectedImage = createSelector(
+ (state: RootState) => state,
+ (state) => state.gallery.selection[state.gallery.selection.length - 1],
+ defaultSelectorOptions
+);
diff --git a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts b/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts
deleted file mode 100644
index 3c14d91994..0000000000
--- a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import {
- PayloadAction,
- Update,
- createEntityAdapter,
- createSelector,
- createSlice,
-} from '@reduxjs/toolkit';
-import { RootState } from 'app/store/store';
-import { ImageCategory, ImageDTO } from 'services/api/types';
-import { dateComparator } from 'common/util/dateComparator';
-import { keyBy } from 'lodash-es';
-import {
- imageDeleted,
- imageUrlsReceived,
- receivedPageOfImages,
-} from 'services/api/thunks/image';
-
-export const imagesAdapter = createEntityAdapter({
- selectId: (image) => image.image_name,
- sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
-});
-
-export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
-export const ASSETS_CATEGORIES: ImageCategory[] = [
- 'control',
- 'mask',
- 'user',
- 'other',
-];
-
-type AdditionaImagesState = {
- offset: number;
- limit: number;
- total: number;
- isLoading: boolean;
- categories: ImageCategory[];
-};
-
-export const initialImagesState =
- imagesAdapter.getInitialState({
- offset: 0,
- limit: 0,
- total: 0,
- isLoading: true,
- categories: IMAGE_CATEGORIES,
- });
-
-export type ImagesState = typeof initialImagesState;
-
-const imagesSlice = createSlice({
- name: 'images',
- initialState: initialImagesState,
- reducers: {
- imageUpserted: (state, action: PayloadAction) => {
- imagesAdapter.upsertOne(state, action.payload);
- },
- imageUpdatedOne: (state, action: PayloadAction>) => {
- imagesAdapter.updateOne(state, action.payload);
- },
- imageRemoved: (state, action: PayloadAction) => {
- imagesAdapter.removeOne(state, action.payload);
- },
- imagesRemoved: (state, action: PayloadAction) => {
- imagesAdapter.removeMany(state, action.payload);
- },
- imageCategoriesChanged: (state, action: PayloadAction) => {
- state.categories = action.payload;
- },
- },
- extraReducers: (builder) => {
- builder.addCase(receivedPageOfImages.pending, (state) => {
- state.isLoading = true;
- });
- builder.addCase(receivedPageOfImages.rejected, (state) => {
- state.isLoading = false;
- });
- builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
- state.isLoading = false;
- const { board_id, categories, image_origin, is_intermediate } =
- action.meta.arg;
-
- const { items, offset, limit, total } = action.payload;
- imagesAdapter.upsertMany(state, items);
-
- if (!categories?.includes('general') || board_id) {
- // need to skip updating the total images count if the images recieved were for a specific board
- // TODO: this doesn't work when on the Asset tab/category...
- return;
- }
-
- state.offset = offset;
- state.limit = limit;
- state.total = total;
- });
- builder.addCase(imageDeleted.pending, (state, action) => {
- // Image deleted
- const { image_name } = action.meta.arg;
- imagesAdapter.removeOne(state, image_name);
- });
- builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
- const { image_name, image_url, thumbnail_url } = action.payload;
-
- imagesAdapter.updateOne(state, {
- id: image_name,
- changes: { image_url, thumbnail_url },
- });
- });
- },
-});
-
-export const {
- selectAll: selectImagesAll,
- selectById: selectImagesById,
- selectEntities: selectImagesEntities,
- selectIds: selectImagesIds,
- selectTotal: selectImagesTotal,
-} = imagesAdapter.getSelectors((state) => state.images);
-
-export const {
- imageUpserted,
- imageUpdatedOne,
- imageRemoved,
- imagesRemoved,
- imageCategoriesChanged,
-} = imagesSlice.actions;
-
-export default imagesSlice.reducer;
-
-export const selectFilteredImagesAsArray = createSelector(
- (state: RootState) => state,
- (state) => {
- const {
- images: { categories },
- } = state;
-
- return selectImagesAll(state).filter((i) =>
- categories.includes(i.image_category)
- );
- }
-);
-
-export const selectFilteredImagesAsObject = createSelector(
- (state: RootState) => state,
- (state) => {
- const {
- images: { categories },
- } = state;
-
- return keyBy(
- selectImagesAll(state).filter((i) =>
- categories.includes(i.image_category)
- ),
- 'image_name'
- );
- }
-);
-
-export const selectFilteredImagesIds = createSelector(
- (state: RootState) => state,
- (state) => {
- const {
- images: { categories },
- } = state;
-
- return selectImagesAll(state)
- .filter((i) => categories.includes(i.image_category))
- .map((i) => i.image_name);
- }
-);
-
-// export const selectImageById = createSelector(
-// (state: RootState, imageId) => state,
-// (state) => {
-// const {
-// images: { categories },
-// } = state;
-
-// return selectImagesAll(state)
-// .filter((i) => categories.includes(i.image_category))
-// .map((i) => i.image_name);
-// }
-// );
diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx
new file mode 100644
index 0000000000..dde6d1a517
--- /dev/null
+++ b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx
@@ -0,0 +1,37 @@
+import { IconButtonProps } from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppSelector } from 'app/store/storeHooks';
+import IAIIconButton from 'common/components/IAIIconButton';
+import { useTranslation } from 'react-i18next';
+import { FaTrash } from 'react-icons/fa';
+
+const deleteImageButtonsSelector = createSelector(
+ [stateSelector],
+ ({ system }) => {
+ const { isProcessing, isConnected } = system;
+
+ return isConnected && !isProcessing;
+ }
+);
+
+type DeleteImageButtonProps = Omit & {
+ onClick: () => void;
+};
+
+export const DeleteImageButton = (props: DeleteImageButtonProps) => {
+ const { onClick, isDisabled } = props;
+ const { t } = useTranslation();
+ const canDeleteImage = useAppSelector(deleteImageButtonsSelector);
+
+ return (
+ }
+ tooltip={`${t('gallery.deleteImage')} (Del)`}
+ aria-label={`${t('gallery.deleteImage')} (Del)`}
+ isDisabled={isDisabled || !canDeleteImage}
+ colorScheme="error"
+ />
+ );
+};
diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx
new file mode 100644
index 0000000000..cdc8257488
--- /dev/null
+++ b/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx
@@ -0,0 +1,122 @@
+import {
+ AlertDialog,
+ AlertDialogBody,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ Divider,
+ Flex,
+ Text,
+} from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import IAIButton from 'common/components/IAIButton';
+import IAISwitch from 'common/components/IAISwitch';
+import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
+
+import { ChangeEvent, memo, useCallback, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import ImageUsageMessage from './ImageUsageMessage';
+import { stateSelector } from 'app/store/store';
+import {
+ imageDeletionConfirmed,
+ imageToDeleteCleared,
+ selectImageUsage,
+} from '../store/imageDeletionSlice';
+
+const selector = createSelector(
+ [stateSelector, selectImageUsage],
+ ({ system, config, imageDeletion }, imageUsage) => {
+ const { shouldConfirmOnDelete } = system;
+ const { canRestoreDeletedImagesFromBin } = config;
+ const { imageToDelete, isModalOpen } = imageDeletion;
+ return {
+ shouldConfirmOnDelete,
+ canRestoreDeletedImagesFromBin,
+ imageToDelete,
+ imageUsage,
+ isModalOpen,
+ };
+ },
+ defaultSelectorOptions
+);
+
+const DeleteImageModal = () => {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+
+ const {
+ shouldConfirmOnDelete,
+ canRestoreDeletedImagesFromBin,
+ imageToDelete,
+ imageUsage,
+ isModalOpen,
+ } = useAppSelector(selector);
+
+ const handleChangeShouldConfirmOnDelete = useCallback(
+ (e: ChangeEvent) =>
+ dispatch(setShouldConfirmOnDelete(!e.target.checked)),
+ [dispatch]
+ );
+
+ const handleClose = useCallback(() => {
+ dispatch(imageToDeleteCleared());
+ }, [dispatch]);
+
+ const handleDelete = useCallback(() => {
+ if (!imageToDelete || !imageUsage) {
+ return;
+ }
+ dispatch(imageToDeleteCleared());
+ dispatch(imageDeletionConfirmed({ imageDTO: imageToDelete, imageUsage }));
+ }, [dispatch, imageToDelete, imageUsage]);
+
+ const cancelRef = useRef(null);
+
+ return (
+
+
+
+
+ {t('gallery.deleteImage')}
+
+
+
+
+
+
+
+ {canRestoreDeletedImagesFromBin
+ ? t('gallery.deleteImageBin')
+ : t('gallery.deleteImagePermanent')}
+
+ {t('common.areYouSure')}
+
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ );
+};
+
+export default memo(DeleteImageModal);
diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx b/invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx
new file mode 100644
index 0000000000..9bd4ca5198
--- /dev/null
+++ b/invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx
@@ -0,0 +1,33 @@
+import { some } from 'lodash-es';
+import { memo } from 'react';
+import { ImageUsage } from '../store/imageDeletionSlice';
+import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
+
+const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
+ const { imageUsage } = props;
+
+ 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.
+
+ >
+ );
+};
+
+export default memo(ImageUsageMessage);
diff --git a/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts b/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts
new file mode 100644
index 0000000000..0daffba0d7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts
@@ -0,0 +1,99 @@
+import {
+ PayloadAction,
+ createAction,
+ createSelector,
+ createSlice,
+} from '@reduxjs/toolkit';
+import { RootState } from 'app/store/store';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { some } from 'lodash-es';
+import { ImageDTO } from 'services/api/types';
+
+type DeleteImageState = {
+ imageToDelete: ImageDTO | null;
+ isModalOpen: boolean;
+};
+
+export const initialDeleteImageState: DeleteImageState = {
+ imageToDelete: null,
+ isModalOpen: false,
+};
+
+const imageDeletion = createSlice({
+ name: 'imageDeletion',
+ initialState: initialDeleteImageState,
+ reducers: {
+ isModalOpenChanged: (state, action: PayloadAction) => {
+ state.isModalOpen = action.payload;
+ },
+ imageToDeleteSelected: (state, action: PayloadAction) => {
+ state.imageToDelete = action.payload;
+ },
+ imageToDeleteCleared: (state) => {
+ state.imageToDelete = null;
+ },
+ },
+});
+
+export const {
+ isModalOpenChanged,
+ imageToDeleteSelected,
+ imageToDeleteCleared,
+} = imageDeletion.actions;
+
+export default imageDeletion.reducer;
+
+export type ImageUsage = {
+ isInitialImage: boolean;
+ isCanvasImage: boolean;
+ isNodesImage: boolean;
+ isControlNetImage: boolean;
+};
+
+export const selectImageUsage = createSelector(
+ [(state: RootState) => state],
+ ({ imageDeletion, generation, canvas, nodes, controlNet }) => {
+ const { imageToDelete } = imageDeletion;
+
+ if (!imageToDelete) {
+ return;
+ }
+
+ const { image_name } = imageToDelete;
+
+ const isInitialImage = generation.initialImage?.imageName === image_name;
+
+ const isCanvasImage = canvas.layerState.objects.some(
+ (obj) => obj.kind === 'image' && obj.imageName === 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 || c.processedControlImage === image_name
+ );
+
+ const imageUsage: ImageUsage = {
+ isInitialImage,
+ isCanvasImage,
+ isNodesImage,
+ isControlNetImage,
+ };
+
+ return imageUsage;
+ },
+ defaultSelectorOptions
+);
+
+export const imageDeletionConfirmed = createAction<{
+ imageDTO: ImageDTO;
+ imageUsage: ImageUsage;
+}>('imageDeletion/imageDeletionConfirmed');
diff --git a/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx
index 65b7cfa560..b3b91ccf5e 100644
--- a/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/InputFieldComponent.tsx
@@ -16,6 +16,7 @@ import NumberInputFieldComponent from './fields/NumberInputFieldComponent';
import StringInputFieldComponent from './fields/StringInputFieldComponent';
import ColorInputFieldComponent from './fields/ColorInputFieldComponent';
import ItemInputFieldComponent from './fields/ItemInputFieldComponent';
+import ImageCollectionInputFieldComponent from './fields/ImageCollectionInputFieldComponent';
type InputFieldComponentProps = {
nodeId: string;
@@ -191,6 +192,16 @@ const InputFieldComponent = (props: InputFieldComponentProps) => {
);
}
+ if (type === 'image_collection' && template.type === 'image_collection') {
+ return (
+
+ );
+ }
+
return Unknown field type: {type};
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx
index fc3a6377b2..3c3568a6b2 100644
--- a/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/InvocationComponent.tsx
@@ -30,7 +30,7 @@ const InvocationComponentWrapper = (props: InvocationComponentWrapperProps) => {
position: 'relative',
borderRadius: 'md',
minWidth: NODE_MIN_WIDTH,
- boxShadow: props.selected
+ shadow: props.selected
? `${nodeSelectedOutline}, ${nodeShadow}`
: `${nodeShadow}`,
}}
diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/ImageCollectionInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/ImageCollectionInputFieldComponent.tsx
new file mode 100644
index 0000000000..0ac1f7aa1c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/fields/ImageCollectionInputFieldComponent.tsx
@@ -0,0 +1,103 @@
+import { useAppDispatch } from 'app/store/storeHooks';
+
+import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
+import {
+ ImageCollectionInputFieldTemplate,
+ ImageCollectionInputFieldValue,
+} from 'features/nodes/types/types';
+import { memo, useCallback } from 'react';
+
+import { FieldComponentProps } from './types';
+import IAIDndImage from 'common/components/IAIDndImage';
+import { ImageDTO } from 'services/api/types';
+import { Flex } from '@chakra-ui/react';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import { skipToken } from '@reduxjs/toolkit/dist/query';
+import { uniq, uniqBy } from 'lodash-es';
+import {
+ NodesMultiImageDropData,
+ isValidDrop,
+ useDroppable,
+} from 'app/components/ImageDnd/typesafeDnd';
+import IAIDropOverlay from 'common/components/IAIDropOverlay';
+
+const ImageCollectionInputFieldComponent = (
+ props: FieldComponentProps<
+ ImageCollectionInputFieldValue,
+ ImageCollectionInputFieldTemplate
+ >
+) => {
+ const { nodeId, field } = props;
+
+ const dispatch = useAppDispatch();
+
+ const handleDrop = useCallback(
+ ({ image_name }: ImageDTO) => {
+ dispatch(
+ fieldValueChanged({
+ nodeId,
+ fieldName: field.name,
+ value: uniqBy([...(field.value ?? []), { image_name }], 'image_name'),
+ })
+ );
+ },
+ [dispatch, field.name, field.value, nodeId]
+ );
+
+ const droppableData: NodesMultiImageDropData = {
+ id: `node-${nodeId}-${field.name}`,
+ actionType: 'SET_MULTI_NODES_IMAGE',
+ context: { nodeId, fieldName: field.name },
+ };
+
+ const {
+ isOver,
+ setNodeRef: setDroppableRef,
+ active,
+ over,
+ } = useDroppable({
+ id: `node_${nodeId}`,
+ data: droppableData,
+ });
+
+ const handleReset = useCallback(() => {
+ dispatch(
+ fieldValueChanged({
+ nodeId,
+ fieldName: field.name,
+ value: undefined,
+ })
+ );
+ }, [dispatch, field.name, nodeId]);
+
+ return (
+
+ {field.value?.map(({ image_name }) => (
+
+ ))}
+ {isValidDrop(droppableData, active) && }
+
+ );
+};
+
+export default memo(ImageCollectionInputFieldComponent);
+
+type ImageSubFieldProps = { imageName: string };
+
+const ImageSubField = (props: ImageSubFieldProps) => {
+ const { currentData: image } = useGetImageDTOQuery(props.imageName);
+
+ return (
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx
index 8d83e8353f..499946e3af 100644
--- a/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/fields/ImageInputFieldComponent.tsx
@@ -5,7 +5,7 @@ import {
ImageInputFieldTemplate,
ImageInputFieldValue,
} from 'features/nodes/types/types';
-import { memo, useCallback } from 'react';
+import { memo, useCallback, useMemo } from 'react';
import { FieldComponentProps } from './types';
import IAIDndImage from 'common/components/IAIDndImage';
@@ -13,6 +13,12 @@ import { ImageDTO } from 'services/api/types';
import { Flex } from '@chakra-ui/react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
+import {
+ NodesImageDropData,
+ TypesafeDraggableData,
+ TypesafeDroppableData,
+} from 'app/components/ImageDnd/typesafeDnd';
+import { PostUploadAction } from 'services/api/thunks/image';
const ImageInputFieldComponent = (
props: FieldComponentProps
@@ -22,7 +28,7 @@ const ImageInputFieldComponent = (
const dispatch = useAppDispatch();
const {
- currentData: image,
+ currentData: imageDTO,
isLoading,
isError,
isSuccess,
@@ -55,6 +61,35 @@ const ImageInputFieldComponent = (
);
}, [dispatch, field.name, nodeId]);
+ const draggableData = useMemo(() => {
+ if (imageDTO) {
+ return {
+ id: `node-${nodeId}-${field.name}`,
+ payloadType: 'IMAGE_DTO',
+ payload: { imageDTO },
+ };
+ }
+ }, [field.name, imageDTO, nodeId]);
+
+ const droppableData = useMemo(() => {
+ if (imageDTO) {
+ return {
+ id: `node-${nodeId}-${field.name}`,
+ actionType: 'SET_NODES_IMAGE',
+ context: { nodeId, fieldName: field.name },
+ };
+ }
+ }, [field.name, imageDTO, nodeId]);
+
+ const postUploadAction = useMemo(
+ () => ({
+ type: 'SET_NODES_IMAGE',
+ nodeId,
+ fieldName: field.name,
+ }),
+ [nodeId, field.name]
+ );
+
return (
);
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index ba217fff5f..ffc93db2ba 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -16,6 +16,7 @@ import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import { InvocationTemplate, InvocationValue } from '../types/types';
import { RgbaColor } from 'react-colorful';
import { RootState } from 'app/store/store';
+import { cloneDeep, isArray, uniq, uniqBy } from 'lodash-es';
export type NodesState = {
nodes: Node[];
@@ -62,7 +63,14 @@ const nodesSlice = createSlice({
action: PayloadAction<{
nodeId: string;
fieldName: string;
- value: string | number | boolean | ImageField | RgbaColor | undefined;
+ value:
+ | string
+ | number
+ | boolean
+ | ImageField
+ | RgbaColor
+ | undefined
+ | ImageField[];
}>
) => {
const { nodeId, fieldName, value } = action.payload;
@@ -72,6 +80,35 @@ const nodesSlice = createSlice({
state.nodes[nodeIndex].data.inputs[fieldName].value = value;
}
},
+ imageCollectionFieldValueChanged: (
+ state,
+ action: PayloadAction<{
+ nodeId: string;
+ fieldName: string;
+ value: ImageField[];
+ }>
+ ) => {
+ const { nodeId, fieldName, value } = action.payload;
+ const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
+
+ if (nodeIndex === -1) {
+ return;
+ }
+
+ const currentValue = cloneDeep(
+ state.nodes[nodeIndex].data.inputs[fieldName].value
+ );
+
+ if (!currentValue) {
+ state.nodes[nodeIndex].data.inputs[fieldName].value = value;
+ return;
+ }
+
+ state.nodes[nodeIndex].data.inputs[fieldName].value = uniqBy(
+ (currentValue as ImageField[]).concat(value),
+ 'image_name'
+ );
+ },
shouldShowGraphOverlayChanged: (state, action: PayloadAction) => {
state.shouldShowGraphOverlay = action.payload;
},
@@ -103,6 +140,7 @@ export const {
shouldShowGraphOverlayChanged,
nodeTemplatesBuilt,
nodeEditorReset,
+ imageCollectionFieldValueChanged,
} = nodesSlice.actions;
export default nodesSlice.reducer;
diff --git a/invokeai/frontend/web/src/features/nodes/types/constants.ts b/invokeai/frontend/web/src/features/nodes/types/constants.ts
index 83fadb6bcb..9f6124c9d4 100644
--- a/invokeai/frontend/web/src/features/nodes/types/constants.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/constants.ts
@@ -10,6 +10,7 @@ export const FIELD_TYPE_MAP: Record = {
boolean: 'boolean',
enum: 'enum',
ImageField: 'image',
+ image_collection: 'image_collection',
LatentsField: 'latents',
ConditioningField: 'conditioning',
UNetField: 'unet',
@@ -30,9 +31,6 @@ const COLOR_TOKEN_VALUE = 500;
const getColorTokenCssVariable = (color: string) =>
`var(--invokeai-colors-${color}-${COLOR_TOKEN_VALUE})`;
-// @ts-ignore
-// @ts-ignore
-// @ts-ignore
export const FIELDS: Record = {
integer: {
color: 'red',
@@ -70,6 +68,12 @@ export const FIELDS: Record = {
title: 'Image',
description: 'Images may be passed between nodes.',
},
+ image_collection: {
+ color: 'purple',
+ colorCssVar: getColorTokenCssVariable('purple'),
+ title: 'Image Collection',
+ description: 'A collection of images.',
+ },
latents: {
color: 'pink',
colorCssVar: getColorTokenCssVariable('pink'),
diff --git a/invokeai/frontend/web/src/features/nodes/types/types.ts b/invokeai/frontend/web/src/features/nodes/types/types.ts
index 3faf2f9653..9498bbd5d5 100644
--- a/invokeai/frontend/web/src/features/nodes/types/types.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/types.ts
@@ -66,7 +66,8 @@ export type FieldType =
| 'model'
| 'array'
| 'item'
- | 'color';
+ | 'color'
+ | 'image_collection';
/**
* An input field is persisted across reloads as part of the user's local state.
@@ -92,7 +93,8 @@ export type InputFieldValue =
| ModelInputFieldValue
| ArrayInputFieldValue
| ItemInputFieldValue
- | ColorInputFieldValue;
+ | ColorInputFieldValue
+ | ImageCollectionInputFieldValue;
/**
* An input field template is generated on each page load from the OpenAPI schema.
@@ -116,7 +118,8 @@ export type InputFieldTemplate =
| ModelInputFieldTemplate
| ArrayInputFieldTemplate
| ItemInputFieldTemplate
- | ColorInputFieldTemplate;
+ | ColorInputFieldTemplate
+ | ImageCollectionInputFieldTemplate;
/**
* An output field is persisted across as part of the user's local state.
@@ -215,6 +218,11 @@ export type ImageInputFieldValue = FieldValueBase & {
value?: ImageField;
};
+export type ImageCollectionInputFieldValue = FieldValueBase & {
+ type: 'image_collection';
+ value?: ImageField[];
+};
+
export type ModelInputFieldValue = FieldValueBase & {
type: 'model';
value?: string;
@@ -282,6 +290,11 @@ export type ImageInputFieldTemplate = InputFieldTemplateBase & {
type: 'image';
};
+export type ImageCollectionInputFieldTemplate = InputFieldTemplateBase & {
+ default: ImageField[];
+ type: 'image_collection';
+};
+
export type LatentsInputFieldTemplate = InputFieldTemplateBase & {
default: string;
type: 'latents';
diff --git a/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts b/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts
index f1ad731d32..6f971dd60b 100644
--- a/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts
@@ -23,6 +23,7 @@ import {
OutputFieldTemplate,
TypeHints,
FieldType,
+ ImageCollectionInputFieldTemplate,
} from '../types/types';
export type BaseFieldProperties = 'name' | 'title' | 'description';
@@ -189,6 +190,21 @@ const buildImageInputFieldTemplate = ({
return template;
};
+const buildImageCollectionInputFieldTemplate = ({
+ schemaObject,
+ baseField,
+}: BuildInputFieldArg): ImageCollectionInputFieldTemplate => {
+ const template: ImageCollectionInputFieldTemplate = {
+ ...baseField,
+ type: 'image_collection',
+ inputRequirement: 'always',
+ inputKind: 'any',
+ default: schemaObject.default ?? undefined,
+ };
+
+ return template;
+};
+
const buildLatentsInputFieldTemplate = ({
schemaObject,
baseField,
@@ -400,6 +416,10 @@ export const buildInputFieldTemplate = (
if (['image'].includes(fieldType)) {
return buildImageInputFieldTemplate({ schemaObject, baseField });
}
+
+ if (['image_collection'].includes(fieldType)) {
+ return buildImageCollectionInputFieldTemplate({ schemaObject, baseField });
+ }
if (['latents'].includes(fieldType)) {
return buildLatentsInputFieldTemplate({ schemaObject, baseField });
}
diff --git a/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts b/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts
index 1703c45331..e05ef404c0 100644
--- a/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/fieldValueBuilders.ts
@@ -44,6 +44,10 @@ export const buildInputFieldValue = (
fieldValue.value = undefined;
}
+ if (template.type === 'image_collection') {
+ fieldValue.value = [];
+ }
+
if (template.type === 'latents') {
fieldValue.value = undefined;
}
diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts
index 15d5a431a2..ca0a2e4dd9 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/buildLinearImageToImageGraph.ts
@@ -1,7 +1,9 @@
import { RootState } from 'app/store/store';
import {
+ ImageCollectionInvocation,
ImageResizeInvocation,
ImageToLatentsInvocation,
+ IterateInvocation,
} from 'services/api/types';
import { NonNullableGraph } from 'features/nodes/types/types';
import { log } from 'app/logging/useLogger';
@@ -15,6 +17,8 @@ import {
IMAGE_TO_LATENTS,
LATENTS_TO_LATENTS,
RESIZE,
+ IMAGE_COLLECTION,
+ IMAGE_COLLECTION_ITERATE,
} from './constants';
import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph';
import { modelIdToPipelineModelField } from '../modelIdToPipelineModelField';
@@ -42,6 +46,15 @@ export const buildLinearImageToImageGraph = (
height,
} = state.generation;
+ const {
+ isEnabled: isBatchEnabled,
+ imageNames: batchImageNames,
+ asInitialImage,
+ } = state.batch;
+
+ const shouldBatch =
+ isBatchEnabled && batchImageNames.length > 0 && asInitialImage;
+
/**
* The easiest way to build linear graphs is to do it in the node editor, then copy and paste the
* full graph here as a template. Then use the parameters from app state and set friendlier node
@@ -51,7 +64,7 @@ export const buildLinearImageToImageGraph = (
* the `fit` param. These are added to the graph at the end.
*/
- if (!initialImage) {
+ if (!initialImage && !shouldBatch) {
moduleLog.error('No initial image found in state');
throw new Error('No initial image found in state');
}
@@ -275,6 +288,41 @@ export const buildLinearImageToImageGraph = (
});
}
+ if (isBatchEnabled && asInitialImage && batchImageNames.length > 0) {
+ // we are going to connect an iterate up to the init image
+ delete (graph.nodes[IMAGE_TO_LATENTS] as ImageToLatentsInvocation).image;
+
+ const imageCollection: ImageCollectionInvocation = {
+ id: IMAGE_COLLECTION,
+ type: 'image_collection',
+ images: batchImageNames.map((image_name) => ({ image_name })),
+ };
+
+ const imageCollectionIterate: IterateInvocation = {
+ id: IMAGE_COLLECTION_ITERATE,
+ type: 'iterate',
+ };
+
+ graph.nodes[IMAGE_COLLECTION] = imageCollection;
+ graph.nodes[IMAGE_COLLECTION_ITERATE] = imageCollectionIterate;
+
+ graph.edges.push({
+ source: { node_id: IMAGE_COLLECTION, field: 'collection' },
+ destination: {
+ node_id: IMAGE_COLLECTION_ITERATE,
+ field: 'collection',
+ },
+ });
+
+ graph.edges.push({
+ source: { node_id: IMAGE_COLLECTION_ITERATE, field: 'item' },
+ destination: {
+ node_id: IMAGE_TO_LATENTS,
+ field: 'image',
+ },
+ });
+ }
+
// add dynamic prompts, mutating `graph`
addDynamicPromptsToGraph(graph, state);
diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts
index d6ab33a6ea..b0b1edde30 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/constants.ts
@@ -14,6 +14,8 @@ export const RESIZE = 'resize_image';
export const INPAINT = 'inpaint';
export const CONTROL_NET_COLLECT = 'control_net_collect';
export const DYNAMIC_PROMPT = 'dynamic_prompt';
+export const IMAGE_COLLECTION = 'image_collection';
+export const IMAGE_COLLECTION_ITERATE = 'image_collection_iterate';
// friendly graph ids
export const TEXT_TO_IMAGE_GRAPH = 'text_to_image_graph';
diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx
new file mode 100644
index 0000000000..7951df31a7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImage.tsx
@@ -0,0 +1,76 @@
+import { Flex, Icon, Text } from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppSelector } from 'app/store/storeHooks';
+import { useMemo } from 'react';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import IAIDndImage from 'common/components/IAIDndImage';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import { skipToken } from '@reduxjs/toolkit/dist/query';
+import { FaImage } from 'react-icons/fa';
+import { stateSelector } from 'app/store/store';
+import {
+ TypesafeDraggableData,
+ TypesafeDroppableData,
+} from 'app/components/ImageDnd/typesafeDnd';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
+
+const selector = createSelector(
+ [stateSelector],
+ (state) => {
+ const { initialImage } = state.generation;
+ const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch;
+ return {
+ initialImage,
+ useBatchAsInitialImage,
+ isResetButtonDisabled: useBatchAsInitialImage
+ ? imageNames.length === 0
+ : !initialImage,
+ };
+ },
+ defaultSelectorOptions
+);
+
+const InitialImage = () => {
+ const { initialImage } = useAppSelector(selector);
+
+ const {
+ currentData: imageDTO,
+ isLoading,
+ isError,
+ isSuccess,
+ } = useGetImageDTOQuery(initialImage?.imageName ?? skipToken);
+
+ const draggableData = useMemo(() => {
+ if (imageDTO) {
+ return {
+ id: 'initial-image',
+ payloadType: 'IMAGE_DTO',
+ payload: { imageDTO },
+ };
+ }
+ }, [imageDTO]);
+
+ const droppableData = useMemo(
+ () => ({
+ id: 'initial-image',
+ actionType: 'SET_INITIAL_IMAGE',
+ }),
+ []
+ );
+
+ return (
+
+ }
+ />
+ );
+};
+
+export default InitialImage;
diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx
index 19eb45a0a9..235628ef7d 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImageDisplay.tsx
@@ -1,34 +1,153 @@
-import { Flex } from '@chakra-ui/react';
-import InitialImagePreview from './InitialImagePreview';
+import { Flex, Spacer, Text } from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { clearInitialImage } from 'features/parameters/store/generationSlice';
+import { useCallback, useMemo } from 'react';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import { skipToken } from '@reduxjs/toolkit/dist/query';
+import IAIIconButton from 'common/components/IAIIconButton';
+import { FaLayerGroup, FaUndo, FaUpload } from 'react-icons/fa';
+import useImageUploader from 'common/hooks/useImageUploader';
+import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
+import IAIButton from 'common/components/IAIButton';
+import { stateSelector } from 'app/store/store';
+import {
+ asInitialImageToggled,
+ batchReset,
+} from 'features/batch/store/batchSlice';
+import BatchImageContainer from 'features/batch/components/BatchImageContainer';
+import { PostUploadAction } from 'services/api/thunks/image';
+import InitialImage from './InitialImage';
+
+const selector = createSelector(
+ [stateSelector],
+ (state) => {
+ const { initialImage } = state.generation;
+ const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch;
+ return {
+ initialImage,
+ useBatchAsInitialImage,
+ isResetButtonDisabled: useBatchAsInitialImage
+ ? imageNames.length === 0
+ : !initialImage,
+ };
+ },
+ defaultSelectorOptions
+);
const InitialImageDisplay = () => {
+ const { initialImage, useBatchAsInitialImage, isResetButtonDisabled } =
+ useAppSelector(selector);
+ const dispatch = useAppDispatch();
+ const { openUploader } = useImageUploader();
+
+ const {
+ currentData: imageDTO,
+ isLoading,
+ isError,
+ isSuccess,
+ } = useGetImageDTOQuery(initialImage?.imageName ?? skipToken);
+
+ const postUploadAction = useMemo(
+ () =>
+ useBatchAsInitialImage
+ ? { type: 'ADD_TO_BATCH' }
+ : { type: 'SET_INITIAL_IMAGE' },
+ [useBatchAsInitialImage]
+ );
+
+ const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
+ postUploadAction,
+ });
+
+ const handleReset = useCallback(() => {
+ if (useBatchAsInitialImage) {
+ dispatch(batchReset());
+ } else {
+ dispatch(clearInitialImage());
+ }
+ }, [dispatch, useBatchAsInitialImage]);
+
+ const handleUpload = useCallback(() => {
+ openUploader();
+ }, [openUploader]);
+
+ const handleClickUseBatch = useCallback(() => {
+ dispatch(asInitialImageToggled());
+ }, [dispatch]);
+
return (
-
+
+ Initial Image
+
+
+ }
+ isChecked={useBatchAsInitialImage}
+ onClick={handleClickUseBatch}
+ >
+ {useBatchAsInitialImage ? 'Batch' : 'Single'}
+
+ }
+ onClick={handleUpload}
+ {...getUploadButtonProps()}
+ />
+ }
+ onClick={handleReset}
+ isDisabled={isResetButtonDisabled}
+ />
+ {useBatchAsInitialImage ? : }
+
);
};
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
deleted file mode 100644
index 2a05eee9b4..0000000000
--- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ImageToImage/InitialImagePreview.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { Flex, Spacer, Text } from '@chakra-ui/react';
-import { createSelector } from '@reduxjs/toolkit';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import {
- clearInitialImage,
- initialImageChanged,
-} from 'features/parameters/store/generationSlice';
-import { useCallback } from 'react';
-import { generationSelector } from 'features/parameters/store/generationSelectors';
-import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
-import IAIDndImage from 'common/components/IAIDndImage';
-import { ImageDTO } from 'services/api/types';
-import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
-import { useGetImageDTOQuery } from 'services/api/endpoints/images';
-import { skipToken } from '@reduxjs/toolkit/dist/query';
-import IAIIconButton from 'common/components/IAIIconButton';
-import { FaUndo, FaUpload } from 'react-icons/fa';
-import useImageUploader from 'common/hooks/useImageUploader';
-import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
-
-const selector = createSelector(
- [generationSelector],
- (generation) => {
- const { initialImage } = generation;
- return {
- initialImage,
- };
- },
- defaultSelectorOptions
-);
-
-const InitialImagePreview = () => {
- const { initialImage } = useAppSelector(selector);
- const dispatch = useAppDispatch();
- const { openUploader } = useImageUploader();
-
- const {
- currentData: image,
- isLoading,
- isError,
- isSuccess,
- } = useGetImageDTOQuery(initialImage?.imageName ?? skipToken);
-
- const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
- postUploadAction: { type: 'SET_INITIAL_IMAGE' },
- });
-
- const handleDrop = useCallback(
- (droppedImage: ImageDTO) => {
- if (droppedImage.image_name === initialImage?.imageName) {
- return;
- }
- dispatch(initialImageChanged(droppedImage));
- },
- [dispatch, initialImage]
- );
-
- const handleReset = useCallback(() => {
- dispatch(clearInitialImage());
- }, [dispatch]);
-
- const handleUpload = useCallback(() => {
- openUploader();
- }, [openUploader]);
-
- return (
-
-
-
- Initial Image
-
-
- }
- onClick={handleUpload}
- {...getUploadButtonProps()}
- />
- }
- onClick={handleReset}
- isDisabled={!initialImage}
- />
-
- }
- isUploadDisabled={true}
- fitContainer
- />
-
-
- );
-};
-
-export default InitialImagePreview;
diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx
index 6b1dd46780..f30d9215e8 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedRandomize.tsx
@@ -1,10 +1,8 @@
import { ChangeEvent, memo } from 'react';
-
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { setShouldRandomizeSeed } from 'features/parameters/store/generationSlice';
import { useTranslation } from 'react-i18next';
-import { FormControl, FormLabel, Switch, Tooltip } from '@chakra-ui/react';
import IAISwitch from 'common/components/IAISwitch';
const ParamSeedRandomize = () => {
@@ -25,32 +23,6 @@ const ParamSeedRandomize = () => {
onChange={handleChangeShouldRandomizeSeed}
/>
);
-
- return (
-
-
- {t('parameters.randomizeSeed')}
-
-
-
- );
};
export default memo(ParamSeedRandomize);
diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx
index 6442e34268..e71e2c36c0 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Seed/ParamSeedShuffle.tsx
@@ -1,8 +1,6 @@
-import { Box } from '@chakra-ui/react';
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from 'app/constants';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import randomInt from 'common/util/randomInt';
import { setSeed } from 'features/parameters/store/generationSlice';
@@ -29,16 +27,4 @@ export default function ParamSeedShuffle() {
icon={}
/>
);
-
- return (
-
- {t('parameters.shuffle')}
-
- );
}
diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx
index 63258d0a81..e2338e2575 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx
@@ -1,4 +1,4 @@
-import { Box } from '@chakra-ui/react';
+import { Box, ChakraProps } from '@chakra-ui/react';
import { userInvoked } from 'app/store/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton, { IAIButtonProps } from 'common/components/IAIButton';
@@ -14,6 +14,16 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaPlay } from 'react-icons/fa';
+const IN_PROGRESS_STYLES: ChakraProps['sx'] = {
+ _disabled: {
+ bg: 'none',
+ cursor: 'not-allowed',
+ _hover: {
+ bg: 'none',
+ },
+ },
+};
+
interface InvokeButton
extends Omit {
iconButton?: boolean;
@@ -24,6 +34,7 @@ export default function InvokeButton(props: InvokeButton) {
const dispatch = useAppDispatch();
const isReady = useIsReadyToInvoke();
const activeTabName = useAppSelector(activeTabNameSelector);
+ const isProcessing = useAppSelector((state) => state.system.isProcessing);
const handleInvoke = useCallback(() => {
dispatch(clampSymmetrySteps());
@@ -69,19 +80,16 @@ export default function InvokeButton(props: InvokeButton) {
icon={}
isDisabled={!isReady}
onClick={handleInvoke}
- flexGrow={1}
- w="100%"
tooltip={t('parameters.invoke')}
tooltipProps={{ placement: 'top' }}
colorScheme="accent"
id="invoke-button"
- _disabled={{
- background: 'none',
- _hover: {
- background: 'none',
- },
- }}
{...rest}
+ sx={{
+ w: 'full',
+ flexGrow: 1,
+ ...(isProcessing ? IN_PROGRESS_STYLES : {}),
+ }}
/>
) : (
Invoke
diff --git a/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx b/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx
index 2a14af32b2..8b098936b3 100644
--- a/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx
+++ b/invokeai/frontend/web/src/features/system/components/ModelSelect.tsx
@@ -65,6 +65,8 @@ const ModelSelect = () => {
);
useEffect(() => {
+ // If the selected model is not in the list of models, select the first one
+ // Handles first-run setting of models, and the user deleting the previously-selected model
if (selectedModelId && pipelineModels?.ids.includes(selectedModelId)) {
return;
}
@@ -90,8 +92,9 @@ const ModelSelect = () => {
tooltip={selectedModel?.description}
label={t('modelManager.model')}
value={selectedModelId}
- placeholder="Pick one"
+ placeholder={data.length > 0 ? 'Select a model' : 'No models detected!'}
data={data}
+ error={data.length === 0}
onChange={handleChangeModel}
/>
);
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index 6bbeedcaaa..3cc90013f7 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -32,11 +32,12 @@ import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent
import TextToImageTab from './tabs/TextToImage/TextToImageTab';
import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab';
import NodesTab from './tabs/Nodes/NodesTab';
-import { FaFont, FaImage } from 'react-icons/fa';
+import { FaFont, FaImage, FaLayerGroup } from 'react-icons/fa';
import ResizeHandle from './tabs/ResizeHandle';
import ImageTab from './tabs/ImageToImage/ImageToImageTab';
import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator';
import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize';
+import BatchTab from './tabs/Batch/BatchTab';
export interface InvokeTabInfo {
id: InvokeTabName;
@@ -65,6 +66,11 @@ const tabs: InvokeTabInfo[] = [
icon: ,
content: ,
},
+ {
+ id: 'batch',
+ icon: ,
+ content: ,
+ },
];
const enabledTabsSelector = createSelector(
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx
index b41017c2c9..0777463ec4 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersDrawer.tsx
@@ -71,7 +71,15 @@ const ParametersDrawer = () => {
onClose={handleClosePanel}
>
{
-
- {drawerContent}
-
+
+ {drawerContent}
+
);
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx
index d47ca3e1ba..f327e10efc 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPinnedWrapper.tsx
@@ -42,18 +42,10 @@ const ParametersPinnedWrapper = (props: ParametersPinnedWrapperProps) => {
h: 'full',
w: 'full',
position: 'absolute',
+ overflowY: 'auto',
}}
>
-
-
- {props.children}
-
-
+ {props.children}
{
+ const dispatch = useAppDispatch();
+ const panelGroupRef = useRef(null);
+
+ const handleDoubleClickHandle = useCallback(() => {
+ if (!panelGroupRef.current) {
+ return;
+ }
+
+ panelGroupRef.current.setLayout([50, 50]);
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+export default memo(ImageToImageTab);
diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx
index 77085bcb75..5474fe8358 100644
--- a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasContent.tsx
@@ -14,8 +14,12 @@ import UnifiedCanvasToolbarBeta from './UnifiedCanvasBeta/UnifiedCanvasToolbarBe
import UnifiedCanvasToolSettingsBeta from './UnifiedCanvasBeta/UnifiedCanvasToolSettingsBeta';
import { ImageDTO } from 'services/api/types';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
-import { useDroppable } from '@dnd-kit/core';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
+import {
+ CanvasInitialImageDropData,
+ isValidDrop,
+ useDroppable,
+} from 'app/components/ImageDnd/typesafeDnd';
const selector = createSelector(
[canvasSelector, uiSelector],
@@ -30,28 +34,24 @@ const selector = createSelector(
defaultSelectorOptions
);
+const droppableData: CanvasInitialImageDropData = {
+ id: 'canvas-intial-image',
+ actionType: 'SET_CANVAS_INITIAL_IMAGE',
+};
+
const UnifiedCanvasContent = () => {
const dispatch = useAppDispatch();
const { doesCanvasNeedScaling, shouldUseCanvasBetaLayout } =
useAppSelector(selector);
- const onDrop = useCallback(
- (droppedImage: ImageDTO) => {
- dispatch(setInitialCanvasImage(droppedImage));
- },
- [dispatch]
- );
-
const {
isOver,
setNodeRef: setDroppableRef,
active,
} = useDroppable({
id: 'unifiedCanvas',
- data: {
- handleDrop: onDrop,
- },
+ data: droppableData,
});
useLayoutEffect(() => {
@@ -97,7 +97,12 @@ const UnifiedCanvasContent = () => {
{doesCanvasNeedScaling ? : }
- {active && }
+ {isValidDrop(droppableData, active) && (
+
+ )}
@@ -139,7 +144,12 @@ const UnifiedCanvasContent = () => {
>
{doesCanvasNeedScaling ? : }
- {active && }
+ {isValidDrop(droppableData, active) && (
+
+ )}
diff --git a/invokeai/frontend/web/src/features/ui/store/tabMap.ts b/invokeai/frontend/web/src/features/ui/store/tabMap.ts
index becf52886e..4f683c95cb 100644
--- a/invokeai/frontend/web/src/features/ui/store/tabMap.ts
+++ b/invokeai/frontend/web/src/features/ui/store/tabMap.ts
@@ -4,6 +4,7 @@ export const tabMap = [
// 'generate',
'unifiedCanvas',
'nodes',
+ 'batch',
// 'postprocessing',
// 'training',
] as const;
diff --git a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts
index cef9ab7cae..a0db3f3dff 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts
@@ -1,6 +1,7 @@
import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types';
import { api } from '..';
import { paths } from '../schema';
+import { imagesApi } from './images';
type ListBoardImagesArg =
paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] &
@@ -41,8 +42,22 @@ export const boardImagesApi = api.injectEndpoints({
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg.board_id },
- { type: 'Image', id: arg.image_name },
],
+ async onQueryStarted(
+ { image_name, ...patch },
+ { dispatch, queryFulfilled }
+ ) {
+ const patchResult = dispatch(
+ imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => {
+ Object.assign(draft, patch);
+ })
+ );
+ try {
+ await queryFulfilled;
+ } catch {
+ patchResult.undo();
+ }
+ },
}),
removeImageFromBoard: build.mutation({
@@ -53,8 +68,22 @@ export const boardImagesApi = api.injectEndpoints({
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg.board_id },
- { type: 'Image', id: arg.image_name },
],
+ async onQueryStarted(
+ { image_name, ...patch },
+ { dispatch, queryFulfilled }
+ ) {
+ const patchResult = dispatch(
+ imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => {
+ Object.assign(draft, { board_id: null });
+ })
+ );
+ try {
+ await queryFulfilled;
+ } catch {
+ patchResult.undo();
+ }
+ },
}),
}),
});
diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts
index 767fe7b2b3..e542cd4ba2 100644
--- a/invokeai/frontend/web/src/services/api/schema.d.ts
+++ b/invokeai/frontend/web/src/services/api/schema.d.ts
@@ -76,10 +76,10 @@ export type paths = {
*/
get: operations["list_models"];
/**
- * Update Model
+ * Import Model
* @description Add Model
*/
- post: operations["update_model"];
+ post: operations["import_model"];
};
"/api/v1/models/{model_name}": {
/**
@@ -650,7 +650,7 @@ export type components = {
end_step_percent: number;
/**
* Control Mode
- * @description The contorl mode to use
+ * @description The control mode to use
* @default balanced
* @enum {string}
*/
@@ -1030,7 +1030,7 @@ export type components = {
* @description The nodes in this graph
*/
nodes?: {
- [key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined;
+ [key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined;
};
/**
* Edges
@@ -1073,7 +1073,7 @@ export type components = {
* @description The results of node executions
*/
results: {
- [key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined;
+ [key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined;
};
/**
* Errors
@@ -1276,6 +1276,53 @@ export type components = {
*/
channel?: "A" | "R" | "G" | "B";
};
+ /**
+ * ImageCollectionInvocation
+ * @description Load a collection of images and provide it as output.
+ */
+ ImageCollectionInvocation: {
+ /**
+ * Id
+ * @description The id of this node. Must be unique among all nodes.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this node is an intermediate node.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Type
+ * @default image_collection
+ * @enum {string}
+ */
+ type?: "image_collection";
+ /**
+ * Images
+ * @description The image collection to load
+ * @default []
+ */
+ images?: (components["schemas"]["ImageField"])[];
+ };
+ /**
+ * ImageCollectionOutput
+ * @description A collection of images
+ */
+ ImageCollectionOutput: {
+ /**
+ * Type
+ * @default image_collection
+ * @enum {string}
+ */
+ type: "image_collection";
+ /**
+ * Collection
+ * @description The output images
+ * @default []
+ */
+ collection: (components["schemas"]["ImageField"])[];
+ };
/**
* ImageConvertInvocation
* @description Converts an image to a different mode.
@@ -1928,6 +1975,20 @@ export type components = {
*/
thumbnail_url: string;
};
+ /** ImportModelRequest */
+ ImportModelRequest: {
+ /**
+ * Name
+ * @description A model path, repo_id or URL to import
+ */
+ name: string;
+ /**
+ * Prediction Type
+ * @description Prediction type for SDv2 checkpoint files
+ * @enum {string}
+ */
+ prediction_type?: "epsilon" | "v_prediction" | "sample";
+ };
/**
* InfillColorInvocation
* @description Infills transparent areas of an image with a solid color
@@ -2440,6 +2501,64 @@ export type components = {
*/
strength?: number;
};
+ /**
+ * LeresImageProcessorInvocation
+ * @description Applies leres processing to image
+ */
+ LeresImageProcessorInvocation: {
+ /**
+ * Id
+ * @description The id of this node. Must be unique among all nodes.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this node is an intermediate node.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Type
+ * @default leres_image_processor
+ * @enum {string}
+ */
+ type?: "leres_image_processor";
+ /**
+ * Image
+ * @description The image to process
+ */
+ image?: components["schemas"]["ImageField"];
+ /**
+ * Thr A
+ * @description Leres parameter `thr_a`
+ * @default 0
+ */
+ thr_a?: number;
+ /**
+ * Thr B
+ * @description Leres parameter `thr_b`
+ * @default 0
+ */
+ thr_b?: number;
+ /**
+ * Boost
+ * @description Whether to use boost mode
+ * @default false
+ */
+ boost?: boolean;
+ /**
+ * Detect Resolution
+ * @description The pixel resolution for detection
+ * @default 512
+ */
+ detect_resolution?: number;
+ /**
+ * Image Resolution
+ * @description The pixel resolution for the output image
+ * @default 512
+ */
+ image_resolution?: number;
+ };
/**
* LineartAnimeImageProcessorInvocation
* @description Applies line art anime processing to image
@@ -2907,7 +3026,7 @@ export type components = {
* @description An enumeration.
* @enum {string}
*/
- ModelType: "pipeline" | "vae" | "lora" | "controlnet" | "embedding";
+ ModelType: "main" | "vae" | "lora" | "controlnet" | "embedding";
/**
* ModelVariantType
* @description An enumeration.
@@ -2993,12 +3112,6 @@ export type components = {
* @default 512
*/
height?: number;
- /**
- * Perlin
- * @description The amount of perlin noise to add to the noise
- * @default 0
- */
- perlin?: number;
/**
* Use Cpu
* @description Use CPU for noise generation (for reproducible results across platforms)
@@ -3697,11 +3810,33 @@ export type components = {
antialias?: boolean;
};
/**
- * SchedulerPredictionType
- * @description An enumeration.
- * @enum {string}
+ * SegmentAnythingProcessorInvocation
+ * @description Applies segment anything processing to image
*/
- SchedulerPredictionType: "epsilon" | "v_prediction" | "sample";
+ SegmentAnythingProcessorInvocation: {
+ /**
+ * Id
+ * @description The id of this node. Must be unique among all nodes.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this node is an intermediate node.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Type
+ * @default segment_anything_processor
+ * @enum {string}
+ */
+ type?: "segment_anything_processor";
+ /**
+ * Image
+ * @description The image to process
+ */
+ image?: components["schemas"]["ImageField"];
+ };
/**
* ShowImageInvocation
* @description Displays a provided image, and passes it forward in the pipeline.
@@ -3739,7 +3874,7 @@ export type components = {
* Type
* @enum {string}
*/
- type: "pipeline";
+ type: "main";
/** Path */
path: string;
/** Description */
@@ -3753,7 +3888,7 @@ export type components = {
/** Vae */
vae?: string;
/** Config */
- config?: string;
+ config: string;
variant: components["schemas"]["ModelVariantType"];
};
/** StableDiffusion1ModelDiffusersConfig */
@@ -3765,7 +3900,7 @@ export type components = {
* Type
* @enum {string}
*/
- type: "pipeline";
+ type: "main";
/** Path */
path: string;
/** Description */
@@ -3789,7 +3924,7 @@ export type components = {
* Type
* @enum {string}
*/
- type: "pipeline";
+ type: "main";
/** Path */
path: string;
/** Description */
@@ -3803,11 +3938,8 @@ export type components = {
/** Vae */
vae?: string;
/** Config */
- config?: string;
+ config: string;
variant: components["schemas"]["ModelVariantType"];
- prediction_type: components["schemas"]["SchedulerPredictionType"];
- /** Upcast Attention */
- upcast_attention: boolean;
};
/** StableDiffusion2ModelDiffusersConfig */
StableDiffusion2ModelDiffusersConfig: {
@@ -3818,7 +3950,7 @@ export type components = {
* Type
* @enum {string}
*/
- type: "pipeline";
+ type: "main";
/** Path */
path: string;
/** Description */
@@ -3832,9 +3964,6 @@ export type components = {
/** Vae */
vae?: string;
variant: components["schemas"]["ModelVariantType"];
- prediction_type: components["schemas"]["SchedulerPredictionType"];
- /** Upcast Attention */
- upcast_attention: boolean;
};
/**
* StepParamEasingInvocation
@@ -4044,6 +4173,40 @@ export type components = {
model_format: null;
error?: components["schemas"]["ModelError"];
};
+ /**
+ * TileResamplerProcessorInvocation
+ * @description Base class for invocations that preprocess images for ControlNet
+ */
+ TileResamplerProcessorInvocation: {
+ /**
+ * Id
+ * @description The id of this node. Must be unique among all nodes.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this node is an intermediate node.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Type
+ * @default tile_image_processor
+ * @enum {string}
+ */
+ type?: "tile_image_processor";
+ /**
+ * Image
+ * @description The image to process
+ */
+ image?: components["schemas"]["ImageField"];
+ /**
+ * Down Sampling Rate
+ * @description Down sampling rate
+ * @default 1
+ */
+ down_sampling_rate?: number;
+ };
/** UNetField */
UNetField: {
/**
@@ -4311,7 +4474,7 @@ export type operations = {
};
requestBody: {
content: {
- "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
+ "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
};
};
responses: {
@@ -4348,7 +4511,7 @@ export type operations = {
};
requestBody: {
content: {
- "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
+ "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["PipelineModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
};
};
responses: {
@@ -4568,13 +4731,13 @@ export type operations = {
};
};
/**
- * Update Model
+ * Import Model
* @description Add Model
*/
- update_model: {
+ import_model: {
requestBody: {
content: {
- "application/json": components["schemas"]["CreateModelRequest"];
+ "application/json": components["schemas"]["ImportModelRequest"];
};
};
responses: {
@@ -4947,6 +5110,10 @@ export type operations = {
*/
delete_board: {
parameters: {
+ query?: {
+ /** @description Permanently delete all images on the board */
+ include_images?: boolean;
+ };
path: {
/** @description The id of board to delete */
board_id: string;
diff --git a/invokeai/frontend/web/src/services/api/thunks/image.ts b/invokeai/frontend/web/src/services/api/thunks/image.ts
index a8b3dec5a7..d6e502bc54 100644
--- a/invokeai/frontend/web/src/services/api/thunks/image.ts
+++ b/invokeai/frontend/web/src/services/api/thunks/image.ts
@@ -1,6 +1,6 @@
import queryString from 'query-string';
import { createAppAsyncThunk } from 'app/store/storeUtils';
-import { selectImagesAll } from 'features/gallery/store/imagesSlice';
+import { selectImagesAll } from 'features/gallery/store/gallerySlice';
import { size } from 'lodash-es';
import { paths } from 'services/api/schema';
import { $client } from 'services/api/client';
@@ -112,6 +112,10 @@ type UploadedToastAction = {
type: 'TOAST_UPLOADED';
};
+type AddToBatchAction = {
+ type: 'ADD_TO_BATCH';
+};
+
export type PostUploadAction =
| ControlNetAction
| InitialImageAction
@@ -119,12 +123,12 @@ export type PostUploadAction =
| CanvasInitialImageAction
| CanvasMergedAction
| CanvasSavedToGalleryAction
- | UploadedToastAction;
+ | UploadedToastAction
+ | AddToBatchAction;
type UploadImageArg =
paths['/api/v1/images/']['post']['parameters']['query'] & {
file: File;
- // file: paths['/api/v1/images/']['post']['requestBody']['content']['multipart/form-data']['file'];
postUploadAction?: PostUploadAction;
};
@@ -284,8 +288,7 @@ export const receivedPageOfImages = createAppAsyncThunk<
const { get } = $client.get();
const state = getState();
- const { categories } = state.images;
- const { selectedBoardId } = state.boards;
+ const { categories, selectedBoardId } = state.gallery;
const images = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
diff --git a/invokeai/frontend/web/src/services/api/types.d.ts b/invokeai/frontend/web/src/services/api/types.d.ts
index 2a2f90f434..12c072509b 100644
--- a/invokeai/frontend/web/src/services/api/types.d.ts
+++ b/invokeai/frontend/web/src/services/api/types.d.ts
@@ -58,6 +58,7 @@ export type LatentsToLatentsInvocation = N<'LatentsToLatentsInvocation'>;
export type ImageToLatentsInvocation = N<'ImageToLatentsInvocation'>;
export type LatentsToImageInvocation = N<'LatentsToImageInvocation'>;
export type PipelineModelLoaderInvocation = N<'PipelineModelLoaderInvocation'>;
+export type ImageCollectionInvocation = N<'ImageCollectionInvocation'>;
// ControlNet Nodes
export type ControlNetInvocation = N<'ControlNetInvocation'>;
diff --git a/invokeai/frontend/web/src/theme/components/button.ts b/invokeai/frontend/web/src/theme/components/button.ts
index 75662f7d42..7bb8a39a71 100644
--- a/invokeai/frontend/web/src/theme/components/button.ts
+++ b/invokeai/frontend/web/src/theme/components/button.ts
@@ -7,10 +7,10 @@ const invokeAI = defineStyle((props) => {
if (c === 'base') {
const _disabled = {
- bg: mode('base.200', 'base.700')(props),
- color: mode('base.500', 'base.150')(props),
+ bg: mode('base.150', 'base.700')(props),
+ color: mode('base.500', 'base.500')(props),
svg: {
- fill: mode('base.500', 'base.150')(props),
+ fill: mode('base.500', 'base.500')(props),
},
opacity: 1,
};
@@ -30,7 +30,6 @@ const invokeAI = defineStyle((props) => {
'drop-shadow(0px 0px 0.3rem var(--invokeai-colors-base-800))'
)(props),
},
- _disabled,
_hover: {
bg: mode('base.300', 'base.500')(props),
color: mode('base.900', 'base.50')(props),
@@ -39,34 +38,16 @@ const invokeAI = defineStyle((props) => {
},
_disabled,
},
- _checked: {
- bg: mode('accent.400', 'accent.600')(props),
- color: mode('base.50', 'base.100')(props),
- svg: {
- fill: mode(`${c}.50`, `${c}.100`)(props),
- filter: mode(
- `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-600))`,
- `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-800))`
- )(props),
- },
- _disabled,
- _hover: {
- bg: mode('accent.500', 'accent.500')(props),
- color: mode('white', 'base.50')(props),
- svg: {
- fill: mode('white', 'base.50')(props),
- },
- _disabled,
- },
- },
+ _disabled,
};
}
const _disabled = {
- bg: mode(`${c}.200`, `${c}.700`)(props),
- color: mode(`${c}.100`, `${c}.150`)(props),
+ bg: mode(`${c}.250`, `${c}.700`)(props),
+ color: mode(`${c}.50`, `${c}.500`)(props),
svg: {
- fill: mode(`${c}.100`, `${c}.150`)(props),
+ fill: mode(`${c}.50`, `${c}.500`)(props),
+ filter: 'unset',
},
opacity: 1,
filter: mode(undefined, 'saturate(65%)')(props),
@@ -78,7 +59,7 @@ const invokeAI = defineStyle((props) => {
borderRadius: 'base',
textShadow: mode(
`0 0 0.3rem var(--invokeai-colors-${c}-600)`,
- `0 0 0.3rem var(--invokeai-colors-${c}-900)`
+ `0 0 0.3rem var(--invokeai-colors-${c}-800)`
)(props),
svg: {
fill: mode(`base.50`, `base.100`)(props),
@@ -96,26 +77,6 @@ const invokeAI = defineStyle((props) => {
},
_disabled,
},
- _checked: {
- bg: mode('accent.400', 'accent.600')(props),
- color: mode('base.50', 'base.100')(props),
- svg: {
- fill: mode(`base.50`, `base.100`)(props),
- filter: mode(
- `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-600))`,
- `drop-shadow(0px 0px 0.3rem var(--invokeai-colors-${c}-800))`
- )(props),
- },
- _disabled,
- _hover: {
- bg: mode('accent.500', 'accent.500')(props),
- color: mode('white', 'base.50')(props),
- svg: {
- fill: mode('white', 'base.50')(props),
- },
- _disabled,
- },
- },
};
});
diff --git a/invokeai/frontend/web/src/theme/components/menu.ts b/invokeai/frontend/web/src/theme/components/menu.ts
index 02f75087ed..324720a040 100644
--- a/invokeai/frontend/web/src/theme/components/menu.ts
+++ b/invokeai/frontend/web/src/theme/components/menu.ts
@@ -22,6 +22,8 @@ const invokeAI = definePartsStyle((props) => ({
list: {
zIndex: 9999,
bg: mode('base.200', 'base.800')(props),
+ shadow: 'dark-lg',
+ border: 'none',
},
item: {
// this will style the MenuItem and MenuItemOption components
diff --git a/invokeai/frontend/web/src/theme/components/progress.ts b/invokeai/frontend/web/src/theme/components/progress.ts
index 6a8915422f..71231869ce 100644
--- a/invokeai/frontend/web/src/theme/components/progress.ts
+++ b/invokeai/frontend/web/src/theme/components/progress.ts
@@ -9,14 +9,7 @@ const { defineMultiStyleConfig, definePartsStyle } =
createMultiStyleConfigHelpers(parts.keys);
const invokeAIFilledTrack = defineStyle((_props) => ({
- bg: 'accent.500',
- // TODO: the animation is nice but looks weird bc it is substantially longer than each step
- // so we get to 100% long before it finishes
- // transition: 'width 0.2s ease-in-out',
- _indeterminate: {
- bgGradient:
- 'linear(to-r, transparent 0%, accent.500 50%, transparent 100%);',
- },
+ bg: 'accentAlpha.500',
}));
const invokeAITrack = defineStyle((_props) => {
diff --git a/invokeai/frontend/web/src/theme/components/skeleton.ts b/invokeai/frontend/web/src/theme/components/skeleton.ts
new file mode 100644
index 0000000000..8ee97e0fb8
--- /dev/null
+++ b/invokeai/frontend/web/src/theme/components/skeleton.ts
@@ -0,0 +1,25 @@
+import { defineStyle, defineStyleConfig, cssVar } from '@chakra-ui/react';
+
+const $startColor = cssVar('skeleton-start-color');
+const $endColor = cssVar('skeleton-end-color');
+
+const invokeAI = defineStyle({
+ borderRadius: 'base',
+ maxW: 'full',
+ maxH: 'full',
+ _light: {
+ [$startColor.variable]: 'colors.base.250',
+ [$endColor.variable]: 'colors.base.450',
+ },
+ _dark: {
+ [$startColor.variable]: 'colors.base.700',
+ [$endColor.variable]: 'colors.base.500',
+ },
+});
+
+export const skeletonTheme = defineStyleConfig({
+ variants: { invokeAI },
+ defaultProps: {
+ variant: 'invokeAI',
+ },
+});
diff --git a/invokeai/frontend/web/src/theme/theme.ts b/invokeai/frontend/web/src/theme/theme.ts
index 76b4aaaacc..03d1f640ac 100644
--- a/invokeai/frontend/web/src/theme/theme.ts
+++ b/invokeai/frontend/web/src/theme/theme.ts
@@ -19,6 +19,7 @@ import { tabsTheme } from './components/tabs';
import { textTheme } from './components/text';
import { textareaTheme } from './components/textarea';
import { tooltipTheme } from './components/tooltip';
+import { skeletonTheme } from './components/skeleton';
export const theme: ThemeOverride = {
config: {
@@ -68,6 +69,11 @@ export const theme: ThemeOverride = {
working: `0 0 7px var(--invokeai-colors-working-400)`,
error: `0 0 7px var(--invokeai-colors-error-400)`,
},
+ selected: {
+ light:
+ '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-400)',
+ dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-400)',
+ },
nodeSelectedOutline: `0 0 0 2px var(--invokeai-colors-base-500)`,
},
colors: InvokeAIColors,
@@ -82,6 +88,7 @@ export const theme: ThemeOverride = {
Switch: switchTheme,
NumberInput: numberInputTheme,
Select: selectTheme,
+ Skeleton: skeletonTheme,
Slider: sliderTheme,
Popover: popoverTheme,
Modal: modalTheme,
diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock
index 45bdf25fae..64841bc39f 100644
--- a/invokeai/frontend/web/yarn.lock
+++ b/invokeai/frontend/web/yarn.lock
@@ -4928,7 +4928,7 @@ open@^8.4.0:
is-docker "^2.1.1"
is-wsl "^2.2.0"
-openapi-fetch@^0.4.0:
+openapi-fetch@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/openapi-fetch/-/openapi-fetch-0.4.0.tgz#45c368321ba6c15bc2e168e7dc3fbf322e9cca6d"
integrity sha512-4lzZtH5J1ZH9EXfmpcmKi0gOgjy0hc6BAcucAdCmLHY6jZopMeGP51vD3Cd4rE1nTFMfJzmYDc8ar0+364gBVw==