Merge branch 'main' into release/invokeai-3-0-beta

This commit is contained in:
Lincoln Stein 2023-07-19 12:09:32 -04:00 committed by GitHub
commit 8439e30798
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 3307 additions and 2852 deletions

View File

@ -24,11 +24,14 @@ async def create_board_image(
): ):
"""Creates a board_image""" """Creates a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to add to board") raise HTTPException(status_code=500, detail="Failed to add to board")
@board_images_router.delete( @board_images_router.delete(
"/", "/",
operation_id="remove_board_image", operation_id="remove_board_image",
@ -43,27 +46,10 @@ async def remove_board_image(
): ):
"""Deletes a board_image""" """Deletes a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.remove_image_from_board(
board_id=board_id, image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@board_images_router.get(
"/{board_id}",
operation_id="list_board_images",
response_model=OffsetPaginatedResults[ImageDTO],
)
async def list_board_images(
board_id: str = Path(description="The id of the board"),
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of boards per page"),
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a list of images for a board"""
results = ApiDependencies.invoker.services.board_images.get_images_for_board(
board_id,
)
return results

View File

@ -1,16 +1,28 @@
from typing import Optional, Union from typing import Optional, Union
from fastapi import Body, HTTPException, Path, Query from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from invokeai.app.services.board_record_storage import BoardChanges from invokeai.app.services.board_record_storage import BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO from invokeai.app.services.models.board_record import BoardDTO
from ..dependencies import ApiDependencies from ..dependencies import ApiDependencies
boards_router = APIRouter(prefix="/v1/boards", tags=["boards"]) boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
class DeleteBoardResult(BaseModel):
board_id: str = Field(description="The id of the board that was deleted.")
deleted_board_images: list[str] = Field(
description="The image names of the board-images relationships that were deleted."
)
deleted_images: list[str] = Field(
description="The names of the images that were deleted."
)
@boards_router.post( @boards_router.post(
"/", "/",
operation_id="create_board", operation_id="create_board",
@ -69,25 +81,42 @@ async def update_board(
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@boards_router.delete("/{board_id}", operation_id="delete_board") @boards_router.delete(
"/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult
)
async def delete_board( async def delete_board(
board_id: str = Path(description="The id of board to delete"), board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query( include_images: Optional[bool] = Query(
description="Permanently delete all images on the board", default=False description="Permanently delete all images on the board", default=False
), ),
) -> None: ) -> DeleteBoardResult:
"""Deletes a board""" """Deletes a board"""
try: try:
if include_images is True: if include_images is True:
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
)
ApiDependencies.invoker.services.images.delete_images_on_board( ApiDependencies.invoker.services.images.delete_images_on_board(
board_id=board_id board_id=board_id
) )
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=[],
deleted_images=deleted_images,
)
else: else:
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
)
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=deleted_board_images,
deleted_images=[],
)
except Exception as e: except Exception as e:
# TODO: Does this need any exception handling at all? raise HTTPException(status_code=500, detail="Failed to delete board")
pass
@boards_router.get( @boards_router.get(
@ -115,3 +144,19 @@ async def list_boards(
status_code=400, status_code=400,
detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'", detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'",
) )
@boards_router.get(
"/{board_id}/image_names",
operation_id="list_all_board_image_names",
response_model=list[str],
)
async def list_all_board_image_names(
board_id: str = Path(description="The id of the board"),
) -> list[str]:
"""Gets a list of images for a board"""
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id,
)
return image_names

View File

@ -84,6 +84,17 @@ async def delete_image(
# TODO: Does this need any exception handling at all? # TODO: Does this need any exception handling at all?
pass pass
@images_router.post("/clear-intermediates", operation_id="clear_intermediates")
async def clear_intermediates() -> int:
"""Clears first 100 intermediates"""
try:
count_deleted = ApiDependencies.invoker.services.images.delete_many(is_intermediate=True)
return count_deleted
except Exception as e:
# TODO: Does this need any exception handling at all?
pass
@images_router.patch( @images_router.patch(
"/{image_name}", "/{image_name}",
@ -234,16 +245,16 @@ async def get_image_urls(
) )
async def list_image_dtos( async def list_image_dtos(
image_origin: Optional[ResourceOrigin] = Query( image_origin: Optional[ResourceOrigin] = Query(
default=None, description="The origin of images to list" default=None, description="The origin of images to list."
), ),
categories: Optional[list[ImageCategory]] = Query( categories: Optional[list[ImageCategory]] = Query(
default=None, description="The categories of image to include" default=None, description="The categories of image to include."
), ),
is_intermediate: Optional[bool] = Query( is_intermediate: Optional[bool] = Query(
default=None, description="Whether to list intermediate images" default=None, description="Whether to list intermediate images."
), ),
board_id: Optional[str] = Query( board_id: Optional[str] = Query(
default=None, description="The board id to filter by" default=None, description="The board id to filter by. Use 'none' to find images without a board."
), ),
offset: int = Query(default=0, description="The page offset"), offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of images per page"), limit: int = Query(default=10, description="The number of images per page"),

View File

@ -22,7 +22,7 @@ from ...backend.stable_diffusion.diffusers_pipeline import (
from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \ from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \
PostprocessingSettings PostprocessingSettings
from ...backend.stable_diffusion.schedulers import SCHEDULER_MAP from ...backend.stable_diffusion.schedulers import SCHEDULER_MAP
from ...backend.util.devices import torch_dtype from ...backend.util.devices import choose_torch_device, torch_dtype
from ..models.image import ImageCategory, ImageField, ResourceOrigin from ..models.image import ImageCategory, ImageField, ResourceOrigin
from .baseinvocation import (BaseInvocation, BaseInvocationOutput, from .baseinvocation import (BaseInvocation, BaseInvocationOutput,
InvocationConfig, InvocationContext) InvocationConfig, InvocationContext)
@ -38,7 +38,6 @@ from diffusers.models.attention_processor import (
XFormersAttnProcessor, XFormersAttnProcessor,
) )
class LatentsField(BaseModel): class LatentsField(BaseModel):
"""A latents field used for passing latents between invocations""" """A latents field used for passing latents between invocations"""

View File

@ -32,11 +32,11 @@ class BoardImageRecordStorageBase(ABC):
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageRecord]: ) -> list[str]:
"""Gets images for a board.""" """Gets all board images for a board, as a list of the image names."""
pass pass
@abstractmethod @abstractmethod
@ -211,6 +211,26 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
items=images, offset=offset, limit=limit, total=count items=images, offset=offset, limit=limit, total=count
) )
def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT image_name
FROM board_images
WHERE board_id = ?;
""",
(board_id,),
)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
image_names = list(map(lambda r: r[0], result))
return image_names
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
def get_board_for_image( def get_board_for_image(
self, self,
image_name: str, image_name: str,

View File

@ -38,11 +38,11 @@ class BoardImagesServiceABC(ABC):
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> list[str]:
"""Gets images for a board.""" """Gets all board images for a board, as a list of the image names."""
pass pass
@abstractmethod @abstractmethod
@ -98,30 +98,13 @@ class BoardImagesService(BoardImagesServiceABC):
) -> None: ) -> None:
self._services.board_image_records.remove_image_from_board(board_id, image_name) self._services.board_image_records.remove_image_from_board(board_id, image_name)
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> list[str]:
image_records = self._services.board_image_records.get_images_for_board( return self._services.board_image_records.get_all_board_image_names_for_board(
board_id board_id
) )
image_dtos = list(
map(
lambda r: image_record_to_dto(
r,
self._services.urls.get_image_url(r.image_name),
self._services.urls.get_image_url(r.image_name, True),
board_id,
),
image_records.items,
)
)
return OffsetPaginatedResults[ImageDTO](
items=image_dtos,
offset=image_records.offset,
limit=image_records.limit,
total=image_records.total,
)
def get_board_for_image( def get_board_for_image(
self, self,
@ -136,7 +119,7 @@ def board_record_to_dto(
) -> BoardDTO: ) -> BoardDTO:
"""Converts a board record to a board DTO.""" """Converts a board record to a board DTO."""
return BoardDTO( return BoardDTO(
**board_record.dict(exclude={'cover_image_name'}), **board_record.dict(exclude={"cover_image_name"}),
cover_image_name=cover_image_name, cover_image_name=cover_image_name,
image_count=image_count, image_count=image_count,
) )

View File

@ -141,7 +141,7 @@ class EventServiceBase:
model_type=model_type, model_type=model_type,
submodel=submodel, submodel=submodel,
hash=model_info.hash, hash=model_info.hash,
location=model_info.location, location=str(model_info.location),
precision=str(model_info.precision), precision=str(model_info.precision),
), ),
) )

View File

@ -10,7 +10,10 @@ from pydantic.generics import GenericModel
from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.services.models.image_record import ( from invokeai.app.services.models.image_record import (
ImageRecord, ImageRecordChanges, deserialize_image_record) ImageRecord,
ImageRecordChanges,
deserialize_image_record,
)
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
@ -97,8 +100,8 @@ class ImageRecordStorageBase(ABC):
@abstractmethod @abstractmethod
def get_many( def get_many(
self, self,
offset: int = 0, offset: Optional[int] = None,
limit: int = 10, limit: Optional[int] = None,
image_origin: Optional[ResourceOrigin] = None, image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None, categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None, is_intermediate: Optional[bool] = None,
@ -322,8 +325,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
def get_many( def get_many(
self, self,
offset: int = 0, offset: Optional[int] = None,
limit: int = 10, limit: Optional[int] = None,
image_origin: Optional[ResourceOrigin] = None, image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None, categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None, is_intermediate: Optional[bool] = None,
@ -377,11 +380,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
query_params.append(is_intermediate) query_params.append(is_intermediate)
if board_id is not None: # board_id of "none" is reserved for images without a board
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
elif board_id is not None:
query_conditions += """--sql query_conditions += """--sql
AND board_images.board_id = ? AND board_images.board_id = ?
""" """
query_params.append(board_id) query_params.append(board_id)
query_pagination = """--sql query_pagination = """--sql
@ -392,8 +399,12 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
images_query += query_conditions + query_pagination + ";" images_query += query_conditions + query_pagination + ";"
# Add all the parameters # Add all the parameters
images_params = query_params.copy() images_params = query_params.copy()
if limit is not None:
images_params.append(limit) images_params.append(limit)
if offset is not None:
images_params.append(offset) images_params.append(offset)
# Build the list of images, deserializing each row # Build the list of images, deserializing each row
self._cursor.execute(images_query, images_params) self._cursor.execute(images_query, images_params)
result = cast(list[sqlite3.Row], self._cursor.fetchall()) result = cast(list[sqlite3.Row], self._cursor.fetchall())

View File

@ -11,7 +11,6 @@ from invokeai.app.models.image import (ImageCategory,
InvalidOriginException, ResourceOrigin) InvalidOriginException, ResourceOrigin)
from invokeai.app.services.board_image_record_storage import \ from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase BoardImageRecordStorageBase
from invokeai.app.services.graph import Graph
from invokeai.app.services.image_file_storage import ( from invokeai.app.services.image_file_storage import (
ImageFileDeleteException, ImageFileNotFoundException, ImageFileDeleteException, ImageFileNotFoundException,
ImageFileSaveException, ImageFileStorageBase) ImageFileSaveException, ImageFileStorageBase)
@ -109,6 +108,13 @@ class ImageServiceABC(ABC):
"""Deletes an image.""" """Deletes an image."""
pass pass
@abstractmethod
def delete_many(self, is_intermediate: bool) -> int:
"""Deletes many images."""
pass
@abstractmethod @abstractmethod
def delete_images_on_board(self, board_id: str): def delete_images_on_board(self, board_id: str):
"""Deletes all images on a board.""" """Deletes all images on a board."""
@ -378,16 +384,39 @@ class ImageService(ImageServiceABC):
def delete_images_on_board(self, board_id: str): def delete_images_on_board(self, board_id: str):
try: try:
images = self._services.board_image_records.get_images_for_board(board_id) image_names = (
image_name_list = list( self._services.board_image_records.get_all_board_image_names_for_board(
map( board_id
lambda r: r.image_name,
images.items,
) )
) )
for image_name in image_name_list: for image_name in image_names:
self._services.image_files.delete(image_name) self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list) self._services.image_records.delete_many(image_names)
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records")
raise
except ImageFileDeleteException:
self._services.logger.error(f"Failed to delete image files")
raise
except Exception as e:
self._services.logger.error("Problem deleting image records and files")
raise e
def delete_many(self, is_intermediate: bool):
try:
# only clears 100 at a time
images = self._services.image_records.get_many(offset=0, limit=100, is_intermediate=is_intermediate,)
count = len(images.items)
image_name_list = list(
map(
lambda r: r.image_name,
images.items,
)
)
for image_name in image_name_list:
self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list)
return count
except ImageRecordDeleteException: except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records") self._services.logger.error(f"Failed to delete image records")
raise raise

View File

@ -21,6 +21,7 @@ import re
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import Union from typing import Union
from packaging import version
import torch import torch
from safetensors.torch import load_file from safetensors.torch import load_file
@ -63,6 +64,7 @@ from diffusers.pipelines.stable_diffusion.safety_checker import (
StableDiffusionSafetyChecker, StableDiffusionSafetyChecker,
) )
from diffusers.utils import is_safetensors_available from diffusers.utils import is_safetensors_available
import transformers
from transformers import ( from transformers import (
AutoFeatureExtractor, AutoFeatureExtractor,
BertTokenizerFast, BertTokenizerFast,
@ -841,6 +843,15 @@ def convert_ldm_clip_checkpoint(checkpoint):
key key
] ]
# transformers 4.31.0 and higher - this key no longer in state dict
if version.parse(transformers.__version__) >= version.parse("4.31.0"):
position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None)
text_model.load_state_dict(text_model_dict)
if position_ids is not None:
text_model.text_model.embeddings.position_ids.copy_(position_ids)
# transformers 4.30.2 and lower - position_ids is part of state_dict
else:
text_model.load_state_dict(text_model_dict) text_model.load_state_dict(text_model_dict)
return text_model return text_model
@ -947,6 +958,15 @@ def convert_open_clip_checkpoint(checkpoint):
text_model_dict[new_key] = checkpoint[key] text_model_dict[new_key] = checkpoint[key]
# transformers 4.31.0 and higher - this key no longer in state dict
if version.parse(transformers.__version__) >= version.parse("4.31.0"):
position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None)
text_model.load_state_dict(text_model_dict)
if position_ids is not None:
text_model.text_model.embeddings.position_ids.copy_(position_ids)
# transformers 4.30.2 and lower - position_ids is part of state_dict
else:
text_model.load_state_dict(text_model_dict) text_model.load_state_dict(text_model_dict)
return text_model return text_model

View File

@ -15,7 +15,6 @@ import InvokeTabs from 'features/ui/components/InvokeTabs';
import ParametersDrawer from 'features/ui/components/ParametersDrawer'; import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import i18n from 'i18n'; import i18n from 'i18n';
import { ReactNode, memo, useEffect } from 'react'; import { ReactNode, memo, useEffect } from 'react';
import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal';
import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal';
import GlobalHotkeys from './GlobalHotkeys'; import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster'; import Toaster from './Toaster';
@ -84,7 +83,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
</Grid> </Grid>
<DeleteImageModal /> <DeleteImageModal />
<UpdateImageBoardModal /> <UpdateImageBoardModal />
<DeleteBoardImagesModal />
<Toaster /> <Toaster />
<GlobalHotkeys /> <GlobalHotkeys />
</> </>

View File

@ -15,10 +15,7 @@ const STYLES: ChakraProps['sx'] = {
maxH: BOX_SIZE, maxH: BOX_SIZE,
shadow: 'dark-lg', shadow: 'dark-lg',
borderRadius: 'lg', borderRadius: 'lg',
borderWidth: 2, opacity: 0.3,
borderStyle: 'dashed',
borderColor: 'base.100',
opacity: 0.5,
bg: 'base.800', bg: 'base.800',
color: 'base.50', color: 'base.50',
_dark: { _dark: {

View File

@ -28,6 +28,7 @@ const ImageDndContext = (props: ImageDndContextProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
console.log('dragStart', event.active.data.current);
const activeData = event.active.data.current; const activeData = event.active.data.current;
if (!activeData) { if (!activeData) {
return; return;
@ -37,15 +38,16 @@ const ImageDndContext = (props: ImageDndContextProps) => {
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
console.log('dragEnd', event.active.data.current);
const activeData = event.active.data.current; const activeData = event.active.data.current;
const overData = event.over?.data.current; const overData = event.over?.data.current;
if (!activeData || !overData) { if (!activeDragData || !overData) {
return; return;
} }
dispatch(dndDropped({ overData, activeData })); dispatch(dndDropped({ overData, activeData: activeDragData }));
setActiveDragData(null); setActiveDragData(null);
}, },
[dispatch] [activeDragData, dispatch]
); );
const mouseSensor = useSensor(MouseSensor, { const mouseSensor = useSensor(MouseSensor, {

View File

@ -11,6 +11,7 @@ import {
useDraggable as useOriginalDraggable, useDraggable as useOriginalDraggable,
useDroppable as useOriginalDroppable, useDroppable as useOriginalDroppable,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { BoardId } from 'features/gallery/store/gallerySlice';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
type BaseDropData = { type BaseDropData = {
@ -55,7 +56,7 @@ export type AddToBatchDropData = BaseDropData & {
export type MoveBoardDropData = BaseDropData & { export type MoveBoardDropData = BaseDropData & {
actionType: 'MOVE_BOARD'; actionType: 'MOVE_BOARD';
context: { boardId: string | null }; context: { boardId: BoardId };
}; };
export type TypesafeDroppableData = export type TypesafeDroppableData =
@ -158,8 +159,36 @@ export const isValidDrop = (
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'ADD_TO_BATCH': case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD': case 'MOVE_BOARD': {
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; // If the board is the same, don't allow the drop
// Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
if (!isPayloadValid) {
return false;
}
// Check if the image's board is the board we are dragging onto
if (payloadType === 'IMAGE_DTO') {
const { imageDTO } = active.data.current.payload;
const currentBoard = imageDTO.board_id;
const destinationBoard = overData.context.boardId;
const isSameBoard = currentBoard === destinationBoard;
const isDestinationValid = !currentBoard
? destinationBoard !== 'no_board'
: true;
return !isSameBoard && isDestinationValid;
}
if (payloadType === 'IMAGE_NAMES') {
// TODO (multi-select)
return false;
}
return true;
}
default: default:
return false; return false;
} }

View File

@ -18,7 +18,6 @@ import { Middleware } from '@reduxjs/toolkit';
import ImageDndContext from './ImageDnd/ImageDndContext'; import ImageDndContext from './ImageDnd/ImageDndContext';
import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext'; import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext';
import { $authToken, $baseUrl } from 'services/api/client'; import { $authToken, $baseUrl } from 'services/api/client';
import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext';
const App = lazy(() => import('./App')); const App = lazy(() => import('./App'));
const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider'));
@ -78,9 +77,7 @@ const InvokeAIUI = ({
<ThemeLocaleProvider> <ThemeLocaleProvider>
<ImageDndContext> <ImageDndContext>
<AddImageToBoardContextProvider> <AddImageToBoardContextProvider>
<DeleteBoardImagesContextProvider>
<App config={config} headerComponent={headerComponent} /> <App config={config} headerComponent={headerComponent} />
</DeleteBoardImagesContextProvider>
</AddImageToBoardContextProvider> </AddImageToBoardContextProvider>
</ImageDndContext> </ImageDndContext>
</ThemeLocaleProvider> </ThemeLocaleProvider>

View File

@ -1,7 +1,8 @@
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react'; import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { useAppDispatch } from '../store/storeHooks';
export type ImageUsage = { export type ImageUsage = {
isInitialImage: boolean; isInitialImage: boolean;
@ -40,8 +41,7 @@ type Props = PropsWithChildren;
export const AddImageToBoardContextProvider = (props: Props) => { export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>(); const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const [addImageToBoard, result] = useAddImageToBoardMutation();
// Clean up after deleting or dismissing the modal // Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => { const closeAndClearImageToDelete = useCallback(() => {
@ -63,14 +63,16 @@ export const AddImageToBoardContextProvider = (props: Props) => {
const handleAddToBoard = useCallback( const handleAddToBoard = useCallback(
(boardId: string) => { (boardId: string) => {
if (imageToMove) { if (imageToMove) {
addImageToBoard({ dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO: imageToMove,
board_id: boardId, board_id: boardId,
image_name: imageToMove.image_name, })
}); );
closeAndClearImageToDelete(); closeAndClearImageToDelete();
} }
}, },
[addImageToBoard, closeAndClearImageToDelete, imageToMove] [dispatch, closeAndClearImageToDelete, imageToMove]
); );
return ( return (

View File

@ -1,170 +0,0 @@
import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { BoardDTO } from 'services/api/types';
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/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';
export const selectBoardImagesUsage = createSelector(
[
(state: RootState) => state,
generationSelector,
canvasSelector,
nodesSelector,
controlNetSelector,
(state: RootState, board_id?: string) => board_id,
],
(state, generation, canvas, nodes, controlNet, board_id) => {
const initialImage = generation.initialImage
? selectImagesById(state, generation.initialImage.imageName)
: undefined;
const isInitialImage = initialImage?.board_id === board_id;
const isCanvasImage = canvas.layerState.objects.some((obj) => {
if (obj.kind === 'image') {
const image = selectImagesById(state, obj.imageName);
return image?.board_id === board_id;
}
return false;
});
const isNodesImage = nodes.nodes.some((node) => {
return some(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
const image = selectImagesById(state, input.value.image_name);
return image?.board_id === board_id;
}
return false;
});
});
const isControlNetImage = some(controlNet.controlNets, (c) => {
const controlImage = c.controlImage
? selectImagesById(state, c.controlImage)
: undefined;
const processedControlImage = c.processedControlImage
? selectImagesById(state, c.processedControlImage)
: undefined;
return (
controlImage?.board_id === board_id ||
processedControlImage?.board_id === board_id
);
});
const imageUsage: ImageUsage = {
isInitialImage,
isCanvasImage,
isNodesImage,
isControlNetImage,
};
return imageUsage;
},
defaultSelectorOptions
);
type DeleteBoardImagesContextValue = {
/**
* Whether the move image dialog is open.
*/
isOpen: boolean;
/**
* Closes the move image dialog.
*/
onClose: () => void;
imagesUsage?: ImageUsage;
board?: BoardDTO;
onClickDeleteBoardImages: (board: BoardDTO) => void;
handleDeleteBoardImages: (boardId: string) => void;
handleDeleteBoardOnly: (boardId: string) => void;
};
export const DeleteBoardImagesContext =
createContext<DeleteBoardImagesContextValue>({
isOpen: false,
onClose: () => undefined,
onClickDeleteBoardImages: () => undefined,
handleDeleteBoardImages: () => undefined,
handleDeleteBoardOnly: () => undefined,
});
type Props = PropsWithChildren;
export const DeleteBoardImagesContextProvider = (props: Props) => {
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
// Check where the board images to be deleted are used (eg init image, controlnet, etc.)
const imagesUsage = useAppSelector((state) =>
selectBoardImagesUsage(state, boardToDelete?.board_id)
);
const [deleteBoard] = useDeleteBoardMutation();
// Clean up after deleting or dismissing the modal
const closeAndClearBoardToDelete = useCallback(() => {
setBoardToDelete(undefined);
onClose();
}, [onClose]);
const onClickDeleteBoardImages = useCallback(
(board?: BoardDTO) => {
console.log({ board });
if (!board) {
return;
}
setBoardToDelete(board);
onOpen();
},
[setBoardToDelete, onOpen]
);
const handleDeleteBoardImages = useCallback(
(boardId: string) => {
if (boardToDelete) {
dispatch(
requestedBoardImagesDeletion({ board: boardToDelete, imagesUsage })
);
closeAndClearBoardToDelete();
}
},
[dispatch, closeAndClearBoardToDelete, boardToDelete, imagesUsage]
);
const handleDeleteBoardOnly = useCallback(
(boardId: string) => {
if (boardToDelete) {
deleteBoard(boardId);
closeAndClearBoardToDelete();
}
},
[deleteBoard, closeAndClearBoardToDelete, boardToDelete]
);
return (
<DeleteBoardImagesContext.Provider
value={{
isOpen,
board: boardToDelete,
onClose: closeAndClearBoardToDelete,
onClickDeleteBoardImages,
handleDeleteBoardImages,
handleDeleteBoardOnly,
imagesUsage,
}}
>
{props.children}
</DeleteBoardImagesContext.Provider>
);
};

View File

@ -11,7 +11,7 @@ import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingA
import { addAppConfigReceivedListener } from './listeners/appConfigReceived'; import { addAppConfigReceivedListener } from './listeners/appConfigReceived';
import { addAppStartedListener } from './listeners/appStarted'; import { addAppStartedListener } from './listeners/appStarted';
import { addBoardIdSelectedListener } from './listeners/boardIdSelected'; import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted'; import { addDeleteBoardAndImagesFulfilledListener } from './listeners/boardAndImagesDeleted';
import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard'; import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard';
import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage'; import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage';
import { addCanvasMergedListener } from './listeners/canvasMerged'; import { addCanvasMergedListener } from './listeners/canvasMerged';
@ -29,10 +29,6 @@ import {
addRequestedImageDeletionListener, addRequestedImageDeletionListener,
} from './listeners/imageDeleted'; } from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped'; import { addImageDroppedListener } from './listeners/imageDropped';
import {
addImageMetadataReceivedFulfilledListener,
addImageMetadataReceivedRejectedListener,
} from './listeners/imageMetadataReceived';
import { import {
addImageRemovedFromBoardFulfilledListener, addImageRemovedFromBoardFulfilledListener,
addImageRemovedFromBoardRejectedListener, addImageRemovedFromBoardRejectedListener,
@ -46,18 +42,10 @@ import {
addImageUploadedFulfilledListener, addImageUploadedFulfilledListener,
addImageUploadedRejectedListener, addImageUploadedRejectedListener,
} from './listeners/imageUploaded'; } from './listeners/imageUploaded';
import {
addImageUrlsReceivedFulfilledListener,
addImageUrlsReceivedRejectedListener,
} from './listeners/imageUrlsReceived';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected'; import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addModelSelectedListener } from './listeners/modelSelected'; import { addModelSelectedListener } from './listeners/modelSelected';
import { addModelsLoadedListener } from './listeners/modelsLoaded'; import { addModelsLoadedListener } from './listeners/modelsLoaded';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import {
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import { import {
addSessionCanceledFulfilledListener, addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener, addSessionCanceledPendingListener,
@ -91,6 +79,7 @@ import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextTo
import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted'; import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted';
import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted'; import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted';
import { addUpscaleRequestedListener } from './listeners/upscaleRequested'; import { addUpscaleRequestedListener } from './listeners/upscaleRequested';
import { addFirstListImagesListener } from './listeners/addFirstListImagesListener.ts';
export const listenerMiddleware = createListenerMiddleware(); export const listenerMiddleware = createListenerMiddleware();
@ -132,17 +121,9 @@ addRequestedImageDeletionListener();
addImageDeletedPendingListener(); addImageDeletedPendingListener();
addImageDeletedFulfilledListener(); addImageDeletedFulfilledListener();
addImageDeletedRejectedListener(); addImageDeletedRejectedListener();
addRequestedBoardImageDeletionListener(); addDeleteBoardAndImagesFulfilledListener();
addImageToDeleteSelectedListener(); addImageToDeleteSelectedListener();
// Image metadata
addImageMetadataReceivedFulfilledListener();
addImageMetadataReceivedRejectedListener();
// Image URLs
addImageUrlsReceivedFulfilledListener();
addImageUrlsReceivedRejectedListener();
// User Invoked // User Invoked
addUserInvokedCanvasListener(); addUserInvokedCanvasListener();
addUserInvokedNodesListener(); addUserInvokedNodesListener();
@ -198,17 +179,10 @@ addSessionCanceledPendingListener();
addSessionCanceledFulfilledListener(); addSessionCanceledFulfilledListener();
addSessionCanceledRejectedListener(); addSessionCanceledRejectedListener();
// Fetching images
addReceivedPageOfImagesFulfilledListener();
addReceivedPageOfImagesRejectedListener();
// ControlNet // ControlNet
addControlNetImageProcessedListener(); addControlNetImageProcessedListener();
addControlNetAutoProcessListener(); addControlNetAutoProcessListener();
// Update image URLs on connect
// addUpdateImageUrlsOnConnectListener();
// Boards // Boards
addImageAddedToBoardFulfilledListener(); addImageAddedToBoardFulfilledListener();
addImageAddedToBoardRejectedListener(); addImageAddedToBoardRejectedListener();
@ -229,5 +203,7 @@ addModelSelectedListener();
addAppStartedListener(); addAppStartedListener();
addModelsLoadedListener(); addModelsLoadedListener();
addAppConfigReceivedListener(); addAppConfigReceivedListener();
addFirstListImagesListener();
// Ad-hoc upscale workflwo
addUpscaleRequestedListener(); addUpscaleRequestedListener();

View File

@ -0,0 +1,43 @@
import { createAction } from '@reduxjs/toolkit';
import {
IMAGE_CATEGORIES,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import {
ImageCache,
getListImagesUrl,
imagesApi,
} from 'services/api/endpoints/images';
import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted');
export const addFirstListImagesListener = () => {
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: async (
action,
{ getState, dispatch, unsubscribe, cancelActiveListeners }
) => {
// Only run this listener on the first listImages request for `images` categories
if (
action.meta.arg.queryCacheKey !==
getListImagesUrl({ categories: IMAGE_CATEGORIES })
) {
return;
}
// this should only run once
cancelActiveListeners();
unsubscribe();
// TODO: figure out how to type the predicate
const data = action.payload as ImageCache;
if (data.ids.length > 0) {
// Select the first image
dispatch(imageSelected(data.ids[0] as string));
}
},
});
};

View File

@ -1,11 +1,4 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
isLoadingChanged,
} from 'features/gallery/store/gallerySlice';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..'; import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted'); export const appStarted = createAction('app/appStarted');
@ -17,29 +10,9 @@ export const addAppStartedListener = () => {
action, action,
{ getState, dispatch, unsubscribe, cancelActiveListeners } { getState, dispatch, unsubscribe, cancelActiveListeners }
) => { ) => {
// this should only run once
cancelActiveListeners(); cancelActiveListeners();
unsubscribe(); unsubscribe();
// fill up the gallery tab with images
await dispatch(
receivedPageOfImages({
categories: IMAGE_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
// fill up the assets tab with images
await dispatch(
receivedPageOfImages({
categories: ASSETS_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
dispatch(isLoadingChanged(false));
}, },
}); });
}; };

View File

@ -0,0 +1,48 @@
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { getImageUsage } from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { startAppListening } from '..';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addDeleteBoardAndImagesFulfilledListener = () => {
startAppListening({
matcher: boardsApi.endpoints.deleteBoardAndImages.matchFulfilled,
effect: async (action, { dispatch, getState, condition }) => {
const { board_id, deleted_board_images, deleted_images } = action.payload;
// Remove all deleted images from the UI
let wasInitialImageReset = false;
let wasCanvasReset = false;
let wasNodeEditorReset = false;
let wasControlNetReset = false;
const state = getState();
deleted_images.forEach((image_name) => {
const imageUsage = getImageUsage(state, image_name);
if (imageUsage.isInitialImage && !wasInitialImageReset) {
dispatch(clearInitialImage());
wasInitialImageReset = true;
}
if (imageUsage.isCanvasImage && !wasCanvasReset) {
dispatch(resetCanvas());
wasCanvasReset = true;
}
if (imageUsage.isNodesImage && !wasNodeEditorReset) {
dispatch(nodeEditorReset());
wasNodeEditorReset = true;
}
if (imageUsage.isControlNetImage && !wasControlNetReset) {
dispatch(controlNetReset());
wasControlNetReset = true;
}
});
},
});
};

View File

@ -1,17 +1,13 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected, boardIdSelected,
imageSelected, imageSelected,
selectImagesAll,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { boardsApi } from 'services/api/endpoints/boards';
import { import {
IMAGES_PER_PAGE, getBoardIdQueryParamForBoard,
receivedPageOfImages, getCategoriesQueryParamForBoard,
} from 'services/api/thunks/image'; } from 'features/gallery/store/util';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
@ -19,54 +15,44 @@ const moduleLog = log.child({ namespace: 'boards' });
export const addBoardIdSelectedListener = () => { export const addBoardIdSelectedListener = () => {
startAppListening({ startAppListening({
actionCreator: boardIdSelected, actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => { effect: async (
const board_id = action.payload; action,
{ getState, dispatch, condition, cancelActiveListeners }
) => {
// Cancel any in-progress instances of this listener, we don't want to select an image from a previous board
cancelActiveListeners();
// we need to check if we need to fetch more images const _board_id = action.payload;
// when a board is selected, we need to wait until the board has loaded *some* images, then select the first one
const state = getState(); const categories = getCategoriesQueryParamForBoard(_board_id);
const allImages = selectImagesAll(state); const board_id = getBoardIdQueryParamForBoard(_board_id);
const queryArgs = { board_id, categories };
if (board_id === 'all') { // wait until the board has some images - maybe it already has some from a previous fetch
// Selected all images // must use getState() to ensure we do not have stale state
dispatch(imageSelected(allImages[0]?.image_name ?? null)); const isSuccess = await condition(
return; () =>
} imagesApi.endpoints.listImages.select(queryArgs)(getState())
.isSuccess,
if (board_id === 'batch') { 1000
// Selected the batch
dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null));
return;
}
const filteredImages = selectFilteredImages(state);
const categories =
state.gallery.galleryView === 'images'
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// get the board from the cache
const { data: boards } =
boardsApi.endpoints.listAllBoards.select()(state);
const board = boards?.find((b) => b.board_id === board_id);
if (!board) {
// can't find the board in cache...
dispatch(boardIdSelected('all'));
return;
}
dispatch(imageSelected(board.cover_image_name ?? null));
// if we haven't loaded one full page of images from this board, load more
if (
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
); );
if (isSuccess) {
// the board was just changed - we can select the first image
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(
queryArgs
)(getState());
if (boardImagesData?.ids.length) {
dispatch(imageSelected((boardImagesData.ids[0] as string) ?? null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));
}
} else {
// fallback - deselect
dispatch(imageSelected(null));
} }
}, },
}); });

View File

@ -1,82 +0,0 @@
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import {
imageSelected,
imagesRemoved,
selectImagesAll,
selectImagesById,
} 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 { LIST_TAG, api } from 'services/api';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addRequestedBoardImageDeletionListener = () => {
startAppListening({
actionCreator: requestedBoardImagesDeletion,
effect: async (action, { dispatch, getState, condition }) => {
const { board, imagesUsage } = action.payload;
const { board_id } = board;
const state = getState();
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(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
if (imagesUsage.isCanvasImage) {
dispatch(resetCanvas());
}
if (imagesUsage.isControlNetImage) {
dispatch(controlNetReset());
}
if (imagesUsage.isInitialImage) {
dispatch(clearInitialImage());
}
if (imagesUsage.isNodesImage) {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
const images = selectImagesAll(state).reduce((acc: string[], img) => {
if (img.board_id === board_id) {
acc.push(img.image_name);
}
return acc;
}, []);
dispatch(imagesRemoved(images));
// Delete from server
dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id));
const result =
boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state);
const { isSuccess } = result;
// Wait for successful deletion, then trigger boards to re-fetch
const wasBoardDeleted = await condition(() => !!isSuccess, 30000);
if (wasBoardDeleted) {
dispatch(
api.util.invalidateTags([
{ type: 'Board', id: board_id },
{ type: 'Image', id: LIST_TAG },
])
);
}
},
});
};

View File

@ -1,11 +1,11 @@
import { canvasMerged } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { addToast } from 'features/system/store/systemSlice'; import { canvasMerged } from 'features/canvas/store/actions';
import { imageUploaded } from 'services/api/thunks/image';
import { setMergedCanvas } from 'features/canvas/store/canvasSlice'; import { setMergedCanvas } from 'features/canvas/store/canvasSlice';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob'; import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' });
@ -46,27 +46,28 @@ export const addCanvasMergedListener = () => {
}); });
const imageUploadedRequest = dispatch( const imageUploadedRequest = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'mergedCanvas.png', { file: new File([blob], 'mergedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
image_category: 'general', image_category: 'general',
is_intermediate: true, is_intermediate: true,
postUploadAction: { postUploadAction: {
type: 'TOAST_CANVAS_MERGED', type: 'TOAST',
toastOptions: { title: 'Canvas Merged' },
}, },
}) })
); );
const [{ payload }] = await take( const [{ payload }] = await take(
( (uploadedImageAction) =>
uploadedImageAction imagesApi.endpoints.uploadImage.matchFulfilled(uploadedImageAction) &&
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
); );
const { image_name } = payload; // TODO: I can't figure out how to do the type narrowing in the `take()` so just brute forcing it here
const { image_name } =
payload as typeof imagesApi.endpoints.uploadImage.Types.ResultType;
dispatch( dispatch(
setMergedCanvas({ setMergedCanvas({
@ -76,13 +77,6 @@ export const addCanvasMergedListener = () => {
...baseLayerRect, ...baseLayerRect,
}) })
); );
dispatch(
addToast({
title: 'Canvas Merged',
status: 'success',
})
);
}, },
}); });
}; };

View File

@ -1,10 +1,9 @@
import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUploaded } from 'services/api/thunks/image'; import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUpserted } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
@ -28,28 +27,19 @@ export const addCanvasSavedToGalleryListener = () => {
return; return;
} }
const imageUploadedRequest = dispatch( dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'savedCanvas.png', { file: new File([blob], 'savedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
image_category: 'general', image_category: 'general',
is_intermediate: false, is_intermediate: false,
postUploadAction: { postUploadAction: {
type: 'TOAST_CANVAS_SAVED_TO_GALLERY', type: 'TOAST',
toastOptions: { title: 'Canvas Saved to Gallery' },
}, },
}) })
); );
const [{ payload: uploadedImageDTO }] = await take(
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
dispatch(imageUpserted(uploadedImageDTO));
}, },
}); });
}; };

View File

@ -2,10 +2,10 @@ import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions'; import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { sessionReadyToInvoke } from 'features/system/store/actions'; import { sessionReadyToInvoke } from 'features/system/store/actions';
import { imagesApi } from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards'; import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCreated } from 'services/api/thunks/session'; import { sessionCreated } from 'services/api/thunks/session';
import { Graph } from 'services/api/types'; import { Graph, ImageDTO } from 'services/api/types';
import { socketInvocationComplete } from 'services/events/actions'; import { socketInvocationComplete } from 'services/events/actions';
import { startAppListening } from '..'; import { startAppListening } from '..';
@ -62,12 +62,13 @@ export const addControlNetImageProcessedListener = () => {
invocationCompleteAction.payload.data.result.image; invocationCompleteAction.payload.data.result.image;
// Wait for the ImageDTO to be received // Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> => (action) =>
imageDTOReceived.fulfilled.match(action) && imagesApi.endpoints.getImageDTO.matchFulfilled(action) &&
action.payload.image_name === image_name action.payload.image_name === image_name
); );
const processedControlImage = imageMetadataReceivedAction.payload;
const processedControlImage = payload as ImageDTO;
moduleLog.debug( moduleLog.debug(
{ data: { arg: action.payload, processedControlImage } }, { data: { arg: action.payload, processedControlImage } },

View File

@ -1,31 +1,30 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
export const addImageAddedToBoardFulfilledListener = () => { export const addImageAddedToBoardFulfilledListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled, matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, imageDTO } = action.meta.arg.originalArgs;
moduleLog.debug( // TODO: update listImages cache for this board
{ data: { board_id, image_name } },
'Image added to board' moduleLog.debug({ data: { board_id, imageDTO } }, 'Image added to board');
);
}, },
}); });
}; };
export const addImageAddedToBoardRejectedListener = () => { export const addImageAddedToBoardRejectedListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected, matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, imageDTO } = action.meta.arg.originalArgs;
moduleLog.debug( moduleLog.debug(
{ data: { board_id, image_name } }, { data: { board_id, imageDTO } },
'Problem adding image to board' 'Problem adding image to board'
); );
}, },

View File

@ -1,19 +1,17 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors'; import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { import { imageSelected } from 'features/gallery/store/gallerySlice';
imageRemoved,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import { import {
imageDeletionConfirmed, imageDeletionConfirmed,
isModalOpenChanged, isModalOpenChanged,
} from 'features/imageDeletion/store/imageDeletionSlice'; } from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { clamp } from 'lodash-es';
import { api } from 'services/api'; import { api } from 'services/api';
import { imageDeleted } from 'services/api/thunks/image'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
@ -36,10 +34,28 @@ export const addRequestedImageDeletionListener = () => {
state.gallery.selection[state.gallery.selection.length - 1]; state.gallery.selection[state.gallery.selection.length - 1];
if (lastSelectedImage === image_name) { if (lastSelectedImage === image_name) {
const newSelectedImageId = selectNextImageToSelect(state, image_name); const baseQueryArgs = selectListImagesBaseQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const ids = data?.ids ?? [];
const deletedImageIndex = ids.findIndex(
(result) => result.toString() === image_name
);
const filteredIds = ids.filter((id) => id.toString() !== image_name);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
if (newSelectedImageId) { if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImageId)); dispatch(imageSelected(newSelectedImageId as string));
} else { } else {
dispatch(imageSelected(null)); dispatch(imageSelected(null));
} }
@ -63,16 +79,15 @@ export const addRequestedImageDeletionListener = () => {
dispatch(nodeEditorReset()); dispatch(nodeEditorReset());
} }
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// Delete from server // Delete from server
const { requestId } = dispatch(imageDeleted({ image_name })); const { requestId } = dispatch(
imagesApi.endpoints.deleteImage.initiate(imageDTO)
);
// Wait for successful deletion, then trigger boards to re-fetch // Wait for successful deletion, then trigger boards to re-fetch
const wasImageDeleted = await condition( const wasImageDeleted = await condition(
(action): action is ReturnType<typeof imageDeleted.fulfilled> => (action) =>
imageDeleted.fulfilled.match(action) && imagesApi.endpoints.deleteImage.matchFulfilled(action) &&
action.meta.requestId === requestId, action.meta.requestId === requestId,
30000 30000
); );
@ -91,7 +106,7 @@ export const addRequestedImageDeletionListener = () => {
*/ */
export const addImageDeletedPendingListener = () => { export const addImageDeletedPendingListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.pending, matcher: imagesApi.endpoints.deleteImage.matchPending,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
// //
}, },
@ -103,9 +118,12 @@ export const addImageDeletedPendingListener = () => {
*/ */
export const addImageDeletedFulfilledListener = () => { export const addImageDeletedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.fulfilled, matcher: imagesApi.endpoints.deleteImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug({ data: { image: action.meta.arg } }, 'Image deleted'); moduleLog.debug(
{ data: { image: action.meta.arg.originalArgs } },
'Image deleted'
);
}, },
}); });
}; };
@ -115,10 +133,10 @@ export const addImageDeletedFulfilledListener = () => {
*/ */
export const addImageDeletedRejectedListener = () => { export const addImageDeletedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.rejected, matcher: imagesApi.endpoints.deleteImage.matchRejected,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug( moduleLog.debug(
{ data: { image: action.meta.arg } }, { data: { image: action.meta.arg.originalArgs } },
'Unable to delete image' 'Unable to delete image'
); );
}, },

View File

@ -10,12 +10,9 @@ import {
imageSelected, imageSelected,
imagesAddedToBatch, imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
fieldValueChanged,
imageCollectionFieldValueChanged,
} from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '../'; import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' }); const moduleLog = log.child({ namespace: 'dnd' });
@ -137,23 +134,23 @@ export const addImageDroppedListener = () => {
return; return;
} }
// set multiple nodes images (multiple images handler) // // set multiple nodes images (multiple images handler)
if ( // if (
overData.actionType === 'SET_MULTI_NODES_IMAGE' && // overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_NAMES' // activeData.payloadType === 'IMAGE_NAMES'
) { // ) {
const { fieldName, nodeId } = overData.context; // const { fieldName, nodeId } = overData.context;
dispatch( // dispatch(
imageCollectionFieldValueChanged({ // imageCollectionFieldValueChanged({
nodeId, // nodeId,
fieldName, // fieldName,
value: activeData.payload.image_names.map((image_name) => ({ // value: activeData.payload.image_names.map((image_name) => ({
image_name, // image_name,
})), // })),
}) // })
); // );
return; // return;
} // }
// add image to board // add image to board
if ( if (
@ -162,97 +159,95 @@ export const addImageDroppedListener = () => {
activeData.payload.imageDTO && activeData.payload.imageDTO &&
overData.context.boardId overData.context.boardId
) { ) {
const { image_name } = activeData.payload.imageDTO; const { imageDTO } = activeData.payload;
const { boardId } = overData.context; const { boardId } = overData.context;
// if the board is "No Board", this is a remove action
if (boardId === 'no_board') {
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ imagesApi.endpoints.removeImageFromBoard.initiate({
image_name, imageDTO,
})
);
return;
}
// Handle adding image to batch
if (boardId === 'batch') {
// TODO
}
// Otherwise, add the image to the board
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO,
board_id: boardId, board_id: boardId,
}) })
); );
return; return;
} }
// remove image from board // // add gallery selection to board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' && // activeData.payloadType === 'IMAGE_NAMES' &&
activeData.payload.imageDTO && // overData.context.boardId
overData.context.boardId === null // ) {
) { // console.log('adding gallery selection to board');
const { image_name, board_id } = activeData.payload.imageDTO; // const board_id = overData.context.boardId;
if (board_id) { // dispatch(
dispatch( // boardImagesApi.endpoints.addManyBoardImages.initiate({
boardImagesApi.endpoints.removeImageFromBoard.initiate({ // board_id,
image_name, // image_names: activeData.payload.image_names,
board_id, // })
}) // );
); // return;
} // }
return;
}
// add gallery selection to board // // remove gallery selection from board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId // overData.context.boardId === null
) { // ) {
console.log('adding gallery selection to board'); // console.log('removing gallery selection to board');
const board_id = overData.context.boardId; // dispatch(
dispatch( // boardImagesApi.endpoints.deleteManyBoardImages.initiate({
boardImagesApi.endpoints.addManyBoardImages.initiate({ // image_names: activeData.payload.image_names,
board_id, // })
image_names: activeData.payload.image_names, // );
}) // return;
); // }
return;
}
// remove gallery selection from board // // add batch selection to board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null // overData.context.boardId
) { // ) {
console.log('removing gallery selection to board'); // const board_id = overData.context.boardId;
dispatch( // dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({ // boardImagesApi.endpoints.addManyBoardImages.initiate({
image_names: activeData.payload.image_names, // board_id,
}) // image_names: activeData.payload.image_names,
); // })
return; // );
} // return;
// }
// add batch selection to board // // remove batch selection from board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId // overData.context.boardId === null
) { // ) {
const board_id = overData.context.boardId; // dispatch(
dispatch( // boardImagesApi.endpoints.deleteManyBoardImages.initiate({
boardImagesApi.endpoints.addManyBoardImages.initiate({ // image_names: activeData.payload.image_names,
board_id, // })
image_names: activeData.payload.image_names, // );
}) // return;
); // }
return;
}
// remove batch selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
}, },
}); });
}; };

View File

@ -1,51 +0,0 @@
import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' });
export const addImageMetadataReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageDTOReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
const state = getState();
if (
image.session_id === state.canvas.layerState.stagingArea.sessionId &&
state.canvas.shouldAutoSave
) {
dispatch(
imageUpdated({
image_name: image.image_name,
is_intermediate: image.is_intermediate,
})
);
} else if (image.is_intermediate) {
// No further actions needed for intermediate images
moduleLog.trace(
{ data: { image } },
'Image metadata received (intermediate), skipping'
);
return;
}
moduleLog.debug({ data: { image } }, 'Image metadata received');
dispatch(imageUpserted(image));
},
});
};
export const addImageMetadataReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageDTOReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem receiving image metadata'
);
},
});
};

View File

@ -1,12 +1,12 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
export const addImageRemovedFromBoardFulfilledListener = () => { export const addImageRemovedFromBoardFulfilledListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled, matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, image_name } = action.meta.arg.originalArgs;
@ -20,7 +20,7 @@ export const addImageRemovedFromBoardFulfilledListener = () => {
export const addImageRemovedFromBoardRejectedListener = () => { export const addImageRemovedFromBoardRejectedListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected, matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, image_name } = action.meta.arg.originalArgs;

View File

@ -1,15 +1,20 @@
import { startAppListening } from '..';
import { imageUpdated } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
export const addImageUpdatedFulfilledListener = () => { export const addImageUpdatedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUpdated.fulfilled, matcher: imagesApi.endpoints.updateImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug( moduleLog.debug(
{ oldImage: action.meta.arg, updatedImage: action.payload }, {
data: {
oldImage: action.meta.arg.originalArgs,
updatedImage: action.payload,
},
},
'Image updated' 'Image updated'
); );
}, },
@ -18,9 +23,12 @@ export const addImageUpdatedFulfilledListener = () => {
export const addImageUpdatedRejectedListener = () => { export const addImageUpdatedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUpdated.rejected, matcher: imagesApi.endpoints.updateImage.matchRejected,
effect: (action, { dispatch }) => { effect: (action, { dispatch }) => {
moduleLog.debug({ oldImage: action.meta.arg }, 'Image update failed'); moduleLog.debug(
{ data: action.meta.arg.originalArgs },
'Image update failed'
);
}, },
}); });
}; };

View File

@ -1,49 +1,87 @@
import { UseToastOptions } from '@chakra-ui/react';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
imageUpserted,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUploaded } from 'services/api/thunks/image'; import { boardsApi } from 'services/api/endpoints/boards';
import { startAppListening } from '..'; import { startAppListening } from '..';
import {
SYSTEM_BOARDS,
imagesApi,
} from '../../../../../services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
const DEFAULT_UPLOADED_TOAST: UseToastOptions = {
title: 'Image Uploaded',
status: 'success',
};
export const addImageUploadedFulfilledListener = () => { export const addImageUploadedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUploaded.fulfilled, matcher: imagesApi.endpoints.uploadImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
const image = action.payload; const imageDTO = action.payload;
const state = getState();
const { selectedBoardId } = state.gallery;
moduleLog.debug({ arg: '<Blob>', image }, 'Image uploaded'); moduleLog.debug({ arg: '<Blob>', imageDTO }, 'Image uploaded');
if (action.payload.is_intermediate) { const { postUploadAction } = action.meta.arg.originalArgs;
// No further actions needed for intermediate images
if (
// No further actions needed for intermediate images,
action.payload.is_intermediate &&
// unless they have an explicit post-upload action
!postUploadAction
) {
return; return;
} }
dispatch(imageUpserted(image)); // default action - just upload and alert user
if (postUploadAction?.type === 'TOAST') {
const { postUploadAction } = action.meta.arg; const { toastOptions } = postUploadAction;
if (SYSTEM_BOARDS.includes(selectedBoardId)) {
if (postUploadAction?.type === 'TOAST_CANVAS_SAVED_TO_GALLERY') { dispatch(addToast({ ...DEFAULT_UPLOADED_TOAST, ...toastOptions }));
} else {
// Add this image to the board
dispatch( dispatch(
addToast({ title: 'Canvas Saved to Gallery', status: 'success' }) imagesApi.endpoints.addImageToBoard.initiate({
board_id: selectedBoardId,
imageDTO,
})
); );
return;
}
if (postUploadAction?.type === 'TOAST_CANVAS_MERGED') { // Attempt to get the board's name for the toast
dispatch(addToast({ title: 'Canvas Merged', status: 'success' })); const { data } = boardsApi.endpoints.listAllBoards.select()(state);
// Fall back to just the board id if we can't find the board for some reason
const board = data?.find((b) => b.board_id === selectedBoardId);
const description = board
? `Added to board ${board.board_name}`
: `Added to board ${selectedBoardId}`;
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description,
})
);
}
return; return;
} }
if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') { if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') {
dispatch(setInitialCanvasImage(image)); dispatch(setInitialCanvasImage(imageDTO));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as canvas initial image',
})
);
return; return;
} }
@ -52,30 +90,49 @@ export const addImageUploadedFulfilledListener = () => {
dispatch( dispatch(
controlNetImageChanged({ controlNetImageChanged({
controlNetId, controlNetId,
controlImage: image.image_name, controlImage: imageDTO.image_name,
})
);
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as control image',
}) })
); );
return; return;
} }
if (postUploadAction?.type === 'SET_INITIAL_IMAGE') { if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
dispatch(initialImageChanged(image)); dispatch(initialImageChanged(imageDTO));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as initial image',
})
);
return; return;
} }
if (postUploadAction?.type === 'SET_NODES_IMAGE') { if (postUploadAction?.type === 'SET_NODES_IMAGE') {
const { nodeId, fieldName } = postUploadAction; const { nodeId, fieldName } = postUploadAction;
dispatch(fieldValueChanged({ nodeId, fieldName, value: image })); dispatch(fieldValueChanged({ nodeId, fieldName, value: imageDTO }));
return; dispatch(
} addToast({
...DEFAULT_UPLOADED_TOAST,
if (postUploadAction?.type === 'TOAST_UPLOADED') { description: `Set as node field ${fieldName}`,
dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); })
);
return; return;
} }
if (postUploadAction?.type === 'ADD_TO_BATCH') { if (postUploadAction?.type === 'ADD_TO_BATCH') {
dispatch(imagesAddedToBatch([image.image_name])); dispatch(imagesAddedToBatch([imageDTO.image_name]));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Added to batch',
})
);
return; return;
} }
}, },
@ -84,10 +141,10 @@ export const addImageUploadedFulfilledListener = () => {
export const addImageUploadedRejectedListener = () => { export const addImageUploadedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUploaded.rejected, matcher: imagesApi.endpoints.uploadImage.matchRejected,
effect: (action, { dispatch }) => { effect: (action, { dispatch }) => {
const { formData, ...rest } = action.meta.arg; const { file, postUploadAction, ...rest } = action.meta.arg.originalArgs;
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } }; const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
moduleLog.error({ data: sanitizedData }, 'Image upload failed'); moduleLog.error({ data: sanitizedData }, 'Image upload failed');
dispatch( dispatch(
addToast({ addToast({

View File

@ -1,37 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { imageUpdatedOne } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
export const addImageUrlsReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
moduleLog.debug({ data: { image } }, 'Image URLs received');
const { image_name, image_url, thumbnail_url } = image;
dispatch(
imageUpdatedOne({
id: image_name,
changes: { image_url, thumbnail_url },
})
);
},
});
};
export const addImageUrlsReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem getting image URLs'
);
},
});
};

View File

@ -1,11 +1,9 @@
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { t } from 'i18next';
import { addToast } from 'features/system/store/systemSlice';
import { startAppListening } from '..';
import { initialImageSelected } from 'features/parameters/store/actions';
import { makeToast } from 'app/components/Toaster'; import { makeToast } from 'app/components/Toaster';
import { selectImagesById } from 'features/gallery/store/gallerySlice'; import { initialImageSelected } from 'features/parameters/store/actions';
import { isImageDTO } from 'services/api/guards'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { t } from 'i18next';
import { startAppListening } from '..';
export const addInitialImageSelectedListener = () => { export const addInitialImageSelectedListener = () => {
startAppListening({ startAppListening({
@ -20,26 +18,8 @@ export const addInitialImageSelectedListener = () => {
return; return;
} }
if (isImageDTO(action.payload)) {
dispatch(initialImageChanged(action.payload)); dispatch(initialImageChanged(action.payload));
dispatch(addToast(makeToast(t('toast.sentToImageToImage')))); dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
return;
}
const imageName = action.payload;
const image = selectImagesById(getState(), imageName);
if (!image) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
dispatch(initialImageChanged(image));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
}, },
}); });
}; };

View File

@ -1,40 +0,0 @@
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' });
export const addReceivedPageOfImagesFulfilledListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
const { items } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Received ${items.length} images`
);
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
};
export const addReceivedPageOfImagesRejectedListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem receiving images'
);
}
},
});
};

View File

@ -1,9 +1,17 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import {
IMAGE_CATEGORIES,
boardIdSelected,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import { progressImageSet } from 'features/system/store/systemSlice'; import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import {
SYSTEM_BOARDS,
imagesAdapter,
imagesApi,
} from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards'; import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session'; import { sessionCanceled } from 'services/api/thunks/session';
import { import {
appSocketInvocationComplete, appSocketInvocationComplete,
@ -22,7 +30,6 @@ export const addInvocationCompleteEventListener = () => {
{ data: action.payload }, { data: action.payload },
`Invocation complete (${action.payload.data.node.type})` `Invocation complete (${action.payload.data.node.type})`
); );
const session_id = action.payload.data.graph_execution_state_id; const session_id = action.payload.data.graph_execution_state_id;
const { cancelType, isCancelScheduled, boardIdToAddTo } = const { cancelType, isCancelScheduled, boardIdToAddTo } =
@ -39,35 +46,72 @@ export const addInvocationCompleteEventListener = () => {
// This complete event has an associated image output // This complete event has an associated image output
if (isImageOutput(result) && !nodeDenylist.includes(node.type)) { if (isImageOutput(result) && !nodeDenylist.includes(node.type)) {
const { image_name } = result.image; const { image_name } = result.image;
const { canvas, gallery } = getState();
// Get its metadata const imageDTO = await dispatch(
dispatch( imagesApi.endpoints.getImageDTO.initiate(image_name)
imageDTOReceived({ ).unwrap();
image_name,
})
);
const [{ payload: imageDTO }] = await take( // Add canvas images to the staging area
imageDTOReceived.fulfilled.match
);
// Handle canvas image
if ( if (
graph_execution_state_id === graph_execution_state_id === canvas.layerState.stagingArea.sessionId
getState().canvas.layerState.stagingArea.sessionId
) { ) {
dispatch(addImageToStagingArea(imageDTO)); dispatch(addImageToStagingArea(imageDTO));
} }
if (boardIdToAddTo && !imageDTO.is_intermediate) { if (!imageDTO.is_intermediate) {
// update the cache for 'All Images'
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ imagesApi.util.updateQueryData(
'listImages',
{
categories: IMAGE_CATEGORIES,
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
// update the cache for 'No Board'
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
board_id: 'none',
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
// add image to the board if we had one selected
if (boardIdToAddTo && !SYSTEM_BOARDS.includes(boardIdToAddTo)) {
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
board_id: boardIdToAddTo, board_id: boardIdToAddTo,
image_name, imageDTO,
}) })
); );
} }
const { selectedBoardId } = gallery;
if (boardIdToAddTo && boardIdToAddTo !== selectedBoardId) {
dispatch(boardIdSelected(boardIdToAddTo));
} else if (!boardIdToAddTo) {
dispatch(boardIdSelected('all'));
}
// If auto-switch is enabled, select the new image
if (getState().gallery.shouldAutoSwitch) {
dispatch(imageSelected(imageDTO.image_name));
}
}
dispatch(progressImageSet(null)); dispatch(progressImageSet(null));
} }
// pass along the socket event as an application action // pass along the socket event as an application action

View File

@ -1,9 +1,8 @@
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUpdated } from 'services/api/thunks/image'; import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvas' }); const moduleLog = log.child({ namespace: 'canvas' });
@ -11,41 +10,27 @@ export const addStagingAreaImageSavedListener = () => {
startAppListening({ startAppListening({
actionCreator: stagingAreaImageSaved, actionCreator: stagingAreaImageSaved,
effect: async (action, { dispatch, getState, take }) => { effect: async (action, { dispatch, getState, take }) => {
const { imageName } = action.payload; const { imageDTO } = action.payload;
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: imageName, imageDTO,
is_intermediate: false, changes: { is_intermediate: false },
}) })
); )
.unwrap()
const [imageUpdatedAction] = await take( .then((image) => {
(action) => dispatch(addToast({ title: 'Image Saved', status: 'success' }));
(imageUpdated.fulfilled.match(action) || })
imageUpdated.rejected.match(action)) && .catch((error) => {
action.meta.arg.image_name === imageName
);
if (imageUpdated.rejected.match(imageUpdatedAction)) {
moduleLog.error(
{ data: { arg: imageUpdatedAction.meta.arg } },
'Image saving failed'
);
dispatch( dispatch(
addToast({ addToast({
title: 'Image Saving Failed', title: 'Image Saving Failed',
description: imageUpdatedAction.error.message, description: error.message,
status: 'error', status: 'error',
}) })
); );
return; });
}
if (imageUpdated.fulfilled.match(imageUpdatedAction)) {
dispatch(imageUpserted(imageUpdatedAction.payload));
dispatch(addToast({ title: 'Image Saved', status: 'success' }));
}
}, },
}); });
}; };

View File

@ -1,91 +0,0 @@
import { socketConnected } from 'services/events/actions';
import { startAppListening } from '..';
import { createSelector } from '@reduxjs/toolkit';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { nodesSelector } from 'features/nodes/store/nodesSlice';
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/gallerySlice';
const moduleLog = log.child({ namespace: 'images' });
const selectAllUsedImages = createSelector(
[
generationSelector,
canvasSelector,
nodesSelector,
controlNetSelector,
selectImagesEntities,
],
(generation, canvas, nodes, controlNet, imageEntities) => {
const allUsedImages: string[] = [];
if (generation.initialImage) {
allUsedImages.push(generation.initialImage.imageName);
}
canvas.layerState.objects.forEach((obj) => {
if (obj.kind === 'image') {
allUsedImages.push(obj.imageName);
}
});
nodes.nodes.forEach((node) => {
forEach(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
allUsedImages.push(input.value.image_name);
}
});
});
forEach(controlNet.controlNets, (c) => {
if (c.controlImage) {
allUsedImages.push(c.controlImage);
}
if (c.processedControlImage) {
allUsedImages.push(c.processedControlImage);
}
});
forEach(imageEntities, (image) => {
if (image) {
allUsedImages.push(image.image_name);
}
});
const uniqueImages = uniqBy(allUsedImages, 'image_name');
return uniqueImages;
}
);
export const addUpdateImageUrlsOnConnectListener = () => {
startAppListening({
actionCreator: socketConnected,
effect: async (action, { dispatch, getState, take }) => {
const state = getState();
if (!state.config.shouldUpdateImagesOnConnect) {
return;
}
const allUsedImages = selectAllUsedImages(state);
moduleLog.trace(
{ data: allUsedImages },
`Fetching new image URLs for ${allUsedImages.length} images`
);
allUsedImages.forEach((image_name) => {
dispatch(
imageUrlsReceived({
image_name,
})
);
});
},
});
};

View File

@ -1,20 +1,20 @@
import { startAppListening } from '..';
import { sessionCreated } from 'services/api/thunks/session';
import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { canvasGraphBuilt } from 'features/nodes/store/actions'; import { userInvoked } from 'app/store/actions';
import { imageUpdated, imageUploaded } from 'services/api/thunks/image'; import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { ImageDTO } from 'services/api/types';
import { import {
canvasSessionIdChanged, canvasSessionIdChanged,
stagingAreaInitialized, stagingAreaInitialized,
} from 'features/canvas/store/canvasSlice'; } from 'features/canvas/store/canvasSlice';
import { userInvoked } from 'app/store/actions'; import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { getCanvasData } from 'features/canvas/util/getCanvasData'; import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode'; import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; import { canvasGraphBuilt } from 'features/nodes/store/actions';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
import { sessionReadyToInvoke } from 'features/system/store/actions'; import { sessionReadyToInvoke } from 'features/system/store/actions';
import { imagesApi } from 'services/api/endpoints/images';
import { sessionCreated } from 'services/api/thunks/session';
import { ImageDTO } from 'services/api/types';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'invoke' }); const moduleLog = log.child({ namespace: 'invoke' });
@ -74,7 +74,7 @@ export const addUserInvokedCanvasListener = () => {
if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) { if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) {
// upload the image, saving the request id // upload the image, saving the request id
const { requestId: initImageUploadedRequestId } = dispatch( const { requestId: initImageUploadedRequestId } = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([baseBlob], 'canvasInitImage.png', { file: new File([baseBlob], 'canvasInitImage.png', {
type: 'image/png', type: 'image/png',
}), }),
@ -85,19 +85,20 @@ export const addUserInvokedCanvasListener = () => {
// Wait for the image to be uploaded, matching by request id // Wait for the image to be uploaded, matching by request id
const [{ payload }] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> => // TODO: figure out how to narrow this action's type
imageUploaded.fulfilled.match(action) && (action) =>
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
action.meta.requestId === initImageUploadedRequestId action.meta.requestId === initImageUploadedRequestId
); );
canvasInitImage = payload; canvasInitImage = payload as ImageDTO;
} }
// For inpaint/outpaint, we also need to upload the mask layer // For inpaint/outpaint, we also need to upload the mask layer
if (['inpaint', 'outpaint'].includes(generationMode)) { if (['inpaint', 'outpaint'].includes(generationMode)) {
// upload the image, saving the request id // upload the image, saving the request id
const { requestId: maskImageUploadedRequestId } = dispatch( const { requestId: maskImageUploadedRequestId } = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([maskBlob], 'canvasMaskImage.png', { file: new File([maskBlob], 'canvasMaskImage.png', {
type: 'image/png', type: 'image/png',
}), }),
@ -108,12 +109,13 @@ export const addUserInvokedCanvasListener = () => {
// Wait for the image to be uploaded, matching by request id // Wait for the image to be uploaded, matching by request id
const [{ payload }] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> => // TODO: figure out how to narrow this action's type
imageUploaded.fulfilled.match(action) && (action) =>
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
action.meta.requestId === maskImageUploadedRequestId action.meta.requestId === maskImageUploadedRequestId
); );
canvasMaskImage = payload; canvasMaskImage = payload as ImageDTO;
} }
const graph = buildCanvasGraph( const graph = buildCanvasGraph(
@ -144,9 +146,9 @@ export const addUserInvokedCanvasListener = () => {
// Associate the init image with the session, now that we have the session ID // Associate the init image with the session, now that we have the session ID
if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) { if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) {
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: canvasInitImage.image_name, imageDTO: canvasInitImage,
session_id: sessionId, changes: { session_id: sessionId },
}) })
); );
} }
@ -154,9 +156,9 @@ export const addUserInvokedCanvasListener = () => {
// Associate the mask image with the session, now that we have the session ID // Associate the mask image with the session, now that we have the session ID
if (['inpaint'].includes(generationMode) && canvasMaskImage) { if (['inpaint'].includes(generationMode) && canvasMaskImage) {
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: canvasMaskImage.image_name, imageDTO: canvasMaskImage,
session_id: sessionId, changes: { session_id: sessionId },
}) })
); );
} }

View File

@ -11,13 +11,15 @@ import {
TypesafeDroppableData, TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import {
IAILoadingImageFallback,
IAINoContentFallback,
} from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react'; import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image'; import { ImageDTO, PostUploadAction } from 'services/api/types';
import { ImageDTO } from 'services/api/types';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import IAIDraggable from './IAIDraggable'; import IAIDraggable from './IAIDraggable';
import IAIDroppable from './IAIDroppable'; import IAIDroppable from './IAIDroppable';
@ -46,6 +48,7 @@ type IAIDndImageProps = {
isSelected?: boolean; isSelected?: boolean;
thumbnail?: boolean; thumbnail?: boolean;
noContentFallback?: ReactElement; noContentFallback?: ReactElement;
useThumbailFallback?: boolean;
}; };
const IAIDndImage = (props: IAIDndImageProps) => { const IAIDndImage = (props: IAIDndImageProps) => {
@ -71,6 +74,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
resetTooltip = 'Reset', resetTooltip = 'Reset',
resetIcon = <FaUndo />, resetIcon = <FaUndo />,
noContentFallback = <IAINoContentFallback icon={FaImage} />, noContentFallback = <IAINoContentFallback icon={FaImage} />,
useThumbailFallback,
} = props; } = props;
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
@ -126,9 +130,14 @@ const IAIDndImage = (props: IAIDndImageProps) => {
<Image <Image
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url} src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError" fallbackStrategy="beforeLoadOrError"
// If we fall back to thumbnail, it feels much snappier than the skeleton... fallbackSrc={
fallbackSrc={imageDTO.thumbnail_url} useThumbailFallback ? imageDTO.thumbnail_url : undefined
// fallback={<IAILoadingImageFallback image={imageDTO} />} }
fallback={
useThumbailFallback ? undefined : (
<IAILoadingImageFallback image={imageDTO} />
)
}
width={imageDTO.width} width={imageDTO.width}
height={imageDTO.height} height={imageDTO.height}
onError={onError} onError={onError}

View File

@ -1,12 +1,12 @@
import { Flex, Text, useColorMode } from '@chakra-ui/react'; import { Flex, Text, useColorMode } from '@chakra-ui/react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { memo, useRef } from 'react'; import { ReactNode, memo, useRef } from 'react';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
type Props = { type Props = {
isOver: boolean; isOver: boolean;
label?: string; label?: ReactNode;
}; };
export const IAIDropOverlay = (props: Props) => { export const IAIDropOverlay = (props: Props) => {
@ -57,16 +57,16 @@ export const IAIDropOverlay = (props: Props) => {
<Flex <Flex
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 0, top: 0.5,
insetInlineStart: 0, insetInlineStart: 0.5,
w: 'full', insetInlineEnd: 0.5,
h: 'full', bottom: 0.5,
opacity: 1, opacity: 1,
borderWidth: 3, borderWidth: 2,
borderColor: isOver borderColor: isOver
? mode('base.50', 'base.200')(colorMode) ? mode('base.50', 'base.50')(colorMode)
: mode('base.100', 'base.500')(colorMode), : mode('base.200', 'base.300')(colorMode),
borderRadius: 'base', borderRadius: 'lg',
borderStyle: 'dashed', borderStyle: 'dashed',
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
@ -78,10 +78,10 @@ export const IAIDropOverlay = (props: Props) => {
sx={{ sx={{
fontSize: '2xl', fontSize: '2xl',
fontWeight: 600, fontWeight: 600,
transform: isOver ? 'scale(1.02)' : 'scale(1)', transform: isOver ? 'scale(1.1)' : 'scale(1)',
color: isOver color: isOver
? mode('base.50', 'base.50')(colorMode) ? mode('base.50', 'base.50')(colorMode)
: mode('base.100', 'base.200')(colorMode), : mode('base.200', 'base.300')(colorMode),
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
}} }}

View File

@ -5,12 +5,12 @@ import {
useDroppable, useDroppable,
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { memo, useRef } from 'react'; import { ReactNode, memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay'; import IAIDropOverlay from './IAIDropOverlay';
type IAIDroppableProps = { type IAIDroppableProps = {
dropLabel?: string; dropLabel?: ReactNode;
disabled?: boolean; disabled?: boolean;
data?: TypesafeDroppableData; data?: TypesafeDroppableData;
}; };

View File

@ -68,6 +68,7 @@ export const IAINoContentFallback = (props: IAINoImageFallbackProps) => {
flexDir: 'column', flexDir: 'column',
gap: 2, gap: 2,
userSelect: 'none', userSelect: 'none',
opacity: 0.7,
color: 'base.700', color: 'base.700',
_dark: { _dark: {
color: 'base.500', color: 'base.500',

View File

@ -32,17 +32,46 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
> >
<Flex <Flex
sx={{ sx={{
opacity: 0.4, position: 'absolute',
width: '100%', top: 0,
height: '100%', insetInlineStart: 0,
flexDirection: 'column', w: 'full',
rowGap: 4, h: 'full',
bg: 'base.700',
_dark: { bg: 'base.900' },
opacity: 0.7,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
bg: 'base.900', transitionProperty: 'common',
boxShadow: `inset 0 0 20rem 1rem var(--invokeai-colors-${ transitionDuration: '0.1s',
isDragAccept ? 'accent' : 'error' }}
}-500)`, />
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
p: 4,
}}
>
<Flex
sx={{
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
flexDir: 'column',
gap: 4,
borderWidth: 3,
borderRadius: 'xl',
borderStyle: 'dashed',
color: 'base.100',
borderColor: 'base.100',
_dark: { borderColor: 'base.200' },
}} }}
> >
{isDragAccept ? ( {isDragAccept ? (
@ -54,6 +83,7 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
</> </>
)} )}
</Flex> </Flex>
</Flex>
</Box> </Box>
); );
}; };

View File

@ -1,35 +1,43 @@
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { createSelector } from '@reduxjs/toolkit';
import useImageUploader from 'common/hooks/useImageUploader'; import { useAppToaster } from 'app/components/Toaster';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { selectIsBusy } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { import {
KeyboardEvent, KeyboardEvent,
memo,
ReactNode, ReactNode,
memo,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
} from 'react'; } from 'react';
import { FileRejection, useDropzone } from 'react-dropzone'; import { FileRejection, useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { imageUploaded } from 'services/api/thunks/image'; import { useUploadImageMutation } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/types';
import ImageUploadOverlay from './ImageUploadOverlay'; import ImageUploadOverlay from './ImageUploadOverlay';
import { useAppToaster } from 'app/components/Toaster'; import { AnimatePresence, motion } from 'framer-motion';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
const selector = createSelector( const selector = createSelector(
[systemSelector, activeTabNameSelector], [activeTabNameSelector],
(system, activeTabName) => { (activeTabName) => {
const { isConnected, isUploading } = system; let postUploadAction: PostUploadAction = { type: 'TOAST' };
const isUploaderDisabled = !isConnected || isUploading; if (activeTabName === 'unifiedCanvas') {
postUploadAction = { type: 'SET_CANVAS_INITIAL_IMAGE' };
}
if (activeTabName === 'img2img') {
postUploadAction = { type: 'SET_INITIAL_IMAGE' };
}
return { return {
isUploaderDisabled, postUploadAction,
activeTabName,
}; };
} },
defaultSelectorOptions
); );
type ImageUploaderProps = { type ImageUploaderProps = {
@ -38,12 +46,13 @@ type ImageUploaderProps = {
const ImageUploader = (props: ImageUploaderProps) => { const ImageUploader = (props: ImageUploaderProps) => {
const { children } = props; const { children } = props;
const dispatch = useAppDispatch(); const { postUploadAction } = useAppSelector(selector);
const { isUploaderDisabled, activeTabName } = useAppSelector(selector); const isBusy = useAppSelector(selectIsBusy);
const toaster = useAppToaster(); const toaster = useAppToaster();
const { t } = useTranslation(); const { t } = useTranslation();
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false); const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const { setOpenUploaderFunction } = useImageUploader();
const [uploadImage] = useUploadImageMutation();
const fileRejectionCallback = useCallback( const fileRejectionCallback = useCallback(
(rejection: FileRejection) => { (rejection: FileRejection) => {
@ -60,16 +69,14 @@ const ImageUploader = (props: ImageUploaderProps) => {
const fileAcceptedCallback = useCallback( const fileAcceptedCallback = useCallback(
async (file: File) => { async (file: File) => {
dispatch( uploadImage({
imageUploaded({
file, file,
image_category: 'user', image_category: 'user',
is_intermediate: false, is_intermediate: false,
postUploadAction: { type: 'TOAST_UPLOADED' }, postUploadAction,
}) });
);
}, },
[dispatch] [postUploadAction, uploadImage]
); );
const onDrop = useCallback( const onDrop = useCallback(
@ -101,13 +108,12 @@ const ImageUploader = (props: ImageUploaderProps) => {
isDragReject, isDragReject,
isDragActive, isDragActive,
inputRef, inputRef,
open,
} = useDropzone({ } = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] }, accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true, noClick: true,
onDrop, onDrop,
onDragOver: () => setIsHandlingUpload(true), onDragOver: () => setIsHandlingUpload(true),
disabled: isUploaderDisabled, disabled: isBusy,
multiple: false, multiple: false,
}); });
@ -126,19 +132,13 @@ const ImageUploader = (props: ImageUploaderProps) => {
} }
}; };
// Set the open function so we can open the uploader from anywhere
setOpenUploaderFunction(open);
// Add the paste event listener // Add the paste event listener
document.addEventListener('paste', handlePaste); document.addEventListener('paste', handlePaste);
return () => { return () => {
document.removeEventListener('paste', handlePaste); document.removeEventListener('paste', handlePaste);
setOpenUploaderFunction(() => {
return;
});
}; };
}, [inputRef, open, setOpenUploaderFunction]); }, [inputRef]);
return ( return (
<Box <Box
@ -150,13 +150,30 @@ const ImageUploader = (props: ImageUploaderProps) => {
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{children} {children}
<AnimatePresence>
{isDragActive && isHandlingUpload && ( {isDragActive && isHandlingUpload && (
<motion.div
key="image-upload-overlay"
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.1 },
}}
>
<ImageUploadOverlay <ImageUploadOverlay
isDragAccept={isDragAccept} isDragAccept={isDragAccept}
isDragReject={isDragReject} isDragReject={isDragReject}
setIsHandlingUpload={setIsHandlingUpload} setIsHandlingUpload={setIsHandlingUpload}
/> />
</motion.div>
)} )}
</AnimatePresence>
</Box> </Box>
); );
}; };

View File

@ -1,49 +0,0 @@
import { Flex, Heading, Icon } from '@chakra-ui/react';
import useImageUploader from 'common/hooks/useImageUploader';
import { FaUpload } from 'react-icons/fa';
type ImageUploaderButtonProps = {
styleClass?: string;
};
const ImageUploaderButton = (props: ImageUploaderButtonProps) => {
const { styleClass } = props;
const { openUploader } = useImageUploader();
return (
<Flex
sx={{
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
className={styleClass}
>
<Flex
onClick={openUploader}
sx={{
display: 'flex',
flexDirection: 'column',
rowGap: 8,
p: 8,
borderRadius: 'base',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
cursor: 'pointer',
color: 'base.600',
bg: 'base.800',
_hover: {
bg: 'base.700',
},
}}
>
<Icon as={FaUpload} boxSize={24} />
<Heading size="md">Click or Drag and Drop</Heading>
</Flex>
</Flex>
);
};
export default ImageUploaderButton;

View File

@ -1,20 +0,0 @@
import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa';
import IAIIconButton from './IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader';
const ImageUploaderIconButton = () => {
const { t } = useTranslation();
const { openUploader } = useImageUploader();
return (
<IAIIconButton
aria-label={t('accessibility.uploadImage')}
tooltip="Upload Image"
icon={<FaUpload />}
onClick={openUploader}
/>
);
};
export default ImageUploaderIconButton;

View File

@ -1,7 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { PostUploadAction, imageUploaded } from 'services/api/thunks/image'; import { useUploadImageMutation } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/types';
type UseImageUploadButtonArgs = { type UseImageUploadButtonArgs = {
postUploadAction?: PostUploadAction; postUploadAction?: PostUploadAction;
@ -12,7 +12,7 @@ type UseImageUploadButtonArgs = {
* Provides image uploader functionality to any component. * Provides image uploader functionality to any component.
* *
* @example * @example
* const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ * const { getUploadButtonProps, getUploadInputProps, openUploader } = useImageUploadButton({
* postUploadAction: { * postUploadAction: {
* type: 'SET_CONTROLNET_IMAGE', * type: 'SET_CONTROLNET_IMAGE',
* controlNetId: '12345', * controlNetId: '12345',
@ -20,6 +20,9 @@ type UseImageUploadButtonArgs = {
* isDisabled: getIsUploadDisabled(), * isDisabled: getIsUploadDisabled(),
* }); * });
* *
* // open the uploaded directly
* const handleSomething = () => { openUploader() }
*
* // in the render function * // in the render function
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click * <Button {...getUploadButtonProps()} /> // will open the file dialog on click
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality * <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
@ -28,24 +31,23 @@ export const useImageUploadButton = ({
postUploadAction, postUploadAction,
isDisabled, isDisabled,
}: UseImageUploadButtonArgs) => { }: UseImageUploadButtonArgs) => {
const dispatch = useAppDispatch(); const [uploadImage] = useUploadImageMutation();
const onDropAccepted = useCallback( const onDropAccepted = useCallback(
(files: File[]) => { (files: File[]) => {
const file = files[0]; const file = files[0];
if (!file) { if (!file) {
return; return;
} }
dispatch( uploadImage({
imageUploaded({
file, file,
image_category: 'user', image_category: 'user',
is_intermediate: false, is_intermediate: false,
postUploadAction, postUploadAction: postUploadAction ?? { type: 'TOAST' },
}) });
);
}, },
[dispatch, postUploadAction] [postUploadAction, uploadImage]
); );
const { const {

View File

@ -1,23 +0,0 @@
import { useCallback } from 'react';
let openUploader = () => {
return;
};
const useImageUploader = () => {
const setOpenUploaderFunction = useCallback(
(openUploaderFunction?: () => void) => {
if (openUploaderFunction) {
openUploader = openUploaderFunction;
}
},
[]
);
return {
setOpenUploaderFunction,
openUploader,
};
};
export default useImageUploader;

View File

@ -26,6 +26,8 @@ import {
FaSave, FaSave,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { stagingAreaImageSaved } from '../store/actions'; import { stagingAreaImageSaved } from '../store/actions';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
const selector = createSelector( const selector = createSelector(
[canvasSelector], [canvasSelector],
@ -123,6 +125,10 @@ const IAICanvasStagingAreaToolbar = () => {
[dispatch, sessionId] [dispatch, sessionId]
); );
const { data: imageDTO } = useGetImageDTOQuery(
currentStagingAreaImage?.imageName ?? skipToken
);
if (!currentStagingAreaImage) return null; if (!currentStagingAreaImage) return null;
return ( return (
@ -173,14 +179,19 @@ const IAICanvasStagingAreaToolbar = () => {
<IAIIconButton <IAIIconButton
tooltip={t('unifiedCanvas.saveToGallery')} tooltip={t('unifiedCanvas.saveToGallery')}
aria-label={t('unifiedCanvas.saveToGallery')} aria-label={t('unifiedCanvas.saveToGallery')}
isDisabled={!imageDTO || !imageDTO.is_intermediate}
icon={<FaSave />} icon={<FaSave />}
onClick={() => onClick={() => {
if (!imageDTO) {
return;
}
dispatch( dispatch(
stagingAreaImageSaved({ stagingAreaImageSaved({
imageName: currentStagingAreaImage.imageName, imageDTO,
}) })
) );
} }}
colorScheme="accent" colorScheme="accent"
/> />
<IAIIconButton <IAIIconButton

View File

@ -2,7 +2,6 @@ import { Box, ButtonGroup, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader';
import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick'; import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick';
import { import {
canvasSelector, canvasSelector,
@ -25,6 +24,7 @@ import { systemSelector } from 'features/system/store/systemSelectors';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { import {
canvasCopiedToClipboard, canvasCopiedToClipboard,
canvasDownloadedAsImage, canvasDownloadedAsImage,
@ -82,7 +82,9 @@ const IAICanvasToolbar = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isClipboardAPIAvailable } = useCopyImageToClipboard(); const { isClipboardAPIAvailable } = useCopyImageToClipboard();
const { openUploader } = useImageUploader(); const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
});
useHotkeys( useHotkeys(
['v'], ['v'],
@ -288,9 +290,10 @@ const IAICanvasToolbar = () => {
aria-label={`${t('common.upload')}`} aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`} tooltip={`${t('common.upload')}`}
icon={<FaUpload />} icon={<FaUpload />}
onClick={openUploader}
isDisabled={isStaging} isDisabled={isStaging}
{...getUploadButtonProps()}
/> />
<input {...getUploadInputProps()} />
<IAIIconButton <IAIIconButton
aria-label={`${t('unifiedCanvas.clearCanvas')}`} aria-label={`${t('unifiedCanvas.clearCanvas')}`}
tooltip={`${t('unifiedCanvas.clearCanvas')}`} tooltip={`${t('unifiedCanvas.clearCanvas')}`}

View File

@ -1,4 +1,5 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import { ImageDTO } from 'services/api/types';
export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery'); export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery');
@ -12,6 +13,6 @@ export const canvasDownloadedAsImage = createAction(
export const canvasMerged = createAction('canvas/canvasMerged'); export const canvasMerged = createAction('canvas/canvasMerged');
export const stagingAreaImageSaved = createAction<{ imageName: string }>( export const stagingAreaImageSaved = createAction<{ imageDTO: ImageDTO }>(
'canvas/stagingAreaImageSaved' 'canvas/stagingAreaImageSaved'
); );

View File

@ -11,8 +11,8 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/thunks/image';
import { controlNetImageChanged } from '../store/controlNetSlice'; import { controlNetImageChanged } from '../store/controlNetSlice';
import { PostUploadAction } from 'services/api/types';
type Props = { type Props = {
controlNetId: string; controlNetId: string;

View File

@ -2,7 +2,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas'; import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas';
import { cloneDeep, forEach } from 'lodash-es'; import { cloneDeep, forEach } from 'lodash-es';
import { imageDeleted } from 'services/api/thunks/image'; import { imagesApi } from 'services/api/endpoints/images';
import { isAnySessionRejected } from 'services/api/thunks/session'; import { isAnySessionRejected } from 'services/api/thunks/session';
import { appSocketInvocationError } from 'services/events/actions'; import { appSocketInvocationError } from 'services/events/actions';
import { controlNetImageProcessed } from './actions'; import { controlNetImageProcessed } from './actions';
@ -300,10 +300,20 @@ export const controlNetSlice = createSlice({
} }
}); });
builder.addCase(imageDeleted.pending, (state, action) => { builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(
imagesApi.endpoints.deleteImage.matchFulfilled,
(state, action) => {
// Preemptively remove the image from all controlnets // Preemptively remove the image from all controlnets
// TODO: doesn't the imageusage stuff do this for us? // TODO: doesn't the imageusage stuff do this for us?
const { image_name } = action.meta.arg; const { image_name } = action.meta.arg.originalArgs;
forEach(state.controlNets, (c) => { forEach(state.controlNets, (c) => {
if (c.controlImage === image_name) { if (c.controlImage === image_name) {
c.controlImage = null; c.controlImage = null;
@ -313,15 +323,8 @@ export const controlNetSlice = createSlice({
c.processedControlImage = null; c.processedControlImage = null;
} }
}); });
}); }
);
builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
}, },
}); });

View File

@ -0,0 +1,50 @@
import {
ASSETS_CATEGORIES,
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaFileImage } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
categories: ASSETS_CATEGORIES,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const AllAssetsBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(boardIdSelected('assets'));
};
const { total } = useListImagesQuery(baseQueryArg, {
selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
});
// TODO: Do we support making 'images' 'assets? if yes, we need to handle this
// const droppableData: MoveBoardDropData = {
// id: 'all-images-board',
// actionType: 'MOVE_BOARD',
// context: { boardId: 'assets' },
// };
return (
<GenericBoard
onClick={handleClick}
isSelected={isSelected}
icon={FaFileImage}
label="All Assets"
badgeCount={total}
/>
);
};
export default AllAssetsBoard;

View File

@ -1,29 +1,48 @@
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import {
import { boardIdSelected } from 'features/gallery/store/gallerySlice'; IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaImages } from 'react-icons/fa'; import { FaImages } from 'react-icons/fa';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard'; import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
categories: IMAGE_CATEGORIES,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleAllImagesBoardClick = () => { const handleClick = () => {
dispatch(boardIdSelected('all')); dispatch(boardIdSelected('images'));
}; };
const droppableData: MoveBoardDropData = { const { total } = useListImagesQuery(baseQueryArg, {
id: 'all-images-board', selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
actionType: 'MOVE_BOARD', });
context: { boardId: null },
}; // TODO: Do we support making 'images' 'assets? if yes, we need to handle this
// const droppableData: MoveBoardDropData = {
// id: 'all-images-board',
// actionType: 'MOVE_BOARD',
// context: { boardId: 'images' },
// };
return ( return (
<GenericBoard <GenericBoard
droppableData={droppableData} onClick={handleClick}
onClick={handleAllImagesBoardClick}
isSelected={isSelected} isSelected={isSelected}
icon={FaImages} icon={FaImages}
label="All Images" label="All Images"
badgeCount={total}
/> />
); );
}; };

View File

@ -1,27 +1,27 @@
import { CloseIcon } from '@chakra-ui/icons';
import { import {
Collapse, Collapse,
Flex, Flex,
Grid, Grid,
GridItem, GridItem,
IconButton, useDisclosure,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
import AddBoardButton from './AddBoardButton'; import AddBoardButton from './AddBoardButton';
import AllAssetsBoard from './AllAssetsBoard';
import AllImagesBoard from './AllImagesBoard'; import AllImagesBoard from './AllImagesBoard';
import BatchBoard from './BatchBoard'; import BatchBoard from './BatchBoard';
import BoardsSearch from './BoardsSearch';
import GalleryBoard from './GalleryBoard'; import GalleryBoard from './GalleryBoard';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus'; import NoBoardBoard from './NoBoardBoard';
import DeleteBoardModal from '../DeleteBoardModal';
import { BoardDTO } from 'services/api/types';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
@ -39,31 +39,19 @@ type Props = {
const BoardsList = (props: Props) => { const BoardsList = (props: Props) => {
const { isOpen } = props; const { isOpen } = props;
const dispatch = useAppDispatch();
const { selectedBoardId, searchText } = useAppSelector(selector); const { selectedBoardId, searchText } = useAppSelector(selector);
const { data: boards } = useListAllBoardsQuery(); const { data: boards } = useListAllBoardsQuery();
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled; const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
const filteredBoards = searchText const filteredBoards = searchText
? boards?.filter((board) => ? boards?.filter((board) =>
board.board_name.toLowerCase().includes(searchText.toLowerCase()) board.board_name.toLowerCase().includes(searchText.toLowerCase())
) )
: boards; : boards;
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const [searchMode, setSearchMode] = useState(false); const [searchMode, setSearchMode] = useState(false);
const handleBoardSearch = (searchTerm: string) => {
setSearchMode(searchTerm.length > 0);
dispatch(setBoardSearchText(searchTerm));
};
const clearBoardSearch = () => {
setSearchMode(false);
dispatch(setBoardSearchText(''));
};
return ( return (
<>
<Collapse in={isOpen} animateOpacity> <Collapse in={isOpen} animateOpacity>
<Flex <Flex
layerStyle={'first'} layerStyle={'first'}
@ -76,26 +64,7 @@ const BoardsList = (props: Props) => {
}} }}
> >
<Flex sx={{ gap: 2, alignItems: 'center' }}> <Flex sx={{ gap: 2, alignItems: 'center' }}>
<InputGroup> <BoardsSearch setSearchMode={setSearchMode} />
<Input
placeholder="Search Boards..."
value={searchText}
onChange={(e) => {
handleBoardSearch(e.target.value);
}}
/>
{searchText && searchText.length && (
<InputRightElement>
<IconButton
onClick={clearBoardSearch}
size="xs"
variant="ghost"
aria-label="Clear Search"
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}
</InputGroup>
<AddBoardButton /> <AddBoardButton />
</Flex> </Flex>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
@ -121,7 +90,13 @@ const BoardsList = (props: Props) => {
{!searchMode && ( {!searchMode && (
<> <>
<GridItem sx={{ p: 1.5 }}> <GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={selectedBoardId === 'all'} /> <AllImagesBoard isSelected={selectedBoardId === 'images'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<AllAssetsBoard isSelected={selectedBoardId === 'assets'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<NoBoardBoard isSelected={selectedBoardId === 'no_board'} />
</GridItem> </GridItem>
{isBatchEnabled && ( {isBatchEnabled && (
<GridItem sx={{ p: 1.5 }}> <GridItem sx={{ p: 1.5 }}>
@ -136,6 +111,7 @@ const BoardsList = (props: Props) => {
<GalleryBoard <GalleryBoard
board={board} board={board}
isSelected={selectedBoardId === board.board_id} isSelected={selectedBoardId === board.board_id}
setBoardToDelete={setBoardToDelete}
/> />
</GridItem> </GridItem>
))} ))}
@ -143,6 +119,11 @@ const BoardsList = (props: Props) => {
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
</Flex> </Flex>
</Collapse> </Collapse>
<DeleteBoardModal
boardToDelete={boardToDelete}
setBoardToDelete={setBoardToDelete}
/>
</>
); );
}; };

View File

@ -0,0 +1,66 @@
import { CloseIcon } from '@chakra-ui/icons';
import {
IconButton,
Input,
InputGroup,
InputRightElement,
} 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 { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { memo } from 'react';
const selector = createSelector(
[stateSelector],
({ boards }) => {
const { searchText } = boards;
return { searchText };
},
defaultSelectorOptions
);
type Props = {
setSearchMode: (searchMode: boolean) => void;
};
const BoardsSearch = (props: Props) => {
const { setSearchMode } = props;
const dispatch = useAppDispatch();
const { searchText } = useAppSelector(selector);
const handleBoardSearch = (searchTerm: string) => {
setSearchMode(searchTerm.length > 0);
dispatch(setBoardSearchText(searchTerm));
};
const clearBoardSearch = () => {
setSearchMode(false);
dispatch(setBoardSearchText(''));
};
return (
<InputGroup>
<Input
placeholder="Search Boards..."
value={searchText}
onChange={(e) => {
handleBoardSearch(e.target.value);
}}
/>
{searchText && searchText.length && (
<InputRightElement>
<IconButton
onClick={clearBoardSearch}
size="xs"
variant="ghost"
aria-label="Clear Search"
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}
</InputGroup>
);
};
export default memo(BoardsSearch);

View File

@ -8,35 +8,32 @@ import {
Image, Image,
MenuItem, MenuItem,
MenuList, MenuList,
Text,
useColorMode, useColorMode,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { FaFolder, FaImages, FaTrash } from 'react-icons/fa';
import {
useDeleteBoardMutation,
useUpdateBoardMutation,
} from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { skipToken } from '@reduxjs/toolkit/dist/query';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
// import { boardAddedToBatch } from 'app/store/middleware/listenerMiddleware/listeners/addBoardToBatch'; import { useAppDispatch } from 'app/store/storeHooks';
import { ContextMenu } from 'chakra-ui-contextmenu';
import IAIDroppable from 'common/components/IAIDroppable'; import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo } from 'react';
import { FaTrash, FaUser } from 'react-icons/fa';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { DeleteBoardImagesContext } from '../../../../../app/contexts/DeleteBoardImagesContext';
interface GalleryBoardProps { interface GalleryBoardProps {
board: BoardDTO; board: BoardDTO;
isSelected: boolean; isSelected: boolean;
setBoardToDelete: (board?: BoardDTO) => void;
} }
const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => { const GalleryBoard = memo(
({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { currentData: coverImage } = useGetImageDTOQuery( const { currentData: coverImage } = useGetImageDTOQuery(
@ -44,11 +41,7 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
); );
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const { board_name, board_id } = board; const { board_name, board_id } = board;
const { onClickDeleteBoardImages } = useContext(DeleteBoardImagesContext);
const handleSelectBoard = useCallback(() => { const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(board_id)); dispatch(boardIdSelected(board_id));
}, [board_id, dispatch]); }, [board_id, dispatch]);
@ -56,24 +49,13 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
const [updateBoard, { isLoading: isUpdateBoardLoading }] = const [updateBoard, { isLoading: isUpdateBoardLoading }] =
useUpdateBoardMutation(); useUpdateBoardMutation();
const [deleteBoard, { isLoading: isDeleteBoardLoading }] =
useDeleteBoardMutation();
const handleUpdateBoardName = (newBoardName: string) => { const handleUpdateBoardName = (newBoardName: string) => {
updateBoard({ board_id, changes: { board_name: newBoardName } }); updateBoard({ board_id, changes: { board_name: newBoardName } });
}; };
const handleDeleteBoard = useCallback(() => { const handleDeleteBoard = useCallback(() => {
deleteBoard(board_id); setBoardToDelete(board);
}, [board_id, deleteBoard]); }, [board, setBoardToDelete]);
const handleAddBoardToBatch = useCallback(() => {
// dispatch(boardAddedToBatch({ board_id }));
}, []);
const handleDeleteBoardAndImages = useCallback(() => {
onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]);
const droppableData: MoveBoardDropData = useMemo( const droppableData: MoveBoardDropData = useMemo(
() => ({ () => ({
@ -88,24 +70,24 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
<Box sx={{ touchAction: 'none', height: 'full' }}> <Box sx={{ touchAction: 'none', height: 'full' }}>
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }} menuProps={{ size: 'sm', isLazy: true }}
menuButtonProps={{
bg: 'transparent',
_hover: { bg: 'transparent' },
}}
renderMenu={() => ( renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}> <MenuList
sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps}
>
{board.image_count > 0 && ( {board.image_count > 0 && (
<> <>
<MenuItem {/* <MenuItem
isDisabled={!board.image_count} isDisabled={!board.image_count}
icon={<FaImages />} icon={<FaImages />}
onClickCapture={handleAddBoardToBatch} onClickCapture={handleAddBoardToBatch}
> >
Add Board to Batch Add Board to Batch
</MenuItem> </MenuItem> */}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
</> </>
)} )}
<MenuItem <MenuItem
@ -147,17 +129,19 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
flexShrink: 0, flexShrink: 0,
}} }}
> >
{board.cover_image_name && coverImage?.image_url && ( {board.cover_image_name && coverImage?.thumbnail_url && (
<Image src={coverImage?.image_url} draggable={false} /> <Image src={coverImage?.thumbnail_url} draggable={false} />
)} )}
{!(board.cover_image_name && coverImage?.image_url) && ( {!(board.cover_image_name && coverImage?.thumbnail_url) && (
<IAINoContentFallback <IAINoContentFallback
boxSize={8} boxSize={8}
icon={FaFolder} icon={FaUser}
sx={{ sx={{
border: '2px solid var(--invokeai-colors-base-200)', borderWidth: '2px',
borderStyle: 'solid',
borderColor: 'base.200',
_dark: { _dark: {
border: '2px solid var(--invokeai-colors-base-800)', borderColor: 'base.800',
}, },
}} }}
/> />
@ -172,7 +156,10 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
> >
<Badge variant="solid">{board.image_count}</Badge> <Badge variant="solid">{board.image_count}</Badge>
</Flex> </Flex>
<IAIDroppable data={droppableData} /> <IAIDroppable
data={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
/>
</Flex> </Flex>
<Flex <Flex
@ -189,6 +176,7 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
onSubmit={(nextValue) => { onSubmit={(nextValue) => {
handleUpdateBoardName(nextValue); handleUpdateBoardName(nextValue);
}} }}
sx={{ maxW: 'full' }}
> >
<EditablePreview <EditablePreview
sx={{ sx={{
@ -199,6 +187,8 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
fontSize: 'xs', fontSize: 'xs',
textAlign: 'center', textAlign: 'center',
p: 0, p: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}} }}
noOfLines={1} noOfLines={1}
/> />
@ -218,7 +208,8 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
</ContextMenu> </ContextMenu>
</Box> </Box>
); );
}); }
);
GalleryBoard.displayName = 'HoverableBoard'; GalleryBoard.displayName = 'HoverableBoard';

View File

@ -2,18 +2,34 @@ import { As, Badge, Flex } from '@chakra-ui/react';
import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable'; import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { ReactNode } from 'react';
type GenericBoardProps = { type GenericBoardProps = {
droppableData: TypesafeDroppableData; droppableData?: TypesafeDroppableData;
onClick: () => void; onClick: () => void;
isSelected: boolean; isSelected: boolean;
icon: As; icon: As;
label: string; label: string;
dropLabel?: ReactNode;
badgeCount?: number; badgeCount?: number;
}; };
const formatBadgeCount = (count: number) =>
Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(count);
const GenericBoard = (props: GenericBoardProps) => { const GenericBoard = (props: GenericBoardProps) => {
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props; const {
droppableData,
onClick,
isSelected,
icon,
label,
badgeCount,
dropLabel,
} = props;
return ( return (
<Flex <Flex
@ -59,10 +75,10 @@ const GenericBoard = (props: GenericBoardProps) => {
}} }}
> >
{badgeCount !== undefined && ( {badgeCount !== undefined && (
<Badge variant="solid">{badgeCount}</Badge> <Badge variant="solid">{formatBadgeCount(badgeCount)}</Badge>
)} )}
</Flex> </Flex>
<IAIDroppable data={droppableData} /> <IAIDroppable data={droppableData} dropLabel={dropLabel} />
</Flex> </Flex>
<Flex <Flex
sx={{ sx={{

View File

@ -0,0 +1,53 @@
import { Text } from '@chakra-ui/react';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import {
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaFolderOpen } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
board_id: 'none',
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const NoBoardBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(boardIdSelected('no_board'));
};
const { total } = useListImagesQuery(baseQueryArg, {
selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
});
// TODO: Do we support making 'images' 'assets? if yes, we need to handle this
const droppableData: MoveBoardDropData = {
id: 'all-images-board',
actionType: 'MOVE_BOARD',
context: { boardId: 'no_board' },
};
return (
<GenericBoard
droppableData={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
onClick={handleClick}
isSelected={isSelected}
icon={FaFolderOpen}
label="No Board"
badgeCount={total}
/>
);
};
export default NoBoardBoard;

View File

@ -1,114 +0,0 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Divider,
Flex,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import { memo, useContext, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
import { some } from 'lodash-es';
import { ImageUsage } from '../../../../app/contexts/DeleteImageContext';
const BoardImageInUseMessage = (props: { imagesUsage?: ImageUsage }) => {
const { imagesUsage } = props;
if (!imagesUsage) {
return null;
}
if (!some(imagesUsage)) {
return null;
}
return (
<>
<Text>
An image from this board is currently in use in the following features:
</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}>
{imagesUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imagesUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imagesUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imagesUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList>
<Text>
If you delete images from this board, those features will immediately be
reset.
</Text>
</>
);
};
const DeleteBoardImagesModal = () => {
const { t } = useTranslation();
const {
isOpen,
onClose,
board,
handleDeleteBoardImages,
handleDeleteBoardOnly,
imagesUsage,
} = useContext(DeleteBoardImagesContext);
const cancelRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
{board && (
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Board
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={3}>
<BoardImageInUseMessage imagesUsage={imagesUsage} />
<Divider />
<Text>{t('common.areYouSure')}</Text>
<Text fontWeight="bold">
This board has {board.image_count} image(s) that will be
deleted.
</Text>
</Flex>
</AlertDialogBody>
<AlertDialogFooter gap={3}>
<IAIButton ref={cancelRef} onClick={onClose}>
Cancel
</IAIButton>
<IAIButton
colorScheme="warning"
onClick={() => handleDeleteBoardOnly(board.board_id)}
>
Delete Board Only
</IAIButton>
<IAIButton
colorScheme="error"
onClick={() => handleDeleteBoardImages(board.board_id)}
>
Delete Board and Images
</IAIButton>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(DeleteBoardImagesModal);

View File

@ -0,0 +1,181 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Flex,
Skeleton,
Text,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import ImageUsageMessage from 'features/imageDeletion/components/ImageUsageMessage';
import {
ImageUsage,
getImageUsage,
} from 'features/imageDeletion/store/imageDeletionSlice';
import { some } from 'lodash-es';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
useDeleteBoardAndImagesMutation,
useDeleteBoardMutation,
useListAllImageNamesForBoardQuery,
} from 'services/api/endpoints/boards';
import { BoardDTO } from 'services/api/types';
type Props = {
boardToDelete?: BoardDTO;
setBoardToDelete: (board?: BoardDTO) => void;
};
const DeleteImageModal = (props: Props) => {
const { boardToDelete, setBoardToDelete } = props;
const { t } = useTranslation();
const canRestoreDeletedImagesFromBin = useAppSelector(
(state) => state.config.canRestoreDeletedImagesFromBin
);
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } =
useListAllImageNamesForBoardQuery(boardToDelete?.board_id ?? skipToken);
const selectImageUsageSummary = useMemo(
() =>
createSelector([stateSelector], (state) => {
const allImageUsage = (boardImageNames ?? []).map((imageName) =>
getImageUsage(state, imageName)
);
const imageUsageSummary: ImageUsage = {
isInitialImage: some(allImageUsage, (usage) => usage.isInitialImage),
isCanvasImage: some(allImageUsage, (usage) => usage.isCanvasImage),
isNodesImage: some(allImageUsage, (usage) => usage.isNodesImage),
isControlNetImage: some(
allImageUsage,
(usage) => usage.isControlNetImage
),
};
return { imageUsageSummary };
}),
[boardImageNames]
);
const [deleteBoardOnly, { isLoading: isDeleteBoardOnlyLoading }] =
useDeleteBoardMutation();
const [deleteBoardAndImages, { isLoading: isDeleteBoardAndImagesLoading }] =
useDeleteBoardAndImagesMutation();
const { imageUsageSummary } = useAppSelector(selectImageUsageSummary);
const handleDeleteBoardOnly = useCallback(() => {
if (!boardToDelete) {
return;
}
deleteBoardOnly(boardToDelete.board_id);
setBoardToDelete(undefined);
}, [boardToDelete, deleteBoardOnly, setBoardToDelete]);
const handleDeleteBoardAndImages = useCallback(() => {
if (!boardToDelete) {
return;
}
deleteBoardAndImages(boardToDelete.board_id);
setBoardToDelete(undefined);
}, [boardToDelete, deleteBoardAndImages, setBoardToDelete]);
const handleClose = useCallback(() => {
setBoardToDelete(undefined);
}, [setBoardToDelete]);
const cancelRef = useRef<HTMLButtonElement>(null);
const isLoading = useMemo(
() =>
isDeleteBoardAndImagesLoading ||
isDeleteBoardOnlyLoading ||
isFetchingBoardNames,
[
isDeleteBoardAndImagesLoading,
isDeleteBoardOnlyLoading,
isFetchingBoardNames,
]
);
if (!boardToDelete) {
return null;
}
return (
<AlertDialog
isOpen={Boolean(boardToDelete)}
onClose={handleClose}
leastDestructiveRef={cancelRef}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete {boardToDelete.board_name}
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={3}>
{isFetchingBoardNames ? (
<Skeleton>
<Flex
sx={{
w: 'full',
h: 32,
}}
/>
</Skeleton>
) : (
<ImageUsageMessage
imageUsage={imageUsageSummary}
topMessage="This board contains images used in the following features:"
bottomMessage="Deleting this board and its images will reset any features currently using them."
/>
)}
<Text>Deleted boards cannot be restored.</Text>
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
</Flex>
</AlertDialogBody>
<AlertDialogFooter>
<Flex
sx={{ justifyContent: 'space-between', width: 'full', gap: 2 }}
>
<IAIButton ref={cancelRef} onClick={handleClose}>
Cancel
</IAIButton>
<IAIButton
colorScheme="warning"
isLoading={isLoading}
onClick={handleDeleteBoardOnly}
>
Delete Board Only
</IAIButton>
<IAIButton
colorScheme="error"
isLoading={isLoading}
onClick={handleDeleteBoardAndImages}
>
Delete Board and Images
</IAIButton>
</Flex>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(DeleteImageModal);

View File

@ -17,6 +17,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer'; import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from '../NextPrevImageButtons'; import NextPrevImageButtons from '../NextPrevImageButtons';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { FaImage } from 'react-icons/fa';
export const imagesSelector = createSelector( export const imagesSelector = createSelector(
[stateSelector, selectLastSelectedImage], [stateSelector, selectLastSelectedImage],
@ -168,7 +170,11 @@ const CurrentImagePreview = () => {
draggableData={draggableData} draggableData={draggableData}
isUploadDisabled={true} isUploadDisabled={true}
fitContainer fitContainer
useThumbailFallback
dropLabel="Set as Current Image" dropLabel="Set as Current Image"
noContentFallback={
<IAINoContentFallback icon={FaImage} label="No image selected" />
}
/> />
)} )}
{shouldShowImageDetails && imageDTO && ( {shouldShowImageDetails && imageDTO && (

View File

@ -0,0 +1,91 @@
import { ChevronUpIcon } from '@chakra-ui/icons';
import { Button, Flex, Text } 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 { memo } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
const selector = createSelector(
[stateSelector],
(state) => {
const { selectedBoardId } = state.gallery;
return {
selectedBoardId,
};
},
defaultSelectorOptions
);
type Props = {
isOpen: boolean;
onToggle: () => void;
};
const GalleryBoardName = (props: Props) => {
const { isOpen, onToggle } = props;
const { selectedBoardId } = useAppSelector(selector);
const { selectedBoardName } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => {
let selectedBoardName = '';
if (selectedBoardId === 'images') {
selectedBoardName = 'All Images';
} else if (selectedBoardId === 'assets') {
selectedBoardName = 'All Assets';
} else if (selectedBoardId === 'no_board') {
selectedBoardName = 'No Board';
} else if (selectedBoardId === 'batch') {
selectedBoardName = 'Batch';
} else {
const selectedBoard = data?.find((b) => b.board_id === selectedBoardId);
selectedBoardName = selectedBoard?.board_name || 'Unknown Board';
}
return { selectedBoardName };
},
});
return (
<Flex
as={Button}
onClick={onToggle}
size="sm"
variant="ghost"
sx={{
w: 'full',
justifyContent: 'center',
alignItems: 'center',
px: 2,
_hover: {
bg: 'base.100',
_dark: { bg: 'base.800' },
},
}}
>
<Text
noOfLines={1}
sx={{
w: 'full',
fontWeight: 600,
color: 'base.800',
_dark: {
color: 'base.200',
},
}}
>
{selectedBoardName}
</Text>
<ChevronUpIcon
sx={{
transform: isOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
);
};
export default memo(GalleryBoardName);

View File

@ -0,0 +1,44 @@
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 IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
const selector = createSelector(
[stateSelector],
(state) => {
const { shouldPinGallery } = state.ui;
return {
shouldPinGallery,
};
},
defaultSelectorOptions
);
const GalleryPinButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { shouldPinGallery } = useAppSelector(selector);
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
return (
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/>
);
};
export default GalleryPinButton;

View File

@ -0,0 +1,76 @@
import { Flex } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAISlider from 'common/components/IAISlider';
import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice';
import { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { FaWrench } from 'react-icons/fa';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
const selector = createSelector(
[stateSelector],
(state) => {
const { galleryImageMinimumWidth, shouldAutoSwitch } = state.gallery;
return {
galleryImageMinimumWidth,
shouldAutoSwitch,
};
},
defaultSelectorOptions
);
const GallerySettingsPopover = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { galleryImageMinimumWidth, shouldAutoSwitch } =
useAppSelector(selector);
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
return (
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<FaWrench />}
/>
}
>
<Flex direction="column" gap={2}>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
hideTooltip={true}
label={t('gallery.galleryImageSize')}
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
<IAISimpleCheckbox
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitch}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(shouldAutoSwitchChanged(e.target.checked))
}
/>
</Flex>
</IAIPopover>
);
};
export default GallerySettingsPopover;

View File

@ -1,13 +1,8 @@
import { MenuList } from '@chakra-ui/react'; import { MenuList } 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 { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback } from 'react';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu'; import { menuListMotionProps } from 'theme/components/menu';
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
import SingleSelectionMenuItems from './SingleSelectionMenuItems'; import SingleSelectionMenuItems from './SingleSelectionMenuItems';
type Props = { type Props = {
@ -16,23 +11,23 @@ type Props = {
}; };
const ImageContextMenu = ({ imageDTO, children }: Props) => { const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selector = useMemo( // const selector = useMemo(
() => // () =>
createSelector( // createSelector(
[stateSelector], // [stateSelector],
({ gallery }) => { // ({ gallery }) => {
const selectionCount = gallery.selection.length; // const selectionCount = gallery.selection.length;
return { selectionCount }; // return { selectionCount };
}, // },
defaultSelectorOptions // defaultSelectorOptions
), // ),
[] // []
); // );
const { selectionCount } = useAppSelector(selector); // const { selectionCount } = useAppSelector(selector);
const handleContextMenu = useCallback((e: MouseEvent<HTMLDivElement>) => { const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
}, []); }, []);
@ -48,13 +43,9 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
<MenuList <MenuList
sx={{ visibility: 'visible !important' }} sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps} motionProps={menuListMotionProps}
onContextMenu={handleContextMenu} onContextMenu={skipEvent}
> >
{selectionCount === 1 ? (
<SingleSelectionMenuItems imageDTO={imageDTO} /> <SingleSelectionMenuItems imageDTO={imageDTO} />
) : (
<MultipleSelectionMenuItems />
)}
</MenuList> </MenuList>
) : null ) : null
} }

View File

@ -28,8 +28,10 @@ import {
FaShare, FaShare,
FaTrash, FaTrash,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; import {
import { useGetImageMetadataQuery } from 'services/api/endpoints/images'; useGetImageMetadataQuery,
useRemoveImageFromBoardMutation,
} from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext'; import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
@ -128,15 +130,8 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
if (!imageDTO.board_id) { if (!imageDTO.board_id) {
return; return;
} }
removeFromBoard({ removeFromBoard({ imageDTO });
board_id: imageDTO.board_id, }, [imageDTO, removeFromBoard]);
image_name: imageDTO.image_name,
});
}, [imageDTO.board_id, imageDTO.image_name, removeFromBoard]);
const handleOpenInNewTab = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
}, [imageDTO.image_url]);
const handleAddToBatch = useCallback(() => { const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([imageDTO.image_name])); dispatch(imagesAddedToBatch([imageDTO.image_name]));
@ -149,10 +144,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
return ( return (
<> <>
<Link href={imageDTO.image_url} target="_blank"> <Link href={imageDTO.image_url} target="_blank">
<MenuItem <MenuItem icon={<FaExternalLinkAlt />}>
icon={<FaExternalLinkAlt />}
onClickCapture={handleOpenInNewTab}
>
{t('common.openInNewTab')} {t('common.openInNewTab')}
</MenuItem> </MenuItem>
</Link> </Link>
@ -161,6 +153,11 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.copyImage')} {t('parameters.copyImage')}
</MenuItem> </MenuItem>
)} )}
<Link download={true} href={imageDTO.image_url} target="_blank">
<MenuItem icon={<FaDownload />} w="100%">
{t('parameters.downloadImage')}
</MenuItem>
</Link>
<MenuItem <MenuItem
icon={<FaQuoteRight />} icon={<FaQuoteRight />}
onClickCapture={handleRecallPrompt} onClickCapture={handleRecallPrompt}
@ -219,11 +216,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
Remove from Board Remove from Board
</MenuItem> </MenuItem>
)} )}
<Link download={true} href={imageDTO.image_url} target="_blank">
<MenuItem icon={<FaDownload />} w="100%">
{t('parameters.downloadImage')}
</MenuItem>
</Link>
<MenuItem <MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }} sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />} icon={<FaTrash />}

View File

@ -1,113 +1,34 @@
import { import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react';
Box,
Button,
ButtonGroup,
Flex,
Text,
VStack,
useColorMode,
useDisclosure,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAISlider from 'common/components/IAISlider';
import {
setGalleryImageMinimumWidth,
setGalleryView,
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { ChangeEvent, memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaServer, FaWrench } from 'react-icons/fa';
import { ChevronUpIcon } from '@chakra-ui/icons';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { memo, useRef } from 'react';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode';
import BoardsList from './Boards/BoardsList/BoardsList'; import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
import GalleryPinButton from './GalleryPinButton';
import GallerySettingsPopover from './GallerySettingsPopover';
import BatchImageGrid from './ImageGrid/BatchImageGrid'; import BatchImageGrid from './ImageGrid/BatchImageGrid';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
(state) => { (state) => {
const { const { selectedBoardId } = state.gallery;
selectedBoardId,
galleryImageMinimumWidth,
galleryView,
shouldAutoSwitch,
} = state.gallery;
const { shouldPinGallery } = state.ui;
return { return {
selectedBoardId, selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
shouldAutoSwitch,
galleryView,
}; };
}, },
defaultSelectorOptions defaultSelectorOptions
); );
const ImageGalleryContent = () => { const ImageGalleryContent = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null); const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null); const galleryGridRef = useRef<HTMLDivElement>(null);
const { selectedBoardId } = useAppSelector(selector);
const { colorMode } = useColorMode(); const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } =
useDisclosure();
const {
selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
shouldAutoSwitch,
galleryView,
} = useAppSelector(selector);
const { selectedBoard } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => ({
selectedBoard: data?.find((b) => b.board_id === selectedBoardId),
}),
});
const boardTitle = useMemo(() => {
if (selectedBoardId === 'batch') {
return 'Batch';
}
if (selectedBoard) {
return selectedBoard.board_name;
}
return 'All Images';
}, [selectedBoard, selectedBoardId]);
const { isOpen: isBoardListOpen, onToggle } = useDisclosure();
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
const handleClickImagesCategory = useCallback(() => {
dispatch(setGalleryView('images'));
}, [dispatch]);
const handleClickAssetsCategory = useCallback(() => {
dispatch(setGalleryView('assets'));
}, [dispatch]);
return ( return (
<VStack <VStack
@ -127,95 +48,12 @@ const ImageGalleryContent = () => {
gap: 2, gap: 2,
}} }}
> >
<ButtonGroup isAttached> <GallerySettingsPopover />
<IAIIconButton <GalleryBoardName
tooltip={t('gallery.images')} isOpen={isBoardListOpen}
aria-label={t('gallery.images')} onToggle={onToggleBoardList}
onClick={handleClickImagesCategory}
isChecked={galleryView === 'images'}
size="sm"
icon={<FaImage />}
/>
<IAIIconButton
tooltip={t('gallery.assets')}
aria-label={t('gallery.assets')}
onClick={handleClickAssetsCategory}
isChecked={galleryView === 'assets'}
size="sm"
icon={<FaServer />}
/>
</ButtonGroup>
<Flex
as={Button}
onClick={onToggle}
size="sm"
variant="ghost"
sx={{
w: 'full',
justifyContent: 'center',
alignItems: 'center',
px: 2,
_hover: {
bg: mode('base.100', 'base.800')(colorMode),
},
}}
>
<Text
noOfLines={1}
sx={{
w: 'full',
color: mode('base.800', 'base.200')(colorMode),
fontWeight: 600,
}}
>
{boardTitle}
</Text>
<ChevronUpIcon
sx={{
transform: isBoardListOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<FaWrench />}
/>
}
>
<Flex direction="column" gap={2}>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
hideTooltip={true}
label={t('gallery.galleryImageSize')}
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
<IAISimpleCheckbox
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitch}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(shouldAutoSwitchChanged(e.target.checked))
}
/>
</Flex>
</IAIPopover>
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/> />
<GalleryPinButton />
</Flex> </Flex>
<Box> <Box>
<BoardsList isOpen={isBoardListOpen} /> <BoardsList isOpen={isBoardListOpen} />

View File

@ -1,16 +1,13 @@
import { Box, Spinner } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { import { imageSelected } from 'features/gallery/store/gallerySlice';
imageRangeEndSelected,
imageSelected,
imageSelectionToggled,
} from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@ -84,7 +81,7 @@ const GalleryImage = (props: HoverableImageProps) => {
}, [imageDTO, selection, selectionCount]); }, [imageDTO, selection, selectionCount]);
if (!imageDTO) { if (!imageDTO) {
return <Spinner />; return <IAIFillSkeleton />;
} }
return ( return (

View File

@ -1,58 +1,26 @@
import { Box } from '@chakra-ui/react'; import { Box, Spinner } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton'; import IAIButton from 'common/components/IAIButton';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { IMAGE_LIMIT } from 'features/gallery//store/gallerySlice';
import {
UseOverlayScrollbarsParams,
useOverlayScrollbars,
} from 'overlayscrollbars-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa'; import { FaExclamationCircle, FaImage } from 'react-icons/fa';
import GalleryImage from './GalleryImage';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
IMAGE_LIMIT,
} from 'features/gallery//store/gallerySlice';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { VirtuosoGrid } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso';
import { receivedPageOfImages } from 'services/api/thunks/image'; import {
import { useListBoardImagesQuery } from '../../../../services/api/endpoints/boardImages'; useLazyListImagesQuery,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GalleryImage from './GalleryImage';
import ImageGridItemContainer from './ImageGridItemContainer'; import ImageGridItemContainer from './ImageGridItemContainer';
import ImageGridListContainer from './ImageGridListContainer'; import ImageGridListContainer from './ImageGridListContainer';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
const selector = createSelector( const overlayScrollbarsConfig: UseOverlayScrollbarsParams = {
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const {
galleryImageMinimumWidth,
selectedBoardId,
galleryView,
total,
isLoading,
} = state.gallery;
return {
imageNames: filteredImages.map((i) => i.image_name),
total,
selectedBoardId,
galleryView,
galleryImageMinimumWidth,
isLoading,
};
},
defaultSelectorOptions
);
const GalleryImageGrid = () => {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const emptyGalleryRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true, defer: true,
options: { options: {
scrollbars: { scrollbars: {
@ -63,62 +31,40 @@ const GalleryImageGrid = () => {
}, },
overflow: { x: 'hidden' }, overflow: { x: 'hidden' },
}, },
}); };
const [didInitialFetch, setDidInitialFetch] = useState(false); const GalleryImageGrid = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const rootRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const { const [initialize, osInstance] = useOverlayScrollbars(
galleryImageMinimumWidth, overlayScrollbarsConfig
imageNames: imageNamesAll, //all images names loaded on main tab,
total: totalAll,
selectedBoardId,
galleryView,
isLoading: isLoadingAll,
} = useAppSelector(selector);
const { data: imagesForBoard, isLoading: isLoadingImagesForBoard } =
useListBoardImagesQuery(
{ board_id: selectedBoardId },
{ skip: selectedBoardId === 'all' }
); );
const imageNames = useMemo(() => { const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
if (selectedBoardId === 'all') {
return imageNamesAll; // already sorted by images/uploads in gallery selector const { currentData, isFetching, isSuccess, isError } =
} else { useListImagesQuery(queryArgs);
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; const [listImages] = useLazyListImagesQuery();
const imageList = (imagesForBoard?.items || []).filter((img) =>
categories.includes(img.image_category)
);
return imageList.map((img) => img.image_name);
}
}, [selectedBoardId, galleryView, imagesForBoard, imageNamesAll]);
const areMoreAvailable = useMemo(() => { const areMoreAvailable = useMemo(() => {
return selectedBoardId === 'all' ? totalAll > imageNamesAll.length : false; if (!currentData) {
}, [selectedBoardId, imageNamesAll.length, totalAll]); return false;
}
const isLoading = useMemo(() => { return currentData.ids.length < currentData.total;
return selectedBoardId === 'all' ? isLoadingAll : isLoadingImagesForBoard; }, [currentData]);
}, [selectedBoardId, isLoadingAll, isLoadingImagesForBoard]);
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch( listImages({
receivedPageOfImages({ ...queryArgs,
categories: offset: currentData?.ids.length ?? 0,
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
is_intermediate: false,
offset: imageNames.length,
limit: IMAGE_LIMIT, limit: IMAGE_LIMIT,
}) });
); }, [listImages, queryArgs, currentData?.ids.length]);
}, [dispatch, imageNames.length, galleryView]);
useEffect(() => { useEffect(() => {
// Set up gallery scroler // Initialize the gallery's custom scrollbar
const { current: root } = rootRef; const { current: root } = rootRef;
if (scroller && root) { if (scroller && root) {
initialize({ initialize({
@ -131,47 +77,17 @@ const GalleryImageGrid = () => {
return () => osInstance()?.destroy(); return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]); }, [scroller, initialize, osInstance]);
const handleEndReached = useMemo(() => { if (!currentData) {
if (areMoreAvailable) {
return handleLoadMoreImages;
}
return undefined;
}, [areMoreAvailable, handleLoadMoreImages]);
// useEffect(() => {
// if (!didInitialFetch) {
// return;
// }
// // rough, conservative calculation of how many images fit in the gallery
// // TODO: this gets an incorrect value on first load...
// const galleryHeight = rootRef.current?.clientHeight ?? 0;
// const galleryWidth = rootRef.current?.clientHeight ?? 0;
// const rows = galleryHeight / galleryImageMinimumWidth;
// const columns = galleryWidth / galleryImageMinimumWidth;
// const imagesToLoad = Math.ceil(rows * columns);
// setDidInitialFetch(true);
// // load up that many images
// dispatch(
// receivedPageOfImages({
// offset: 0,
// limit: 10,
// })
// );
// }, [
// didInitialFetch,
// dispatch,
// galleryImageMinimumWidth,
// galleryView,
// selectedBoardId,
// ]);
if (!isLoading && imageNames.length === 0) {
return ( return (
<Box ref={emptyGalleryRef} sx={{ w: 'full', h: 'full' }}> <Box sx={{ w: 'full', h: 'full' }}>
<Spinner size="2xl" opacity={0.5} />
</Box>
);
}
if (isSuccess && currentData?.ids.length === 0) {
return (
<Box sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback <IAINoContentFallback
label={t('gallery.noImagesInGallery')} label={t('gallery.noImagesInGallery')}
icon={FaImage} icon={FaImage}
@ -180,27 +96,28 @@ const GalleryImageGrid = () => {
); );
} }
if (status !== 'rejected') { if (isSuccess && currentData) {
return ( return (
<> <>
<Box ref={rootRef} data-overlayscrollbars="" h="100%"> <Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid <VirtuosoGrid
style={{ height: '100%' }} style={{ height: '100%' }}
data={imageNames} data={currentData.ids}
endReached={handleLoadMoreImages}
components={{ components={{
Item: ImageGridItemContainer, Item: ImageGridItemContainer,
List: ImageGridListContainer, List: ImageGridListContainer,
}} }}
scrollerRef={setScroller} scrollerRef={setScroller}
itemContent={(index, imageName) => ( itemContent={(index, imageName) => (
<GalleryImage key={imageName} imageName={imageName} /> <GalleryImage key={imageName} imageName={imageName as string} />
)} )}
/> />
</Box> </Box>
<IAIButton <IAIButton
onClick={handleLoadMoreImages} onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable} isDisabled={!areMoreAvailable}
isLoading={status === 'pending'} isLoading={isFetching}
loadingText="Loading" loadingText="Loading"
flexShrink={0} flexShrink={0}
> >
@ -211,6 +128,17 @@ const GalleryImageGrid = () => {
</> </>
); );
} }
if (isError) {
return (
<Box sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback
label="Unable to load Gallery"
icon={FaExclamationCircle}
/>
</Box>
);
}
}; };
export default memo(GalleryImageGrid); export default memo(GalleryImageGrid);

View File

@ -11,11 +11,9 @@ const ImageMetadataActions = (props: Props) => {
const { metadata } = props; const { metadata } = props;
const { const {
recallBothPrompts,
recallPositivePrompt, recallPositivePrompt,
recallNegativePrompt, recallNegativePrompt,
recallSeed, recallSeed,
recallInitialImage,
recallCfgScale, recallCfgScale,
recallModel, recallModel,
recallScheduler, recallScheduler,
@ -23,7 +21,6 @@ const ImageMetadataActions = (props: Props) => {
recallWidth, recallWidth,
recallHeight, recallHeight,
recallStrength, recallStrength,
recallAllParameters,
} = useRecallParameters(); } = useRecallParameters();
const handleRecallPositivePrompt = useCallback(() => { const handleRecallPositivePrompt = useCallback(() => {

View File

@ -2,61 +2,76 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
IMAGE_LIMIT,
imageSelected, imageSelected,
selectImagesById, selectImagesById,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { receivedPageOfImages } from 'services/api/thunks/image'; import {
import { selectFilteredImages } from '../store/gallerySelectors'; ListImagesArgs,
imagesAdapter,
imagesApi,
useLazyListImagesQuery,
} from 'services/api/endpoints/images';
import { selectListImagesBaseQueryArgs } from '../store/gallerySelectors';
export const nextPrevImageButtonsSelector = createSelector( export const nextPrevImageButtonsSelector = createSelector(
[stateSelector, selectFilteredImages], [stateSelector, selectListImagesBaseQueryArgs],
(state, filteredImages) => { (state, baseQueryArgs) => {
const { total, isFetching } = state.gallery; const { data, status } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const lastSelectedImage = const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1]; state.gallery.selection[state.gallery.selection.length - 1];
if (!lastSelectedImage || filteredImages.length === 0) { const isFetching = status === 'pending';
if (!data || !lastSelectedImage || data.total === 0) {
return { return {
isFetching,
queryArgs: baseQueryArgs,
isOnFirstImage: true, isOnFirstImage: true,
isOnLastImage: true, isOnLastImage: true,
}; };
} }
const currentImageIndex = filteredImages.findIndex( const queryArgs: ListImagesArgs = {
...baseQueryArgs,
offset: data.ids.length,
limit: IMAGE_LIMIT,
};
const selectors = imagesAdapter.getSelectors();
const images = selectors.selectAll(data);
const currentImageIndex = images.findIndex(
(i) => i.image_name === lastSelectedImage (i) => i.image_name === lastSelectedImage
); );
const nextImageIndex = clamp( const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
currentImageIndex + 1,
0,
filteredImages.length - 1
);
const prevImageIndex = clamp( const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
currentImageIndex - 1,
0,
filteredImages.length - 1
);
const nextImageId = filteredImages[nextImageIndex].image_name; const nextImageId = images[nextImageIndex].image_name;
const prevImageId = filteredImages[prevImageIndex].image_name; const prevImageId = images[prevImageIndex].image_name;
const nextImage = selectImagesById(state, nextImageId); const nextImage = selectors.selectById(data, nextImageId);
const prevImage = selectImagesById(state, prevImageId); const prevImage = selectors.selectById(data, prevImageId);
const imagesLength = filteredImages.length; const imagesLength = images.length;
return { return {
isOnFirstImage: currentImageIndex === 0, isOnFirstImage: currentImageIndex === 0,
isOnLastImage: isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
areMoreImagesAvailable: total > imagesLength, areMoreImagesAvailable: data?.total ?? 0 > imagesLength,
isFetching, isFetching: status === 'pending',
nextImage, nextImage,
prevImage, prevImage,
nextImageId, nextImageId,
prevImageId, prevImageId,
queryArgs,
}; };
}, },
{ {
@ -76,6 +91,7 @@ export const useNextPrevImage = () => {
prevImageId, prevImageId,
areMoreImagesAvailable, areMoreImagesAvailable,
isFetching, isFetching,
queryArgs,
} = useAppSelector(nextPrevImageButtonsSelector); } = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => { const handlePrevImage = useCallback(() => {
@ -86,13 +102,11 @@ export const useNextPrevImage = () => {
nextImageId && dispatch(imageSelected(nextImageId)); nextImageId && dispatch(imageSelected(nextImageId));
}, [dispatch, nextImageId]); }, [dispatch, nextImageId]);
const [listImages] = useLazyListImagesQuery();
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch( listImages(queryArgs);
receivedPageOfImages({ }, [listImages, queryArgs]);
is_intermediate: false,
})
);
}, [dispatch]);
return { return {
handlePrevImage, handlePrevImage,

View File

@ -1,136 +1,38 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { clamp, keyBy } from 'lodash-es'; import { ListImagesArgs } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { INITIAL_IMAGE_LIMIT } from './gallerySlice';
import { import {
ASSETS_CATEGORIES, getBoardIdQueryParamForBoard,
BoardId, getCategoriesQueryParamForBoard,
IMAGE_CATEGORIES, } from './util';
imagesAdapter,
initialGalleryState,
} from './gallerySlice';
export const gallerySelector = (state: RootState) => state.gallery; export const gallerySelector = (state: RootState) => state.gallery;
const isInSelectedBoard = (
selectedBoardId: BoardId,
imageDTO: ImageDTO,
batchImageNames: string[]
) => {
if (selectedBoardId === 'all') {
// all images are in the "All Images" board
return true;
}
if (selectedBoardId === 'none' && !imageDTO.board_id) {
// Only images without a board are in the "No Board" board
return true;
}
if (
selectedBoardId === 'batch' &&
batchImageNames.includes(imageDTO.image_name)
) {
// Only images with is_batch are in the "Batch" board
return true;
}
return selectedBoardId === imageDTO.board_id;
};
export const selectFilteredImagesLocal = createSelector(
[(state: typeof initialGalleryState) => state],
(galleryState) => {
const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
const { galleryView, selectedBoardId } = galleryState;
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInBoard = isInSelectedBoard(
selectedBoardId,
i,
galleryState.batchImageNames
);
return isInCategory && isInBoard;
});
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( export const selectLastSelectedImage = createSelector(
(state: RootState) => state, (state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1], (state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions defaultSelectorOptions
); );
export const selectSelectedImages = createSelector( export const selectListImagesBaseQueryArgs = createSelector(
(state: RootState) => state, [(state: RootState) => state],
(state) => (state) => {
imagesAdapter const { selectedBoardId } = state.gallery;
.getSelectors()
.selectAll(state.gallery)
.filter((i) => state.gallery.selection.includes(i.image_name)),
defaultSelectorOptions
);
export const selectNextImageToSelectLocal = createSelector( const categories = getCategoriesQueryParamForBoard(selectedBoardId);
[ const board_id = getBoardIdQueryParamForBoard(selectedBoardId);
(state: typeof initialGalleryState) => state,
(state: typeof initialGalleryState, image_name: string) => image_name,
],
(state, image_name) => {
const filteredImages = selectFilteredImagesLocal(state);
const ids = filteredImages.map((i) => i.image_name);
const deletedImageIndex = ids.findIndex( const listImagesBaseQueryArgs: ListImagesArgs = {
(result) => result.toString() === image_name categories,
); board_id,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const filteredIds = ids.filter((id) => id.toString() !== image_name); return listImagesBaseQueryArgs;
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
return newSelectedImageId;
}
);
export const selectNextImageToSelect = createSelector(
[
(state: RootState) => state,
(state: RootState, image_name: string) => image_name,
],
(state, image_name) => {
return selectNextImageToSelectLocal(state.gallery, image_name);
}, },
defaultSelectorOptions defaultSelectorOptions
); );

View File

@ -1,20 +1,8 @@
import type { PayloadAction, Update } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { dateComparator } from 'common/util/dateComparator';
import { uniq } from 'lodash-es'; import { uniq } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards'; import { boardsApi } from 'services/api/endpoints/boards';
import { import { ImageCategory } from 'services/api/types';
imageUrlsReceived,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { ImageCategory, ImageDTO } from 'services/api/types';
import { selectFilteredImagesLocal } from './gallerySelectors';
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = [ export const ASSETS_CATEGORIES: ImageCategory[] = [
@ -26,113 +14,74 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
export const INITIAL_IMAGE_LIMIT = 100; export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20; export const IMAGE_LIMIT = 20;
export type GalleryView = 'images' | 'assets'; // export type GalleryView = 'images' | 'assets';
export type BoardId = export type BoardId =
| 'all' | 'images'
| 'none' | 'assets'
| 'no_board'
| 'batch' | 'batch'
| (string & Record<never, never>); | (string & Record<never, never>);
type AdditionaGalleryState = { type GalleryState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
isFetching: boolean;
selection: string[]; selection: string[];
shouldAutoSwitch: boolean; shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number; galleryImageMinimumWidth: number;
galleryView: GalleryView;
selectedBoardId: BoardId; selectedBoardId: BoardId;
isInitialized: boolean;
batchImageNames: string[]; batchImageNames: string[];
isBatchEnabled: boolean; isBatchEnabled: boolean;
}; };
export const initialGalleryState = export const initialGalleryState: GalleryState = {
imagesAdapter.getInitialState<AdditionaGalleryState>({
offset: 0,
limit: 0,
total: 0,
isLoading: true,
isFetching: true,
selection: [], selection: [],
shouldAutoSwitch: true, shouldAutoSwitch: true,
galleryImageMinimumWidth: 96, galleryImageMinimumWidth: 96,
galleryView: 'images', selectedBoardId: 'images',
selectedBoardId: 'all',
isInitialized: false,
batchImageNames: [], batchImageNames: [],
isBatchEnabled: false, isBatchEnabled: false,
}); };
export const gallerySlice = createSlice({ export const gallerySlice = createSlice({
name: 'gallery', name: 'gallery',
initialState: initialGalleryState, initialState: initialGalleryState,
reducers: { reducers: {
imageUpserted: (state, action: PayloadAction<ImageDTO>) => {
imagesAdapter.upsertOne(state, action.payload);
if (
state.shouldAutoSwitch &&
action.payload.image_category === 'general'
) {
state.selection = [action.payload.image_name];
state.galleryView = 'images';
state.selectedBoardId = 'all';
}
},
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
imagesAdapter.updateOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload);
state.batchImageNames = state.batchImageNames.filter(
(name) => name !== action.payload
);
},
imagesRemoved: (state, action: PayloadAction<string[]>) => { imagesRemoved: (state, action: PayloadAction<string[]>) => {
imagesAdapter.removeMany(state, action.payload); // TODO: port all instances of this to use RTK Query cache
state.batchImageNames = state.batchImageNames.filter( // imagesAdapter.removeMany(state, action.payload);
(name) => !action.payload.includes(name) // state.batchImageNames = state.batchImageNames.filter(
); // (name) => !action.payload.includes(name)
// );
}, },
imageRangeEndSelected: (state, action: PayloadAction<string>) => { imageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload; // const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1]; // const lastSelectedImage = state.selection[state.selection.length - 1];
// const filteredImages = selectFilteredImagesLocal(state);
const filteredImages = selectFilteredImagesLocal(state); // const lastClickedIndex = filteredImages.findIndex(
// (n) => n.image_name === lastSelectedImage
const lastClickedIndex = filteredImages.findIndex( // );
(n) => n.image_name === lastSelectedImage // const currentClickedIndex = filteredImages.findIndex(
); // (n) => n.image_name === rangeEndImageName
// );
const currentClickedIndex = filteredImages.findIndex( // if (lastClickedIndex > -1 && currentClickedIndex > -1) {
(n) => n.image_name === rangeEndImageName // // We have a valid range!
); // const start = Math.min(lastClickedIndex, currentClickedIndex);
// const end = Math.max(lastClickedIndex, currentClickedIndex);
if (lastClickedIndex > -1 && currentClickedIndex > -1) { // const imagesToSelect = filteredImages
// We have a valid range! // .slice(start, end + 1)
const start = Math.min(lastClickedIndex, currentClickedIndex); // .map((i) => i.image_name);
const end = Math.max(lastClickedIndex, currentClickedIndex); // state.selection = uniq(state.selection.concat(imagesToSelect));
// }
const imagesToSelect = filteredImages
.slice(start, end + 1)
.map((i) => i.image_name);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
}, },
imageSelectionToggled: (state, action: PayloadAction<string>) => { imageSelectionToggled: (state, action: PayloadAction<string>) => {
if ( // if (
state.selection.includes(action.payload) && // state.selection.includes(action.payload) &&
state.selection.length > 1 // state.selection.length > 1
) { // ) {
state.selection = state.selection.filter( // state.selection = state.selection.filter(
(imageName) => imageName !== action.payload // (imageName) => imageName !== action.payload
); // );
} else { // } else {
state.selection = uniq(state.selection.concat(action.payload)); // state.selection = uniq(state.selection.concat(action.payload));
} // }
}, },
imageSelected: (state, action: PayloadAction<string | null>) => { imageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload ? [action.payload] : []; state.selection = action.payload ? [action.payload] : [];
@ -143,15 +92,9 @@ export const gallerySlice = createSlice({
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => { setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload; state.galleryImageMinimumWidth = action.payload;
}, },
setGalleryView: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
boardIdSelected: (state, action: PayloadAction<BoardId>) => { boardIdSelected: (state, action: PayloadAction<BoardId>) => {
state.selectedBoardId = action.payload; state.selectedBoardId = action.payload;
}, },
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => { isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isBatchEnabled = action.payload; state.isBatchEnabled = action.payload;
}, },
@ -182,47 +125,11 @@ export const gallerySlice = createSlice({
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(receivedPageOfImages.pending, (state) => {
state.isFetching = true;
});
builder.addCase(receivedPageOfImages.rejected, (state) => {
state.isFetching = false;
});
builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
state.isFetching = false;
const { board_id, categories, image_origin, is_intermediate } =
action.meta.arg;
const { items, offset, limit, total } = action.payload;
imagesAdapter.upsertMany(state, items);
if (state.selection.length === 0 && items.length) {
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.total = total;
});
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( builder.addMatcher(
boardsApi.endpoints.deleteBoard.matchFulfilled, boardsApi.endpoints.deleteBoard.matchFulfilled,
(state, action) => { (state, action) => {
if (action.meta.arg.originalArgs === state.selectedBoardId) { if (action.meta.arg.originalArgs === state.selectedBoardId) {
state.selectedBoardId = 'all'; state.selectedBoardId = 'images';
} }
} }
); );
@ -230,26 +137,13 @@ export const gallerySlice = createSlice({
}); });
export const { export const {
selectAll: selectImagesAll,
selectById: selectImagesById,
selectEntities: selectImagesEntities,
selectIds: selectImagesIds,
selectTotal: selectImagesTotal,
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery);
export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imagesRemoved, imagesRemoved,
imageRangeEndSelected, imageRangeEndSelected,
imageSelectionToggled, imageSelectionToggled,
imageSelected, imageSelected,
shouldAutoSwitchChanged, shouldAutoSwitchChanged,
setGalleryImageMinimumWidth, setGalleryImageMinimumWidth,
setGalleryView,
boardIdSelected, boardIdSelected,
isLoadingChanged,
isBatchEnabledChanged, isBatchEnabledChanged,
imagesAddedToBatch, imagesAddedToBatch,
imagesRemovedFromBatch, imagesRemovedFromBatch,

View File

@ -0,0 +1,54 @@
import { SYSTEM_BOARDS } from 'services/api/endpoints/images';
import { ASSETS_CATEGORIES, BoardId, IMAGE_CATEGORIES } from './gallerySlice';
import { ImageCategory } from 'services/api/types';
import { isEqual } from 'lodash-es';
export const getCategoriesQueryParamForBoard = (
board_id: BoardId
): ImageCategory[] | undefined => {
if (board_id === 'assets') {
return ASSETS_CATEGORIES;
}
if (board_id === 'images') {
return IMAGE_CATEGORIES;
}
// 'no_board' board, 'batch' board, user boards
return undefined;
};
export const getBoardIdQueryParamForBoard = (
board_id: BoardId
): string | undefined => {
if (board_id === 'no_board') {
return 'none';
}
// system boards besides 'no_board'
if (SYSTEM_BOARDS.includes(board_id)) {
return undefined;
}
// user boards
return board_id;
};
export const getBoardIdFromBoardAndCategoriesQueryParam = (
board_id: string | undefined,
categories: ImageCategory[] | undefined
): BoardId => {
if (board_id === undefined && isEqual(categories, IMAGE_CATEGORIES)) {
return 'images';
}
if (board_id === undefined && isEqual(categories, ASSETS_CATEGORIES)) {
return 'assets';
}
if (board_id === 'none') {
return 'no_board';
}
return board_id ?? 'UNKNOWN_BOARD';
};

View File

@ -2,9 +2,17 @@ import { some } from 'lodash-es';
import { memo } from 'react'; import { memo } from 'react';
import { ImageUsage } from '../store/imageDeletionSlice'; import { ImageUsage } from '../store/imageDeletionSlice';
import { ListItem, Text, UnorderedList } from '@chakra-ui/react'; import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
type Props = {
const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => { imageUsage?: ImageUsage;
const { imageUsage } = props; topMessage?: string;
bottomMessage?: string;
};
const ImageUsageMessage = (props: Props) => {
const {
imageUsage,
topMessage = 'This image is currently in use in the following features:',
bottomMessage = 'If you delete this image, those features will immediately be reset.',
} = props;
if (!imageUsage) { if (!imageUsage) {
return null; return null;
@ -16,16 +24,14 @@ const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
return ( return (
<> <>
<Text>This image is currently in use in the following features:</Text> <Text>{topMessage}</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}> <UnorderedList sx={{ paddingInlineStart: 6 }}>
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>} {imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>} {imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>} {imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>} {imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList> </UnorderedList>
<Text> <Text>{bottomMessage}</Text>
If you delete this image, those features will immediately be reset.
</Text>
</> </>
); );
}; };

View File

@ -51,17 +51,8 @@ export type ImageUsage = {
isControlNetImage: boolean; isControlNetImage: boolean;
}; };
export const selectImageUsage = createSelector( export const getImageUsage = (state: RootState, image_name: string) => {
[(state: RootState) => state], const { generation, canvas, nodes, controlNet } = state;
({ imageDeletion, generation, canvas, nodes, controlNet }) => {
const { imageToDelete } = imageDeletion;
if (!imageToDelete) {
return;
}
const { image_name } = imageToDelete;
const isInitialImage = generation.initialImage?.imageName === image_name; const isInitialImage = generation.initialImage?.imageName === image_name;
const isCanvasImage = canvas.layerState.objects.some( const isCanvasImage = canvas.layerState.objects.some(
@ -89,6 +80,22 @@ export const selectImageUsage = createSelector(
isControlNetImage, isControlNetImage,
}; };
return imageUsage;
};
export const selectImageUsage = createSelector(
[(state: RootState) => state],
(state) => {
const { imageToDelete } = state.imageDeletion;
if (!imageToDelete) {
return;
}
const { image_name } = imageToDelete;
const imageUsage = getImageUsage(state, image_name);
return imageUsage; return imageUsage;
}, },
defaultSelectorOptions defaultSelectorOptions

View File

@ -1,33 +1,46 @@
import { Flex, Image } from '@chakra-ui/react'; import { Flex, Image } from '@chakra-ui/react';
import { NodeProps } from 'reactflow'; import { RootState } from 'app/store/store';
import { InvocationValue } from '../types/types';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { memo } from 'react'; import { memo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { NodeProps, OnResize } from 'reactflow';
import { setProgressNodeSize } from '../store/nodesSlice';
import IAINodeHeader from './IAINode/IAINodeHeader'; import IAINodeHeader from './IAINode/IAINodeHeader';
import IAINodeResizer from './IAINode/IAINodeResizer'; import IAINodeResizer from './IAINode/IAINodeResizer';
import NodeWrapper from './NodeWrapper'; import NodeWrapper from './NodeWrapper';
const ProgressImageNode = (props: NodeProps<InvocationValue>) => { const ProgressImageNode = (props: NodeProps) => {
const progressImage = useAppSelector((state) => state.system.progressImage); const progressImage = useSelector(
(state: RootState) => state.system.progressImage
);
const progressNodeSize = useSelector(
(state: RootState) => state.nodes.progressNodeSize
);
const dispatch = useDispatch();
const { selected } = props; const { selected } = props;
const handleResize: OnResize = (_, newSize) => {
dispatch(setProgressNodeSize(newSize));
};
return ( return (
<NodeWrapper selected={selected}> <NodeWrapper selected={selected}>
<IAINodeHeader <IAINodeHeader
title="Progress Image" title="Progress Image"
description="Displays the progress image in the Node Editor" description="Displays the progress image in the Node Editor"
/> />
<Flex <Flex
className="nopan"
sx={{ sx={{
flexDirection: 'column', flexDirection: 'column',
flexShrink: 0,
borderBottomRadius: 'md', borderBottomRadius: 'md',
p: 2,
bg: 'base.200', bg: 'base.200',
_dark: { bg: 'base.800' }, _dark: { bg: 'base.800' },
width: progressNodeSize.width - 2,
height: progressNodeSize.height - 2,
minW: 250,
minH: 250,
overflow: 'hidden',
}} }}
> >
{progressImage ? ( {progressImage ? (
@ -42,22 +55,17 @@ const ProgressImageNode = (props: NodeProps<InvocationValue>) => {
) : ( ) : (
<Flex <Flex
sx={{ sx={{
w: 'full', minW: 250,
h: 'full', minH: 250,
minW: 32, width: progressNodeSize.width - 2,
minH: 32, height: progressNodeSize.height - 2,
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
<IAINoContentFallback /> <IAINoContentFallback />
</Flex> </Flex>
)} )}
</Flex> </Flex>
<IAINodeResizer <IAINodeResizer onResize={handleResize} />
maxHeight={progressImage?.height ?? 512}
maxWidth={progressImage?.width ?? 512}
/>
</NodeWrapper> </NodeWrapper>
); );
}; };

View File

@ -15,8 +15,8 @@ import {
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/thunks/image';
import { FieldComponentProps } from './types'; import { FieldComponentProps } from './types';
import { PostUploadAction } from 'services/api/types';
const ImageInputFieldComponent = ( const ImageInputFieldComponent = (
props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate> props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate>

View File

@ -35,6 +35,7 @@ export type NodesState = {
shouldShowFieldTypeLegend: boolean; shouldShowFieldTypeLegend: boolean;
shouldShowMinimapPanel: boolean; shouldShowMinimapPanel: boolean;
editorInstance: ReactFlowInstance | undefined; editorInstance: ReactFlowInstance | undefined;
progressNodeSize: { width: number; height: number };
}; };
export const initialNodesState: NodesState = { export const initialNodesState: NodesState = {
@ -47,6 +48,7 @@ export const initialNodesState: NodesState = {
shouldShowFieldTypeLegend: false, shouldShowFieldTypeLegend: false,
shouldShowMinimapPanel: true, shouldShowMinimapPanel: true,
editorInstance: undefined, editorInstance: undefined,
progressNodeSize: { width: 512, height: 512 },
}; };
const nodesSlice = createSlice({ const nodesSlice = createSlice({
@ -157,6 +159,12 @@ const nodesSlice = createSlice({
loadFileEdges: (state, action: PayloadAction<Edge[]>) => { loadFileEdges: (state, action: PayloadAction<Edge[]>) => {
state.edges = action.payload; state.edges = action.payload;
}, },
setProgressNodeSize: (
state,
action: PayloadAction<{ width: number; height: number }>
) => {
state.progressNodeSize = action.payload;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => { builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => {
@ -182,6 +190,7 @@ export const {
setEditorInstance, setEditorInstance,
loadFileNodes, loadFileNodes,
loadFileEdges, loadFileEdges,
setProgressNodeSize,
} = nodesSlice.actions; } = nodesSlice.actions;
export default nodesSlice.reducer; export default nodesSlice.reducer;

View File

@ -29,6 +29,7 @@ export const addControlNetToLinearGraph = (
const controlNetIterateNode: CollectInvocation = { const controlNetIterateNode: CollectInvocation = {
id: CONTROL_NET_COLLECT, id: CONTROL_NET_COLLECT,
type: 'collect', type: 'collect',
is_intermediate: true,
}; };
graph.nodes[CONTROL_NET_COLLECT] = controlNetIterateNode; graph.nodes[CONTROL_NET_COLLECT] = controlNetIterateNode;
graph.edges.push({ graph.edges.push({
@ -55,6 +56,7 @@ export const addControlNetToLinearGraph = (
const controlNetNode: ControlNetInvocation = { const controlNetNode: ControlNetInvocation = {
id: `control_net_${controlNetId}`, id: `control_net_${controlNetId}`,
type: 'controlnet', type: 'controlnet',
is_intermediate: true,
begin_step_percent: beginStepPct, begin_step_percent: beginStepPct,
end_step_percent: endStepPct, end_step_percent: endStepPct,
control_mode: controlMode, control_mode: controlMode,

View File

@ -43,6 +43,7 @@ export const addDynamicPromptsToGraph = (
const dynamicPromptNode: DynamicPromptInvocation = { const dynamicPromptNode: DynamicPromptInvocation = {
id: DYNAMIC_PROMPT, id: DYNAMIC_PROMPT,
type: 'dynamic_prompt', type: 'dynamic_prompt',
is_intermediate: true,
max_prompts: combinatorial ? maxPrompts : iterations, max_prompts: combinatorial ? maxPrompts : iterations,
combinatorial, combinatorial,
prompt: positivePrompt, prompt: positivePrompt,
@ -51,6 +52,7 @@ export const addDynamicPromptsToGraph = (
const iterateNode: IterateInvocation = { const iterateNode: IterateInvocation = {
id: ITERATE, id: ITERATE,
type: 'iterate', type: 'iterate',
is_intermediate: true,
}; };
graph.nodes[DYNAMIC_PROMPT] = dynamicPromptNode; graph.nodes[DYNAMIC_PROMPT] = dynamicPromptNode;
@ -99,6 +101,7 @@ export const addDynamicPromptsToGraph = (
const randomIntNode: RandomIntInvocation = { const randomIntNode: RandomIntInvocation = {
id: RANDOM_INT, id: RANDOM_INT,
type: 'rand_int', type: 'rand_int',
is_intermediate: true,
}; };
graph.nodes[RANDOM_INT] = randomIntNode; graph.nodes[RANDOM_INT] = randomIntNode;
@ -133,6 +136,7 @@ export const addDynamicPromptsToGraph = (
const rangeOfSizeNode: RangeOfSizeInvocation = { const rangeOfSizeNode: RangeOfSizeInvocation = {
id: RANGE_OF_SIZE, id: RANGE_OF_SIZE,
type: 'range_of_size', type: 'range_of_size',
is_intermediate: true,
size: iterations, size: iterations,
step: 1, step: 1,
}; };
@ -140,6 +144,7 @@ export const addDynamicPromptsToGraph = (
const iterateNode: IterateInvocation = { const iterateNode: IterateInvocation = {
id: ITERATE, id: ITERATE,
type: 'iterate', type: 'iterate',
is_intermediate: true,
}; };
graph.nodes[ITERATE] = iterateNode; graph.nodes[ITERATE] = iterateNode;
@ -186,6 +191,7 @@ export const addDynamicPromptsToGraph = (
const randomIntNode: RandomIntInvocation = { const randomIntNode: RandomIntInvocation = {
id: RANDOM_INT, id: RANDOM_INT,
type: 'rand_int', type: 'rand_int',
is_intermediate: true,
}; };
graph.nodes[RANDOM_INT] = randomIntNode; graph.nodes[RANDOM_INT] = randomIntNode;

View File

@ -60,6 +60,7 @@ export const addLoRAsToGraph = (
const loraLoaderNode: LoraLoaderInvocation = { const loraLoaderNode: LoraLoaderInvocation = {
type: 'lora_loader', type: 'lora_loader',
id: currentLoraNodeId, id: currentLoraNodeId,
is_intermediate: true,
lora: { model_name, base_model }, lora: { model_name, base_model },
weight, weight,
}; };

View File

@ -28,6 +28,7 @@ export const addVAEToGraph = (
graph.nodes[VAE_LOADER] = { graph.nodes[VAE_LOADER] = {
type: 'vae_loader', type: 'vae_loader',
id: VAE_LOADER, id: VAE_LOADER,
is_intermediate: true,
vae_model: vae, vae_model: vae,
}; };
} }

View File

@ -1,10 +1,9 @@
import { RootState } from 'app/store/store';
import { ImageDTO } from 'services/api/types';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { forEach } from 'lodash-es'; import { RootState } from 'app/store/store';
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
import { NonNullableGraph } from 'features/nodes/types/types'; import { NonNullableGraph } from 'features/nodes/types/types';
import { ImageDTO } from 'services/api/types';
import { buildCanvasImageToImageGraph } from './buildCanvasImageToImageGraph'; import { buildCanvasImageToImageGraph } from './buildCanvasImageToImageGraph';
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
import { buildCanvasTextToImageGraph } from './buildCanvasTextToImageGraph'; import { buildCanvasTextToImageGraph } from './buildCanvasTextToImageGraph';
const moduleLog = log.child({ namespace: 'nodes' }); const moduleLog = log.child({ namespace: 'nodes' });
@ -31,9 +30,5 @@ export const buildCanvasGraph = (
graph = buildCanvasInpaintGraph(state, canvasInitImage, canvasMaskImage); graph = buildCanvasInpaintGraph(state, canvasInitImage, canvasMaskImage);
} }
forEach(graph.nodes, (node) => {
graph.nodes[node.id].is_intermediate = true;
});
return graph; return graph;
}; };

View File

@ -50,6 +50,8 @@ export const buildCanvasImageToImageGraph = (
// The bounding box determines width and height, not the width and height params // The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions; const { width, height } = state.canvas.boundingBoxDimensions;
const { shouldAutoSave } = state.canvas;
if (!model) { if (!model) {
moduleLog.error('No model found in state'); moduleLog.error('No model found in state');
throw new Error('No model found in state'); throw new Error('No model found in state');
@ -75,35 +77,42 @@ export const buildCanvasImageToImageGraph = (
[POSITIVE_CONDITIONING]: { [POSITIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: POSITIVE_CONDITIONING, id: POSITIVE_CONDITIONING,
is_intermediate: true,
prompt: positivePrompt, prompt: positivePrompt,
}, },
[NEGATIVE_CONDITIONING]: { [NEGATIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: NEGATIVE_CONDITIONING, id: NEGATIVE_CONDITIONING,
is_intermediate: true,
prompt: negativePrompt, prompt: negativePrompt,
}, },
[NOISE]: { [NOISE]: {
type: 'noise', type: 'noise',
id: NOISE, id: NOISE,
is_intermediate: true,
use_cpu, use_cpu,
}, },
[MAIN_MODEL_LOADER]: { [MAIN_MODEL_LOADER]: {
type: 'main_model_loader', type: 'main_model_loader',
id: MAIN_MODEL_LOADER, id: MAIN_MODEL_LOADER,
is_intermediate: true,
model, model,
}, },
[CLIP_SKIP]: { [CLIP_SKIP]: {
type: 'clip_skip', type: 'clip_skip',
id: CLIP_SKIP, id: CLIP_SKIP,
is_intermediate: true,
skipped_layers: clipSkip, skipped_layers: clipSkip,
}, },
[LATENTS_TO_IMAGE]: { [LATENTS_TO_IMAGE]: {
is_intermediate: !shouldAutoSave,
type: 'l2i', type: 'l2i',
id: LATENTS_TO_IMAGE, id: LATENTS_TO_IMAGE,
}, },
[LATENTS_TO_LATENTS]: { [LATENTS_TO_LATENTS]: {
type: 'l2l', type: 'l2l',
id: LATENTS_TO_LATENTS, id: LATENTS_TO_LATENTS,
is_intermediate: true,
cfg_scale, cfg_scale,
scheduler, scheduler,
steps, steps,
@ -112,6 +121,7 @@ export const buildCanvasImageToImageGraph = (
[IMAGE_TO_LATENTS]: { [IMAGE_TO_LATENTS]: {
type: 'i2l', type: 'i2l',
id: IMAGE_TO_LATENTS, id: IMAGE_TO_LATENTS,
is_intermediate: true,
// must be set manually later, bc `fit` parameter may require a resize node inserted // must be set manually later, bc `fit` parameter may require a resize node inserted
// image: { // image: {
// image_name: initialImage.image_name, // image_name: initialImage.image_name,

View File

@ -61,12 +61,17 @@ export const buildCanvasInpaintGraph = (
const { width, height } = state.canvas.boundingBoxDimensions; const { width, height } = state.canvas.boundingBoxDimensions;
// We may need to set the inpaint width and height to scale the image // We may need to set the inpaint width and height to scale the image
const { scaledBoundingBoxDimensions, boundingBoxScaleMethod } = state.canvas; const {
scaledBoundingBoxDimensions,
boundingBoxScaleMethod,
shouldAutoSave,
} = state.canvas;
const graph: NonNullableGraph = { const graph: NonNullableGraph = {
id: INPAINT_GRAPH, id: INPAINT_GRAPH,
nodes: { nodes: {
[INPAINT]: { [INPAINT]: {
is_intermediate: !shouldAutoSave,
type: 'inpaint', type: 'inpaint',
id: INPAINT, id: INPAINT,
steps, steps,
@ -100,26 +105,31 @@ export const buildCanvasInpaintGraph = (
[POSITIVE_CONDITIONING]: { [POSITIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: POSITIVE_CONDITIONING, id: POSITIVE_CONDITIONING,
is_intermediate: true,
prompt: positivePrompt, prompt: positivePrompt,
}, },
[NEGATIVE_CONDITIONING]: { [NEGATIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: NEGATIVE_CONDITIONING, id: NEGATIVE_CONDITIONING,
is_intermediate: true,
prompt: negativePrompt, prompt: negativePrompt,
}, },
[MAIN_MODEL_LOADER]: { [MAIN_MODEL_LOADER]: {
type: 'main_model_loader', type: 'main_model_loader',
id: MAIN_MODEL_LOADER, id: MAIN_MODEL_LOADER,
is_intermediate: true,
model, model,
}, },
[CLIP_SKIP]: { [CLIP_SKIP]: {
type: 'clip_skip', type: 'clip_skip',
id: CLIP_SKIP, id: CLIP_SKIP,
is_intermediate: true,
skipped_layers: clipSkip, skipped_layers: clipSkip,
}, },
[RANGE_OF_SIZE]: { [RANGE_OF_SIZE]: {
type: 'range_of_size', type: 'range_of_size',
id: RANGE_OF_SIZE, id: RANGE_OF_SIZE,
is_intermediate: true,
// seed - must be connected manually // seed - must be connected manually
// start: 0, // start: 0,
size: iterations, size: iterations,
@ -128,6 +138,7 @@ export const buildCanvasInpaintGraph = (
[ITERATE]: { [ITERATE]: {
type: 'iterate', type: 'iterate',
id: ITERATE, id: ITERATE,
is_intermediate: true,
}, },
}, },
edges: [ edges: [

View File

@ -41,6 +41,8 @@ export const buildCanvasTextToImageGraph = (
// The bounding box determines width and height, not the width and height params // The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions; const { width, height } = state.canvas.boundingBoxDimensions;
const { shouldAutoSave } = state.canvas;
if (!model) { if (!model) {
moduleLog.error('No model found in state'); moduleLog.error('No model found in state');
throw new Error('No model found in state'); throw new Error('No model found in state');
@ -66,16 +68,19 @@ export const buildCanvasTextToImageGraph = (
[POSITIVE_CONDITIONING]: { [POSITIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: POSITIVE_CONDITIONING, id: POSITIVE_CONDITIONING,
is_intermediate: true,
prompt: positivePrompt, prompt: positivePrompt,
}, },
[NEGATIVE_CONDITIONING]: { [NEGATIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: NEGATIVE_CONDITIONING, id: NEGATIVE_CONDITIONING,
is_intermediate: true,
prompt: negativePrompt, prompt: negativePrompt,
}, },
[NOISE]: { [NOISE]: {
type: 'noise', type: 'noise',
id: NOISE, id: NOISE,
is_intermediate: true,
width, width,
height, height,
use_cpu, use_cpu,
@ -83,6 +88,7 @@ export const buildCanvasTextToImageGraph = (
[TEXT_TO_LATENTS]: { [TEXT_TO_LATENTS]: {
type: 't2l', type: 't2l',
id: TEXT_TO_LATENTS, id: TEXT_TO_LATENTS,
is_intermediate: true,
cfg_scale, cfg_scale,
scheduler, scheduler,
steps, steps,
@ -90,16 +96,19 @@ export const buildCanvasTextToImageGraph = (
[MAIN_MODEL_LOADER]: { [MAIN_MODEL_LOADER]: {
type: 'main_model_loader', type: 'main_model_loader',
id: MAIN_MODEL_LOADER, id: MAIN_MODEL_LOADER,
is_intermediate: true,
model, model,
}, },
[CLIP_SKIP]: { [CLIP_SKIP]: {
type: 'clip_skip', type: 'clip_skip',
id: CLIP_SKIP, id: CLIP_SKIP,
is_intermediate: true,
skipped_layers: clipSkip, skipped_layers: clipSkip,
}, },
[LATENTS_TO_IMAGE]: { [LATENTS_TO_IMAGE]: {
type: 'l2i', type: 'l2i',
id: LATENTS_TO_IMAGE, id: LATENTS_TO_IMAGE,
is_intermediate: !shouldAutoSave,
}, },
}, },
edges: [ edges: [

View File

@ -5,12 +5,11 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import useImageUploader from 'common/hooks/useImageUploader';
import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FaUndo, FaUpload } from 'react-icons/fa'; import { FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image';
import InitialImage from './InitialImage'; import InitialImage from './InitialImage';
import { PostUploadAction } from 'services/api/types';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
@ -30,7 +29,6 @@ const postUploadAction: PostUploadAction = {
const InitialImageDisplay = () => { const InitialImageDisplay = () => {
const { isResetButtonDisabled } = useAppSelector(selector); const { isResetButtonDisabled } = useAppSelector(selector);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { openUploader } = useImageUploader();
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction, postUploadAction,
@ -40,10 +38,6 @@ const InitialImageDisplay = () => {
dispatch(clearInitialImage()); dispatch(clearInitialImage());
}, [dispatch]); }, [dispatch]);
const handleUpload = useCallback(() => {
openUploader();
}, [openUploader]);
return ( return (
<Flex <Flex
layerStyle={'first'} layerStyle={'first'}
@ -85,7 +79,6 @@ const InitialImageDisplay = () => {
tooltip={'Upload Initial Image'} tooltip={'Upload Initial Image'}
aria-label={'Upload Initial Image'} aria-label={'Upload Initial Image'}
icon={<FaUpload />} icon={<FaUpload />}
onClick={handleUpload}
{...getUploadButtonProps()} {...getUploadButtonProps()}
/> />
<IAIIconButton <IAIIconButton

View File

@ -244,22 +244,7 @@ export const useRecallParameters = () => {
[dispatch, parameterSetToast, parameterNotSetToast] [dispatch, parameterSetToast, parameterNotSetToast]
); );
/** /*
* Sets initial image with toast
*/
const recallInitialImage = useCallback(
async (image: unknown) => {
if (!isImageField(image)) {
parameterNotSetToast();
return;
}
dispatch(initialImageSelected(image.image_name));
parameterSetToast();
},
[dispatch, parameterSetToast, parameterNotSetToast]
);
/**
* Sets image as initial image with toast * Sets image as initial image with toast
*/ */
const sendToImageToImage = useCallback( const sendToImageToImage = useCallback(
@ -330,7 +315,6 @@ export const useRecallParameters = () => {
recallPositivePrompt, recallPositivePrompt,
recallNegativePrompt, recallNegativePrompt,
recallSeed, recallSeed,
recallInitialImage,
recallCfgScale, recallCfgScale,
recallModel, recallModel,
recallScheduler, recallScheduler,

View File

@ -1,7 +1,7 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import { ImageDTO, MainModelField } from 'services/api/types'; import { ImageDTO, MainModelField } from 'services/api/types';
export const initialImageSelected = createAction<ImageDTO | string | undefined>( export const initialImageSelected = createAction<ImageDTO | undefined>(
'generation/initialImageSelected' 'generation/initialImageSelected'
); );

View File

@ -0,0 +1,60 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useCallback, useEffect, useState } from 'react';
import { StyledFlex } from './SettingsModal';
import { Heading, Text } from '@chakra-ui/react';
import IAIButton from '../../../../common/components/IAIButton';
import { useClearIntermediatesMutation } from '../../../../services/api/endpoints/images';
import { addToast } from '../../store/systemSlice';
import { resetCanvas } from '../../../canvas/store/canvasSlice';
export default function SettingsClearIntermediates() {
const dispatch = useAppDispatch();
const [isDisabled, setIsDisabled] = useState(false);
const [clearIntermediates, { isLoading: isLoadingClearIntermediates }] =
useClearIntermediatesMutation();
const handleClickClearIntermediates = useCallback(() => {
clearIntermediates({})
.unwrap()
.then((response) => {
dispatch(resetCanvas());
dispatch(
addToast({
title:
response === 0
? `No intermediates to clear`
: `Successfully cleared ${response} intermediates`,
status: 'info',
})
);
if (response < 100) {
setIsDisabled(true);
}
});
}, [clearIntermediates, dispatch]);
return (
<StyledFlex>
<Heading size="sm">Clear Intermediates</Heading>
<IAIButton
colorScheme="error"
onClick={handleClickClearIntermediates}
isLoading={isLoadingClearIntermediates}
isDisabled={isDisabled}
>
{isDisabled ? 'Intermediates Cleared' : 'Clear 100 Intermediates'}
</IAIButton>
<Text>
Will permanently delete first 100 intermediates found on disk and in
database
</Text>
<Text fontWeight="bold">This will also clear your canvas state.</Text>
<Text>
Intermediate images are byproducts of generation, different from the
result images in the gallery. Purging intermediates will free disk
space. Your gallery images will not be deleted.
</Text>
</StyledFlex>
);
}

View File

@ -11,7 +11,7 @@ import {
Text, Text,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector, current } from '@reduxjs/toolkit';
import { VALID_LOG_LEVELS } from 'app/logging/useLogger'; import { VALID_LOG_LEVELS } from 'app/logging/useLogger';
import { LOCALSTORAGE_KEYS, LOCALSTORAGE_PREFIX } from 'app/store/constants'; import { LOCALSTORAGE_KEYS, LOCALSTORAGE_PREFIX } from 'app/store/constants';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@ -23,6 +23,7 @@ import {
SystemState, SystemState,
consoleLogLevelChanged, consoleLogLevelChanged,
setEnableImageDebugging, setEnableImageDebugging,
setIsNodesEnabled,
setShouldConfirmOnDelete, setShouldConfirmOnDelete,
setShouldDisplayGuides, setShouldDisplayGuides,
shouldAntialiasProgressImageChanged, shouldAntialiasProgressImageChanged,
@ -48,6 +49,7 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LogLevelName } from 'roarr'; import { LogLevelName } from 'roarr';
import SettingsSchedulers from './SettingsSchedulers'; import SettingsSchedulers from './SettingsSchedulers';
import SettingsClearIntermediates from './SettingsClearIntermediates';
const selector = createSelector( const selector = createSelector(
[systemSelector, uiSelector], [systemSelector, uiSelector],
@ -59,6 +61,7 @@ const selector = createSelector(
consoleLogLevel, consoleLogLevel,
shouldLogToConsole, shouldLogToConsole,
shouldAntialiasProgressImage, shouldAntialiasProgressImage,
isNodesEnabled,
} = system; } = system;
const { const {
@ -79,6 +82,7 @@ const selector = createSelector(
shouldLogToConsole, shouldLogToConsole,
shouldAntialiasProgressImage, shouldAntialiasProgressImage,
shouldShowAdvancedOptions, shouldShowAdvancedOptions,
isNodesEnabled,
}; };
}, },
{ {
@ -91,6 +95,8 @@ type ConfigOptions = {
shouldShowResetWebUiText: boolean; shouldShowResetWebUiText: boolean;
shouldShowBetaLayout: boolean; shouldShowBetaLayout: boolean;
shouldShowAdvancedOptionsSettings: boolean; shouldShowAdvancedOptionsSettings: boolean;
shouldShowClearIntermediates: boolean;
shouldShowNodesToggle: boolean;
}; };
type SettingsModalProps = { type SettingsModalProps = {
@ -109,6 +115,9 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true; const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true;
const shouldShowAdvancedOptionsSettings = const shouldShowAdvancedOptionsSettings =
config?.shouldShowAdvancedOptionsSettings ?? true; config?.shouldShowAdvancedOptionsSettings ?? true;
const shouldShowClearIntermediates =
config?.shouldShowClearIntermediates ?? true;
const shouldShowNodesToggle = config?.shouldShowNodesToggle ?? true;
useEffect(() => { useEffect(() => {
if (!shouldShowDeveloperSettings) { if (!shouldShowDeveloperSettings) {
@ -139,6 +148,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
shouldLogToConsole, shouldLogToConsole,
shouldAntialiasProgressImage, shouldAntialiasProgressImage,
shouldShowAdvancedOptions, shouldShowAdvancedOptions,
isNodesEnabled,
} = useAppSelector(selector); } = useAppSelector(selector);
const handleClickResetWebUI = useCallback(() => { const handleClickResetWebUI = useCallback(() => {
@ -169,6 +179,13 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
[dispatch] [dispatch]
); );
const handleToggleNodes = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(setIsNodesEnabled(e.target.checked));
},
[dispatch]
);
return ( return (
<> <>
{cloneElement(children, { {cloneElement(children, {
@ -253,6 +270,13 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
) )
} }
/> />
{shouldShowNodesToggle && (
<IAISwitch
label="Enable Nodes Editor (Experimental)"
isChecked={isNodesEnabled}
onChange={handleToggleNodes}
/>
)}
</StyledFlex> </StyledFlex>
{shouldShowDeveloperSettings && ( {shouldShowDeveloperSettings && (
@ -280,6 +304,8 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
</StyledFlex> </StyledFlex>
)} )}
{shouldShowClearIntermediates && <SettingsClearIntermediates />}
<StyledFlex> <StyledFlex>
<Heading size="sm">{t('settings.resetWebUI')}</Heading> <Heading size="sm">{t('settings.resetWebUI')}</Heading>
<IAIButton colorScheme="error" onClick={handleClickResetWebUI}> <IAIButton colorScheme="error" onClick={handleClickResetWebUI}>
@ -328,7 +354,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
export default SettingsModal; export default SettingsModal;
const StyledFlex = (props: PropsWithChildren) => { export const StyledFlex = (props: PropsWithChildren) => {
return ( return (
<Flex <Flex
sx={{ sx={{

View File

@ -6,7 +6,6 @@ import { userInvoked } from 'app/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice'; import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice';
import { t } from 'i18next'; import { t } from 'i18next';
import { LogLevelName } from 'roarr'; import { LogLevelName } from 'roarr';
import { imageUploaded } from 'services/api/thunks/image';
import { import {
isAnySessionRejected, isAnySessionRejected,
sessionCanceled, sessionCanceled,
@ -86,6 +85,7 @@ export interface SystemState {
language: keyof typeof LANGUAGES; language: keyof typeof LANGUAGES;
isUploading: boolean; isUploading: boolean;
boardIdToAddTo?: string; boardIdToAddTo?: string;
isNodesEnabled: boolean;
} }
export const initialSystemState: SystemState = { export const initialSystemState: SystemState = {
@ -118,6 +118,7 @@ export const initialSystemState: SystemState = {
isPersisted: false, isPersisted: false,
language: 'en', language: 'en',
isUploading: false, isUploading: false,
isNodesEnabled: false,
}; };
export const systemSlice = createSlice({ export const systemSlice = createSlice({
@ -193,6 +194,9 @@ export const systemSlice = createSlice({
progressImageSet(state, action: PayloadAction<ProgressImage | null>) { progressImageSet(state, action: PayloadAction<ProgressImage | null>) {
state.progressImage = action.payload; state.progressImage = action.payload;
}, },
setIsNodesEnabled(state, action: PayloadAction<boolean>) {
state.isNodesEnabled = action.payload;
},
}, },
extraReducers(builder) { extraReducers(builder) {
/** /**
@ -360,27 +364,6 @@ export const systemSlice = createSlice({
state.wasSchemaParsed = true; state.wasSchemaParsed = true;
}); });
/**
* Image Uploading Started
*/
builder.addCase(imageUploaded.pending, (state) => {
state.isUploading = true;
});
/**
* Image Uploading Complete
*/
builder.addCase(imageUploaded.rejected, (state) => {
state.isUploading = false;
});
/**
* Image Uploading Complete
*/
builder.addCase(imageUploaded.fulfilled, (state) => {
state.isUploading = false;
});
// *** Matchers - must be after all cases *** // *** Matchers - must be after all cases ***
/** /**
@ -422,6 +405,7 @@ export const {
shouldAntialiasProgressImageChanged, shouldAntialiasProgressImageChanged,
languageChanged, languageChanged,
progressImageSet, progressImageSet,
setIsNodesEnabled,
} = systemSlice.actions; } = systemSlice.actions;
export default systemSlice.reducer; export default systemSlice.reducer;

View File

@ -37,6 +37,7 @@ import NodesTab from './tabs/Nodes/NodesTab';
import ResizeHandle from './tabs/ResizeHandle'; import ResizeHandle from './tabs/ResizeHandle';
import TextToImageTab from './tabs/TextToImage/TextToImageTab'; import TextToImageTab from './tabs/TextToImage/TextToImageTab';
import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab'; import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab';
import { systemSelector } from '../../system/store/systemSelectors';
export interface InvokeTabInfo { export interface InvokeTabInfo {
id: InvokeTabName; id: InvokeTabName;
@ -84,11 +85,20 @@ const tabs: InvokeTabInfo[] = [
]; ];
const enabledTabsSelector = createSelector( const enabledTabsSelector = createSelector(
configSelector, [configSelector, systemSelector],
(config) => { (config, system) => {
const { disabledTabs } = config; const { disabledTabs } = config;
const { isNodesEnabled } = system;
return tabs.filter((tab) => !disabledTabs.includes(tab.id)); const enabledTabs = tabs.filter((tab) => {
if (tab.id === 'nodes') {
return isNodesEnabled && !disabledTabs.includes(tab.id);
} else {
return !disabledTabs.includes(tab.id);
}
});
return enabledTabs;
}, },
{ {
memoizeOptions: { resultEqualityCheck: isEqual }, memoizeOptions: { resultEqualityCheck: isEqual },

View File

@ -17,14 +17,14 @@ type ModelListProps = {
setSelectedModelId: (name: string | undefined) => void; setSelectedModelId: (name: string | undefined) => void;
}; };
type ModelFormat = 'all' | 'checkpoint' | 'diffusers'; type ModelFormat = 'images' | 'checkpoint' | 'diffusers';
const ModelList = (props: ModelListProps) => { const ModelList = (props: ModelListProps) => {
const { selectedModelId, setSelectedModelId } = props; const { selectedModelId, setSelectedModelId } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [nameFilter, setNameFilter] = useState<string>(''); const [nameFilter, setNameFilter] = useState<string>('');
const [modelFormatFilter, setModelFormatFilter] = const [modelFormatFilter, setModelFormatFilter] =
useState<ModelFormat>('all'); useState<ModelFormat>('images');
const { filteredDiffusersModels } = useGetMainModelsQuery(undefined, { const { filteredDiffusersModels } = useGetMainModelsQuery(undefined, {
selectFromResult: ({ data }) => ({ selectFromResult: ({ data }) => ({
@ -47,8 +47,8 @@ const ModelList = (props: ModelListProps) => {
<Flex flexDirection="column" gap={4} paddingInlineEnd={4}> <Flex flexDirection="column" gap={4} paddingInlineEnd={4}>
<ButtonGroup isAttached> <ButtonGroup isAttached>
<IAIButton <IAIButton
onClick={() => setModelFormatFilter('all')} onClick={() => setModelFormatFilter('images')}
isChecked={modelFormatFilter === 'all'} isChecked={modelFormatFilter === 'images'}
size="sm" size="sm"
> >
{t('modelManager.allModels')} {t('modelManager.allModels')}
@ -75,7 +75,7 @@ const ModelList = (props: ModelListProps) => {
labelPos="side" labelPos="side"
/> />
{['all', 'diffusers'].includes(modelFormatFilter) && {['images', 'diffusers'].includes(modelFormatFilter) &&
filteredDiffusersModels.length > 0 && ( filteredDiffusersModels.length > 0 && (
<StyledModelContainer> <StyledModelContainer>
<Flex sx={{ gap: 2, flexDir: 'column' }}> <Flex sx={{ gap: 2, flexDir: 'column' }}>
@ -93,7 +93,7 @@ const ModelList = (props: ModelListProps) => {
</Flex> </Flex>
</StyledModelContainer> </StyledModelContainer>
)} )}
{['all', 'checkpoint'].includes(modelFormatFilter) && {['images', 'checkpoint'].includes(modelFormatFilter) &&
filteredCheckpointModels.length > 0 && ( filteredCheckpointModels.length > 0 && (
<StyledModelContainer> <StyledModelContainer>
<Flex sx={{ gap: 2, flexDir: 'column' }}> <Flex sx={{ gap: 2, flexDir: 'column' }}>

View File

@ -1,22 +1,28 @@
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors'; import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa'; import { FaUpload } from 'react-icons/fa';
export default function UnifiedCanvasFileUploader() { export default function UnifiedCanvasFileUploader() {
const isStaging = useAppSelector(isStagingSelector); const isStaging = useAppSelector(isStagingSelector);
const { openUploader } = useImageUploader();
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
});
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<>
<IAIIconButton <IAIIconButton
aria-label={t('common.upload')} aria-label={t('common.upload')}
tooltip={t('common.upload')} tooltip={t('common.upload')}
icon={<FaUpload />} icon={<FaUpload />}
onClick={openUploader}
isDisabled={isStaging} isDisabled={isStaging}
{...getUploadButtonProps()}
/> />
<input {...getUploadInputProps()} />
</>
); );
} }

Some files were not shown because too many files have changed in this diff Show More