mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
Merge branch 'main' into release/invokeai-3-0-beta
This commit is contained in:
commit
8439e30798
@ -24,11 +24,14 @@ async def create_board_image(
|
||||
):
|
||||
"""Creates a board_image"""
|
||||
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
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="Failed to add to board")
|
||||
|
||||
|
||||
|
||||
@board_images_router.delete(
|
||||
"/",
|
||||
operation_id="remove_board_image",
|
||||
@ -43,27 +46,10 @@ async def remove_board_image(
|
||||
):
|
||||
"""Deletes a board_image"""
|
||||
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
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
|
@ -1,16 +1,28 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from fastapi import Body, HTTPException, Path, Query
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from invokeai.app.services.board_record_storage import BoardChanges
|
||||
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
|
||||
from invokeai.app.services.models.board_record import BoardDTO
|
||||
|
||||
|
||||
from ..dependencies import ApiDependencies
|
||||
|
||||
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(
|
||||
"/",
|
||||
operation_id="create_board",
|
||||
@ -69,25 +81,42 @@ async def 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(
|
||||
board_id: str = Path(description="The id of board to delete"),
|
||||
include_images: Optional[bool] = Query(
|
||||
description="Permanently delete all images on the board", default=False
|
||||
),
|
||||
) -> None:
|
||||
) -> DeleteBoardResult:
|
||||
"""Deletes a board"""
|
||||
try:
|
||||
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(
|
||||
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:
|
||||
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)
|
||||
return DeleteBoardResult(
|
||||
board_id=board_id,
|
||||
deleted_board_images=deleted_board_images,
|
||||
deleted_images=[],
|
||||
)
|
||||
except Exception as e:
|
||||
# TODO: Does this need any exception handling at all?
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail="Failed to delete board")
|
||||
|
||||
|
||||
@boards_router.get(
|
||||
@ -115,3 +144,19 @@ async def list_boards(
|
||||
status_code=400,
|
||||
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
|
||||
|
@ -84,6 +84,17 @@ async def delete_image(
|
||||
# TODO: Does this need any exception handling at all?
|
||||
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(
|
||||
"/{image_name}",
|
||||
@ -234,16 +245,16 @@ async def get_image_urls(
|
||||
)
|
||||
async def list_image_dtos(
|
||||
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(
|
||||
default=None, description="The categories of image to include"
|
||||
default=None, description="The categories of image to include."
|
||||
),
|
||||
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(
|
||||
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"),
|
||||
limit: int = Query(default=10, description="The number of images per page"),
|
||||
|
@ -22,7 +22,7 @@ from ...backend.stable_diffusion.diffusers_pipeline import (
|
||||
from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \
|
||||
PostprocessingSettings
|
||||
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 .baseinvocation import (BaseInvocation, BaseInvocationOutput,
|
||||
InvocationConfig, InvocationContext)
|
||||
@ -38,7 +38,6 @@ from diffusers.models.attention_processor import (
|
||||
XFormersAttnProcessor,
|
||||
)
|
||||
|
||||
|
||||
class LatentsField(BaseModel):
|
||||
"""A latents field used for passing latents between invocations"""
|
||||
|
||||
|
@ -32,11 +32,11 @@ class BoardImageRecordStorageBase(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_images_for_board(
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
) -> OffsetPaginatedResults[ImageRecord]:
|
||||
"""Gets images for a board."""
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -211,6 +211,26 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
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(
|
||||
self,
|
||||
image_name: str,
|
||||
|
@ -38,11 +38,11 @@ class BoardImagesServiceABC(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_images_for_board(
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
) -> OffsetPaginatedResults[ImageDTO]:
|
||||
"""Gets images for a board."""
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -98,30 +98,13 @@ class BoardImagesService(BoardImagesServiceABC):
|
||||
) -> None:
|
||||
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,
|
||||
board_id: str,
|
||||
) -> OffsetPaginatedResults[ImageDTO]:
|
||||
image_records = self._services.board_image_records.get_images_for_board(
|
||||
) -> list[str]:
|
||||
return self._services.board_image_records.get_all_board_image_names_for_board(
|
||||
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(
|
||||
self,
|
||||
@ -136,7 +119,7 @@ def board_record_to_dto(
|
||||
) -> BoardDTO:
|
||||
"""Converts a board record to a board DTO."""
|
||||
return BoardDTO(
|
||||
**board_record.dict(exclude={'cover_image_name'}),
|
||||
**board_record.dict(exclude={"cover_image_name"}),
|
||||
cover_image_name=cover_image_name,
|
||||
image_count=image_count,
|
||||
)
|
||||
|
@ -141,7 +141,7 @@ class EventServiceBase:
|
||||
model_type=model_type,
|
||||
submodel=submodel,
|
||||
hash=model_info.hash,
|
||||
location=model_info.location,
|
||||
location=str(model_info.location),
|
||||
precision=str(model_info.precision),
|
||||
),
|
||||
)
|
||||
|
@ -10,7 +10,10 @@ from pydantic.generics import GenericModel
|
||||
|
||||
from invokeai.app.models.image import ImageCategory, ResourceOrigin
|
||||
from invokeai.app.services.models.image_record import (
|
||||
ImageRecord, ImageRecordChanges, deserialize_image_record)
|
||||
ImageRecord,
|
||||
ImageRecordChanges,
|
||||
deserialize_image_record,
|
||||
)
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
@ -97,8 +100,8 @@ class ImageRecordStorageBase(ABC):
|
||||
@abstractmethod
|
||||
def get_many(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
offset: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
@ -322,8 +325,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
|
||||
def get_many(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
offset: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
@ -377,11 +380,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
|
||||
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
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
|
||||
query_params.append(board_id)
|
||||
|
||||
query_pagination = """--sql
|
||||
@ -392,8 +399,12 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
images_query += query_conditions + query_pagination + ";"
|
||||
# Add all the parameters
|
||||
images_params = query_params.copy()
|
||||
images_params.append(limit)
|
||||
images_params.append(offset)
|
||||
|
||||
if limit is not None:
|
||||
images_params.append(limit)
|
||||
if offset is not None:
|
||||
images_params.append(offset)
|
||||
|
||||
# Build the list of images, deserializing each row
|
||||
self._cursor.execute(images_query, images_params)
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
|
@ -11,7 +11,6 @@ from invokeai.app.models.image import (ImageCategory,
|
||||
InvalidOriginException, ResourceOrigin)
|
||||
from invokeai.app.services.board_image_record_storage import \
|
||||
BoardImageRecordStorageBase
|
||||
from invokeai.app.services.graph import Graph
|
||||
from invokeai.app.services.image_file_storage import (
|
||||
ImageFileDeleteException, ImageFileNotFoundException,
|
||||
ImageFileSaveException, ImageFileStorageBase)
|
||||
@ -109,6 +108,13 @@ class ImageServiceABC(ABC):
|
||||
"""Deletes an image."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_many(self, is_intermediate: bool) -> int:
|
||||
"""Deletes many images."""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def delete_images_on_board(self, board_id: str):
|
||||
"""Deletes all images on a board."""
|
||||
@ -378,16 +384,39 @@ class ImageService(ImageServiceABC):
|
||||
|
||||
def delete_images_on_board(self, board_id: str):
|
||||
try:
|
||||
images = self._services.board_image_records.get_images_for_board(board_id)
|
||||
image_name_list = list(
|
||||
map(
|
||||
lambda r: r.image_name,
|
||||
images.items,
|
||||
image_names = (
|
||||
self._services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id
|
||||
)
|
||||
)
|
||||
for image_name in image_name_list:
|
||||
for image_name in image_names:
|
||||
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:
|
||||
self._services.logger.error(f"Failed to delete image records")
|
||||
raise
|
||||
|
@ -21,6 +21,7 @@ import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from packaging import version
|
||||
|
||||
import torch
|
||||
from safetensors.torch import load_file
|
||||
@ -63,6 +64,7 @@ from diffusers.pipelines.stable_diffusion.safety_checker import (
|
||||
StableDiffusionSafetyChecker,
|
||||
)
|
||||
from diffusers.utils import is_safetensors_available
|
||||
import transformers
|
||||
from transformers import (
|
||||
AutoFeatureExtractor,
|
||||
BertTokenizerFast,
|
||||
@ -841,7 +843,16 @@ def convert_ldm_clip_checkpoint(checkpoint):
|
||||
key
|
||||
]
|
||||
|
||||
text_model.load_state_dict(text_model_dict)
|
||||
# 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)
|
||||
|
||||
return text_model
|
||||
|
||||
@ -947,7 +958,16 @@ def convert_open_clip_checkpoint(checkpoint):
|
||||
|
||||
text_model_dict[new_key] = checkpoint[key]
|
||||
|
||||
text_model.load_state_dict(text_model_dict)
|
||||
# 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)
|
||||
|
||||
return text_model
|
||||
|
||||
|
@ -15,7 +15,6 @@ import InvokeTabs from 'features/ui/components/InvokeTabs';
|
||||
import ParametersDrawer from 'features/ui/components/ParametersDrawer';
|
||||
import i18n from 'i18n';
|
||||
import { ReactNode, memo, useEffect } from 'react';
|
||||
import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal';
|
||||
import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal';
|
||||
import GlobalHotkeys from './GlobalHotkeys';
|
||||
import Toaster from './Toaster';
|
||||
@ -84,7 +83,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
|
||||
</Grid>
|
||||
<DeleteImageModal />
|
||||
<UpdateImageBoardModal />
|
||||
<DeleteBoardImagesModal />
|
||||
<Toaster />
|
||||
<GlobalHotkeys />
|
||||
</>
|
||||
|
@ -15,10 +15,7 @@ const STYLES: ChakraProps['sx'] = {
|
||||
maxH: BOX_SIZE,
|
||||
shadow: 'dark-lg',
|
||||
borderRadius: 'lg',
|
||||
borderWidth: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'base.100',
|
||||
opacity: 0.5,
|
||||
opacity: 0.3,
|
||||
bg: 'base.800',
|
||||
color: 'base.50',
|
||||
_dark: {
|
||||
|
@ -28,6 +28,7 @@ const ImageDndContext = (props: ImageDndContextProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
console.log('dragStart', event.active.data.current);
|
||||
const activeData = event.active.data.current;
|
||||
if (!activeData) {
|
||||
return;
|
||||
@ -37,15 +38,16 @@ const ImageDndContext = (props: ImageDndContextProps) => {
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
console.log('dragEnd', event.active.data.current);
|
||||
const activeData = event.active.data.current;
|
||||
const overData = event.over?.data.current;
|
||||
if (!activeData || !overData) {
|
||||
if (!activeDragData || !overData) {
|
||||
return;
|
||||
}
|
||||
dispatch(dndDropped({ overData, activeData }));
|
||||
dispatch(dndDropped({ overData, activeData: activeDragData }));
|
||||
setActiveDragData(null);
|
||||
},
|
||||
[dispatch]
|
||||
[activeDragData, dispatch]
|
||||
);
|
||||
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
useDraggable as useOriginalDraggable,
|
||||
useDroppable as useOriginalDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
import { BoardId } from 'features/gallery/store/gallerySlice';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
|
||||
type BaseDropData = {
|
||||
@ -55,7 +56,7 @@ export type AddToBatchDropData = BaseDropData & {
|
||||
|
||||
export type MoveBoardDropData = BaseDropData & {
|
||||
actionType: 'MOVE_BOARD';
|
||||
context: { boardId: string | null };
|
||||
context: { boardId: BoardId };
|
||||
};
|
||||
|
||||
export type TypesafeDroppableData =
|
||||
@ -158,8 +159,36 @@ export const isValidDrop = (
|
||||
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
|
||||
case 'ADD_TO_BATCH':
|
||||
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
|
||||
case 'MOVE_BOARD':
|
||||
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
|
||||
case 'MOVE_BOARD': {
|
||||
// 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:
|
||||
return false;
|
||||
}
|
||||
|
@ -18,7 +18,6 @@ import { Middleware } from '@reduxjs/toolkit';
|
||||
import ImageDndContext from './ImageDnd/ImageDndContext';
|
||||
import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext';
|
||||
import { $authToken, $baseUrl } from 'services/api/client';
|
||||
import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider'));
|
||||
@ -78,9 +77,7 @@ const InvokeAIUI = ({
|
||||
<ThemeLocaleProvider>
|
||||
<ImageDndContext>
|
||||
<AddImageToBoardContextProvider>
|
||||
<DeleteBoardImagesContextProvider>
|
||||
<App config={config} headerComponent={headerComponent} />
|
||||
</DeleteBoardImagesContextProvider>
|
||||
<App config={config} headerComponent={headerComponent} />
|
||||
</AddImageToBoardContextProvider>
|
||||
</ImageDndContext>
|
||||
</ThemeLocaleProvider>
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
|
||||
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 = {
|
||||
isInitialImage: boolean;
|
||||
@ -40,8 +41,7 @@ type Props = PropsWithChildren;
|
||||
export const AddImageToBoardContextProvider = (props: Props) => {
|
||||
const [imageToMove, setImageToMove] = useState<ImageDTO>();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const [addImageToBoard, result] = useAddImageToBoardMutation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Clean up after deleting or dismissing the modal
|
||||
const closeAndClearImageToDelete = useCallback(() => {
|
||||
@ -63,14 +63,16 @@ export const AddImageToBoardContextProvider = (props: Props) => {
|
||||
const handleAddToBoard = useCallback(
|
||||
(boardId: string) => {
|
||||
if (imageToMove) {
|
||||
addImageToBoard({
|
||||
board_id: boardId,
|
||||
image_name: imageToMove.image_name,
|
||||
});
|
||||
dispatch(
|
||||
imagesApi.endpoints.addImageToBoard.initiate({
|
||||
imageDTO: imageToMove,
|
||||
board_id: boardId,
|
||||
})
|
||||
);
|
||||
closeAndClearImageToDelete();
|
||||
}
|
||||
},
|
||||
[addImageToBoard, closeAndClearImageToDelete, imageToMove]
|
||||
[dispatch, closeAndClearImageToDelete, imageToMove]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -11,7 +11,7 @@ import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingA
|
||||
import { addAppConfigReceivedListener } from './listeners/appConfigReceived';
|
||||
import { addAppStartedListener } from './listeners/appStarted';
|
||||
import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
|
||||
import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted';
|
||||
import { addDeleteBoardAndImagesFulfilledListener } from './listeners/boardAndImagesDeleted';
|
||||
import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard';
|
||||
import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage';
|
||||
import { addCanvasMergedListener } from './listeners/canvasMerged';
|
||||
@ -29,10 +29,6 @@ import {
|
||||
addRequestedImageDeletionListener,
|
||||
} from './listeners/imageDeleted';
|
||||
import { addImageDroppedListener } from './listeners/imageDropped';
|
||||
import {
|
||||
addImageMetadataReceivedFulfilledListener,
|
||||
addImageMetadataReceivedRejectedListener,
|
||||
} from './listeners/imageMetadataReceived';
|
||||
import {
|
||||
addImageRemovedFromBoardFulfilledListener,
|
||||
addImageRemovedFromBoardRejectedListener,
|
||||
@ -46,18 +42,10 @@ import {
|
||||
addImageUploadedFulfilledListener,
|
||||
addImageUploadedRejectedListener,
|
||||
} from './listeners/imageUploaded';
|
||||
import {
|
||||
addImageUrlsReceivedFulfilledListener,
|
||||
addImageUrlsReceivedRejectedListener,
|
||||
} from './listeners/imageUrlsReceived';
|
||||
import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
|
||||
import { addModelSelectedListener } from './listeners/modelSelected';
|
||||
import { addModelsLoadedListener } from './listeners/modelsLoaded';
|
||||
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
|
||||
import {
|
||||
addReceivedPageOfImagesFulfilledListener,
|
||||
addReceivedPageOfImagesRejectedListener,
|
||||
} from './listeners/receivedPageOfImages';
|
||||
import {
|
||||
addSessionCanceledFulfilledListener,
|
||||
addSessionCanceledPendingListener,
|
||||
@ -91,6 +79,7 @@ import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextTo
|
||||
import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted';
|
||||
import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted';
|
||||
import { addUpscaleRequestedListener } from './listeners/upscaleRequested';
|
||||
import { addFirstListImagesListener } from './listeners/addFirstListImagesListener.ts';
|
||||
|
||||
export const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
@ -132,17 +121,9 @@ addRequestedImageDeletionListener();
|
||||
addImageDeletedPendingListener();
|
||||
addImageDeletedFulfilledListener();
|
||||
addImageDeletedRejectedListener();
|
||||
addRequestedBoardImageDeletionListener();
|
||||
addDeleteBoardAndImagesFulfilledListener();
|
||||
addImageToDeleteSelectedListener();
|
||||
|
||||
// Image metadata
|
||||
addImageMetadataReceivedFulfilledListener();
|
||||
addImageMetadataReceivedRejectedListener();
|
||||
|
||||
// Image URLs
|
||||
addImageUrlsReceivedFulfilledListener();
|
||||
addImageUrlsReceivedRejectedListener();
|
||||
|
||||
// User Invoked
|
||||
addUserInvokedCanvasListener();
|
||||
addUserInvokedNodesListener();
|
||||
@ -198,17 +179,10 @@ addSessionCanceledPendingListener();
|
||||
addSessionCanceledFulfilledListener();
|
||||
addSessionCanceledRejectedListener();
|
||||
|
||||
// Fetching images
|
||||
addReceivedPageOfImagesFulfilledListener();
|
||||
addReceivedPageOfImagesRejectedListener();
|
||||
|
||||
// ControlNet
|
||||
addControlNetImageProcessedListener();
|
||||
addControlNetAutoProcessListener();
|
||||
|
||||
// Update image URLs on connect
|
||||
// addUpdateImageUrlsOnConnectListener();
|
||||
|
||||
// Boards
|
||||
addImageAddedToBoardFulfilledListener();
|
||||
addImageAddedToBoardRejectedListener();
|
||||
@ -229,5 +203,7 @@ addModelSelectedListener();
|
||||
addAppStartedListener();
|
||||
addModelsLoadedListener();
|
||||
addAppConfigReceivedListener();
|
||||
addFirstListImagesListener();
|
||||
|
||||
// Ad-hoc upscale workflwo
|
||||
addUpscaleRequestedListener();
|
||||
|
@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
@ -1,11 +1,4 @@
|
||||
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 '..';
|
||||
|
||||
export const appStarted = createAction('app/appStarted');
|
||||
@ -17,29 +10,9 @@ export const addAppStartedListener = () => {
|
||||
action,
|
||||
{ getState, dispatch, unsubscribe, cancelActiveListeners }
|
||||
) => {
|
||||
// this should only run once
|
||||
cancelActiveListeners();
|
||||
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));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
@ -1,17 +1,13 @@
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
|
||||
import {
|
||||
ASSETS_CATEGORIES,
|
||||
IMAGE_CATEGORIES,
|
||||
boardIdSelected,
|
||||
imageSelected,
|
||||
selectImagesAll,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { boardsApi } from 'services/api/endpoints/boards';
|
||||
import {
|
||||
IMAGES_PER_PAGE,
|
||||
receivedPageOfImages,
|
||||
} from 'services/api/thunks/image';
|
||||
getBoardIdQueryParamForBoard,
|
||||
getCategoriesQueryParamForBoard,
|
||||
} from 'features/gallery/store/util';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { startAppListening } from '..';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'boards' });
|
||||
@ -19,54 +15,44 @@ const moduleLog = log.child({ namespace: 'boards' });
|
||||
export const addBoardIdSelectedListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: boardIdSelected,
|
||||
effect: (action, { getState, dispatch }) => {
|
||||
const board_id = action.payload;
|
||||
effect: async (
|
||||
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 allImages = selectImagesAll(state);
|
||||
const categories = getCategoriesQueryParamForBoard(_board_id);
|
||||
const board_id = getBoardIdQueryParamForBoard(_board_id);
|
||||
const queryArgs = { board_id, categories };
|
||||
|
||||
if (board_id === 'all') {
|
||||
// Selected all images
|
||||
dispatch(imageSelected(allImages[0]?.image_name ?? null));
|
||||
return;
|
||||
}
|
||||
// wait until the board has some images - maybe it already has some from a previous fetch
|
||||
// must use getState() to ensure we do not have stale state
|
||||
const isSuccess = await condition(
|
||||
() =>
|
||||
imagesApi.endpoints.listImages.select(queryArgs)(getState())
|
||||
.isSuccess,
|
||||
1000
|
||||
);
|
||||
|
||||
if (board_id === 'batch') {
|
||||
// Selected the batch
|
||||
dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null));
|
||||
return;
|
||||
}
|
||||
if (isSuccess) {
|
||||
// the board was just changed - we can select the first image
|
||||
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(
|
||||
queryArgs
|
||||
)(getState());
|
||||
|
||||
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 (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));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -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 },
|
||||
])
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
@ -1,11 +1,11 @@
|
||||
import { canvasMerged } from 'features/canvas/store/actions';
|
||||
import { startAppListening } from '..';
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { addToast } from 'features/system/store/systemSlice';
|
||||
import { imageUploaded } from 'services/api/thunks/image';
|
||||
import { canvasMerged } from 'features/canvas/store/actions';
|
||||
import { setMergedCanvas } from 'features/canvas/store/canvasSlice';
|
||||
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
|
||||
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' });
|
||||
|
||||
@ -46,27 +46,28 @@ export const addCanvasMergedListener = () => {
|
||||
});
|
||||
|
||||
const imageUploadedRequest = dispatch(
|
||||
imageUploaded({
|
||||
imagesApi.endpoints.uploadImage.initiate({
|
||||
file: new File([blob], 'mergedCanvas.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
image_category: 'general',
|
||||
is_intermediate: true,
|
||||
postUploadAction: {
|
||||
type: 'TOAST_CANVAS_MERGED',
|
||||
type: 'TOAST',
|
||||
toastOptions: { title: 'Canvas Merged' },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [{ payload }] = await take(
|
||||
(
|
||||
uploadedImageAction
|
||||
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
|
||||
imageUploaded.fulfilled.match(uploadedImageAction) &&
|
||||
(uploadedImageAction) =>
|
||||
imagesApi.endpoints.uploadImage.matchFulfilled(uploadedImageAction) &&
|
||||
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(
|
||||
setMergedCanvas({
|
||||
@ -76,13 +77,6 @@ export const addCanvasMergedListener = () => {
|
||||
...baseLayerRect,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(
|
||||
addToast({
|
||||
title: 'Canvas Merged',
|
||||
status: 'success',
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { canvasSavedToGallery } from 'features/canvas/store/actions';
|
||||
import { startAppListening } from '..';
|
||||
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 { 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' });
|
||||
|
||||
@ -28,28 +27,19 @@ export const addCanvasSavedToGalleryListener = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageUploadedRequest = dispatch(
|
||||
imageUploaded({
|
||||
dispatch(
|
||||
imagesApi.endpoints.uploadImage.initiate({
|
||||
file: new File([blob], 'savedCanvas.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
image_category: 'general',
|
||||
is_intermediate: false,
|
||||
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));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -2,10 +2,10 @@ import { log } from 'app/logging/useLogger';
|
||||
import { controlNetImageProcessed } from 'features/controlNet/store/actions';
|
||||
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
|
||||
import { sessionReadyToInvoke } from 'features/system/store/actions';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { isImageOutput } from 'services/api/guards';
|
||||
import { imageDTOReceived } from 'services/api/thunks/image';
|
||||
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 { startAppListening } from '..';
|
||||
|
||||
@ -62,12 +62,13 @@ export const addControlNetImageProcessedListener = () => {
|
||||
invocationCompleteAction.payload.data.result.image;
|
||||
|
||||
// Wait for the ImageDTO to be received
|
||||
const [imageMetadataReceivedAction] = await take(
|
||||
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
|
||||
imageDTOReceived.fulfilled.match(action) &&
|
||||
const [{ payload }] = await take(
|
||||
(action) =>
|
||||
imagesApi.endpoints.getImageDTO.matchFulfilled(action) &&
|
||||
action.payload.image_name === image_name
|
||||
);
|
||||
const processedControlImage = imageMetadataReceivedAction.payload;
|
||||
|
||||
const processedControlImage = payload as ImageDTO;
|
||||
|
||||
moduleLog.debug(
|
||||
{ data: { arg: action.payload, processedControlImage } },
|
||||
|
@ -1,31 +1,30 @@
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { boardImagesApi } from 'services/api/endpoints/boardImages';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { startAppListening } from '..';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'boards' });
|
||||
|
||||
export const addImageAddedToBoardFulfilledListener = () => {
|
||||
startAppListening({
|
||||
matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled,
|
||||
matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
|
||||
effect: (action, { getState, dispatch }) => {
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
const { board_id, imageDTO } = action.meta.arg.originalArgs;
|
||||
|
||||
moduleLog.debug(
|
||||
{ data: { board_id, image_name } },
|
||||
'Image added to board'
|
||||
);
|
||||
// TODO: update listImages cache for this board
|
||||
|
||||
moduleLog.debug({ data: { board_id, imageDTO } }, 'Image added to board');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const addImageAddedToBoardRejectedListener = () => {
|
||||
startAppListening({
|
||||
matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected,
|
||||
matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
|
||||
effect: (action, { getState, dispatch }) => {
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
const { board_id, imageDTO } = action.meta.arg.originalArgs;
|
||||
|
||||
moduleLog.debug(
|
||||
{ data: { board_id, image_name } },
|
||||
{ data: { board_id, imageDTO } },
|
||||
'Problem adding image to board'
|
||||
);
|
||||
},
|
||||
|
@ -1,19 +1,17 @@
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { resetCanvas } from 'features/canvas/store/canvasSlice';
|
||||
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
|
||||
import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors';
|
||||
import {
|
||||
imageRemoved,
|
||||
imageSelected,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import {
|
||||
imageDeletionConfirmed,
|
||||
isModalOpenChanged,
|
||||
} from 'features/imageDeletion/store/imageDeletionSlice';
|
||||
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
|
||||
import { clearInitialImage } from 'features/parameters/store/generationSlice';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { api } from 'services/api';
|
||||
import { imageDeleted } from 'services/api/thunks/image';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { startAppListening } from '..';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'image' });
|
||||
@ -36,10 +34,28 @@ export const addRequestedImageDeletionListener = () => {
|
||||
state.gallery.selection[state.gallery.selection.length - 1];
|
||||
|
||||
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) {
|
||||
dispatch(imageSelected(newSelectedImageId));
|
||||
dispatch(imageSelected(newSelectedImageId as string));
|
||||
} else {
|
||||
dispatch(imageSelected(null));
|
||||
}
|
||||
@ -63,16 +79,15 @@ export const addRequestedImageDeletionListener = () => {
|
||||
dispatch(nodeEditorReset());
|
||||
}
|
||||
|
||||
// Preemptively remove from gallery
|
||||
dispatch(imageRemoved(image_name));
|
||||
|
||||
// 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
|
||||
const wasImageDeleted = await condition(
|
||||
(action): action is ReturnType<typeof imageDeleted.fulfilled> =>
|
||||
imageDeleted.fulfilled.match(action) &&
|
||||
(action) =>
|
||||
imagesApi.endpoints.deleteImage.matchFulfilled(action) &&
|
||||
action.meta.requestId === requestId,
|
||||
30000
|
||||
);
|
||||
@ -91,7 +106,7 @@ export const addRequestedImageDeletionListener = () => {
|
||||
*/
|
||||
export const addImageDeletedPendingListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: imageDeleted.pending,
|
||||
matcher: imagesApi.endpoints.deleteImage.matchPending,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
//
|
||||
},
|
||||
@ -103,9 +118,12 @@ export const addImageDeletedPendingListener = () => {
|
||||
*/
|
||||
export const addImageDeletedFulfilledListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: imageDeleted.fulfilled,
|
||||
matcher: imagesApi.endpoints.deleteImage.matchFulfilled,
|
||||
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 = () => {
|
||||
startAppListening({
|
||||
actionCreator: imageDeleted.rejected,
|
||||
matcher: imagesApi.endpoints.deleteImage.matchRejected,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
moduleLog.debug(
|
||||
{ data: { image: action.meta.arg } },
|
||||
{ data: { image: action.meta.arg.originalArgs } },
|
||||
'Unable to delete image'
|
||||
);
|
||||
},
|
||||
|
@ -10,12 +10,9 @@ import {
|
||||
imageSelected,
|
||||
imagesAddedToBatch,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import {
|
||||
fieldValueChanged,
|
||||
imageCollectionFieldValueChanged,
|
||||
} from 'features/nodes/store/nodesSlice';
|
||||
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { initialImageChanged } from 'features/parameters/store/generationSlice';
|
||||
import { boardImagesApi } from 'services/api/endpoints/boardImages';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { startAppListening } from '../';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'dnd' });
|
||||
@ -137,23 +134,23 @@ export const addImageDroppedListener = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// set multiple nodes images (multiple images handler)
|
||||
if (
|
||||
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
|
||||
activeData.payloadType === 'IMAGE_NAMES'
|
||||
) {
|
||||
const { fieldName, nodeId } = overData.context;
|
||||
dispatch(
|
||||
imageCollectionFieldValueChanged({
|
||||
nodeId,
|
||||
fieldName,
|
||||
value: activeData.payload.image_names.map((image_name) => ({
|
||||
image_name,
|
||||
})),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
// // set multiple nodes images (multiple images handler)
|
||||
// if (
|
||||
// overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
|
||||
// activeData.payloadType === 'IMAGE_NAMES'
|
||||
// ) {
|
||||
// const { fieldName, nodeId } = overData.context;
|
||||
// dispatch(
|
||||
// imageCollectionFieldValueChanged({
|
||||
// nodeId,
|
||||
// fieldName,
|
||||
// value: activeData.payload.image_names.map((image_name) => ({
|
||||
// image_name,
|
||||
// })),
|
||||
// })
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// add image to board
|
||||
if (
|
||||
@ -162,97 +159,95 @@ export const addImageDroppedListener = () => {
|
||||
activeData.payload.imageDTO &&
|
||||
overData.context.boardId
|
||||
) {
|
||||
const { image_name } = activeData.payload.imageDTO;
|
||||
const { imageDTO } = activeData.payload;
|
||||
const { boardId } = overData.context;
|
||||
|
||||
// if the board is "No Board", this is a remove action
|
||||
if (boardId === 'no_board') {
|
||||
dispatch(
|
||||
imagesApi.endpoints.removeImageFromBoard.initiate({
|
||||
imageDTO,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle adding image to batch
|
||||
if (boardId === 'batch') {
|
||||
// TODO
|
||||
}
|
||||
|
||||
// Otherwise, add the image to the board
|
||||
dispatch(
|
||||
boardImagesApi.endpoints.addImageToBoard.initiate({
|
||||
image_name,
|
||||
imagesApi.endpoints.addImageToBoard.initiate({
|
||||
imageDTO,
|
||||
board_id: boardId,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove image from board
|
||||
if (
|
||||
overData.actionType === 'MOVE_BOARD' &&
|
||||
activeData.payloadType === 'IMAGE_DTO' &&
|
||||
activeData.payload.imageDTO &&
|
||||
overData.context.boardId === null
|
||||
) {
|
||||
const { image_name, board_id } = activeData.payload.imageDTO;
|
||||
if (board_id) {
|
||||
dispatch(
|
||||
boardImagesApi.endpoints.removeImageFromBoard.initiate({
|
||||
image_name,
|
||||
board_id,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// // add gallery selection to board
|
||||
// if (
|
||||
// overData.actionType === 'MOVE_BOARD' &&
|
||||
// activeData.payloadType === 'IMAGE_NAMES' &&
|
||||
// overData.context.boardId
|
||||
// ) {
|
||||
// console.log('adding gallery selection to board');
|
||||
// const board_id = overData.context.boardId;
|
||||
// dispatch(
|
||||
// boardImagesApi.endpoints.addManyBoardImages.initiate({
|
||||
// board_id,
|
||||
// image_names: activeData.payload.image_names,
|
||||
// })
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// add gallery selection to board
|
||||
if (
|
||||
overData.actionType === 'MOVE_BOARD' &&
|
||||
activeData.payloadType === 'IMAGE_NAMES' &&
|
||||
overData.context.boardId
|
||||
) {
|
||||
console.log('adding gallery selection to board');
|
||||
const board_id = overData.context.boardId;
|
||||
dispatch(
|
||||
boardImagesApi.endpoints.addManyBoardImages.initiate({
|
||||
board_id,
|
||||
image_names: activeData.payload.image_names,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
// // remove gallery selection from board
|
||||
// if (
|
||||
// overData.actionType === 'MOVE_BOARD' &&
|
||||
// activeData.payloadType === 'IMAGE_NAMES' &&
|
||||
// overData.context.boardId === null
|
||||
// ) {
|
||||
// console.log('removing gallery selection to board');
|
||||
// dispatch(
|
||||
// boardImagesApi.endpoints.deleteManyBoardImages.initiate({
|
||||
// image_names: activeData.payload.image_names,
|
||||
// })
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// remove gallery selection from board
|
||||
if (
|
||||
overData.actionType === 'MOVE_BOARD' &&
|
||||
activeData.payloadType === 'IMAGE_NAMES' &&
|
||||
overData.context.boardId === null
|
||||
) {
|
||||
console.log('removing gallery selection to board');
|
||||
dispatch(
|
||||
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
|
||||
image_names: activeData.payload.image_names,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
// // add batch selection to board
|
||||
// if (
|
||||
// overData.actionType === 'MOVE_BOARD' &&
|
||||
// activeData.payloadType === 'IMAGE_NAMES' &&
|
||||
// overData.context.boardId
|
||||
// ) {
|
||||
// const board_id = overData.context.boardId;
|
||||
// dispatch(
|
||||
// boardImagesApi.endpoints.addManyBoardImages.initiate({
|
||||
// board_id,
|
||||
// image_names: activeData.payload.image_names,
|
||||
// })
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// add batch selection to board
|
||||
if (
|
||||
overData.actionType === 'MOVE_BOARD' &&
|
||||
activeData.payloadType === 'IMAGE_NAMES' &&
|
||||
overData.context.boardId
|
||||
) {
|
||||
const board_id = overData.context.boardId;
|
||||
dispatch(
|
||||
boardImagesApi.endpoints.addManyBoardImages.initiate({
|
||||
board_id,
|
||||
image_names: activeData.payload.image_names,
|
||||
})
|
||||
);
|
||||
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;
|
||||
}
|
||||
// // 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;
|
||||
// }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
@ -1,12 +1,12 @@
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { boardImagesApi } from 'services/api/endpoints/boardImages';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { startAppListening } from '..';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'boards' });
|
||||
|
||||
export const addImageRemovedFromBoardFulfilledListener = () => {
|
||||
startAppListening({
|
||||
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled,
|
||||
matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled,
|
||||
effect: (action, { getState, dispatch }) => {
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
|
||||
@ -20,7 +20,7 @@ export const addImageRemovedFromBoardFulfilledListener = () => {
|
||||
|
||||
export const addImageRemovedFromBoardRejectedListener = () => {
|
||||
startAppListening({
|
||||
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected,
|
||||
matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected,
|
||||
effect: (action, { getState, dispatch }) => {
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
|
||||
|
@ -1,15 +1,20 @@
|
||||
import { startAppListening } from '..';
|
||||
import { imageUpdated } from 'services/api/thunks/image';
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { startAppListening } from '..';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'image' });
|
||||
|
||||
export const addImageUpdatedFulfilledListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: imageUpdated.fulfilled,
|
||||
matcher: imagesApi.endpoints.updateImage.matchFulfilled,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
moduleLog.debug(
|
||||
{ oldImage: action.meta.arg, updatedImage: action.payload },
|
||||
{
|
||||
data: {
|
||||
oldImage: action.meta.arg.originalArgs,
|
||||
updatedImage: action.payload,
|
||||
},
|
||||
},
|
||||
'Image updated'
|
||||
);
|
||||
},
|
||||
@ -18,9 +23,12 @@ export const addImageUpdatedFulfilledListener = () => {
|
||||
|
||||
export const addImageUpdatedRejectedListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: imageUpdated.rejected,
|
||||
matcher: imagesApi.endpoints.updateImage.matchRejected,
|
||||
effect: (action, { dispatch }) => {
|
||||
moduleLog.debug({ oldImage: action.meta.arg }, 'Image update failed');
|
||||
moduleLog.debug(
|
||||
{ data: action.meta.arg.originalArgs },
|
||||
'Image update failed'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -1,49 +1,87 @@
|
||||
import { UseToastOptions } from '@chakra-ui/react';
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
|
||||
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
|
||||
import {
|
||||
imageUpserted,
|
||||
imagesAddedToBatch,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
|
||||
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { initialImageChanged } from 'features/parameters/store/generationSlice';
|
||||
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 {
|
||||
SYSTEM_BOARDS,
|
||||
imagesApi,
|
||||
} from '../../../../../services/api/endpoints/images';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'image' });
|
||||
|
||||
const DEFAULT_UPLOADED_TOAST: UseToastOptions = {
|
||||
title: 'Image Uploaded',
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
export const addImageUploadedFulfilledListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: imageUploaded.fulfilled,
|
||||
matcher: imagesApi.endpoints.uploadImage.matchFulfilled,
|
||||
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) {
|
||||
// No further actions needed for intermediate images
|
||||
const { postUploadAction } = action.meta.arg.originalArgs;
|
||||
|
||||
if (
|
||||
// No further actions needed for intermediate images,
|
||||
action.payload.is_intermediate &&
|
||||
// unless they have an explicit post-upload action
|
||||
!postUploadAction
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(imageUpserted(image));
|
||||
// default action - just upload and alert user
|
||||
if (postUploadAction?.type === 'TOAST') {
|
||||
const { toastOptions } = postUploadAction;
|
||||
if (SYSTEM_BOARDS.includes(selectedBoardId)) {
|
||||
dispatch(addToast({ ...DEFAULT_UPLOADED_TOAST, ...toastOptions }));
|
||||
} else {
|
||||
// Add this image to the board
|
||||
dispatch(
|
||||
imagesApi.endpoints.addImageToBoard.initiate({
|
||||
board_id: selectedBoardId,
|
||||
imageDTO,
|
||||
})
|
||||
);
|
||||
|
||||
const { postUploadAction } = action.meta.arg;
|
||||
// Attempt to get the board's name for the toast
|
||||
const { data } = boardsApi.endpoints.listAllBoards.select()(state);
|
||||
|
||||
if (postUploadAction?.type === 'TOAST_CANVAS_SAVED_TO_GALLERY') {
|
||||
dispatch(
|
||||
addToast({ title: 'Canvas Saved to Gallery', status: 'success' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 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}`;
|
||||
|
||||
if (postUploadAction?.type === 'TOAST_CANVAS_MERGED') {
|
||||
dispatch(addToast({ title: 'Canvas Merged', status: 'success' }));
|
||||
dispatch(
|
||||
addToast({
|
||||
...DEFAULT_UPLOADED_TOAST,
|
||||
description,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -52,30 +90,49 @@ export const addImageUploadedFulfilledListener = () => {
|
||||
dispatch(
|
||||
controlNetImageChanged({
|
||||
controlNetId,
|
||||
controlImage: image.image_name,
|
||||
controlImage: imageDTO.image_name,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
addToast({
|
||||
...DEFAULT_UPLOADED_TOAST,
|
||||
description: 'Set as control image',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
|
||||
dispatch(initialImageChanged(image));
|
||||
dispatch(initialImageChanged(imageDTO));
|
||||
dispatch(
|
||||
addToast({
|
||||
...DEFAULT_UPLOADED_TOAST,
|
||||
description: 'Set as initial image',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (postUploadAction?.type === 'SET_NODES_IMAGE') {
|
||||
const { nodeId, fieldName } = postUploadAction;
|
||||
dispatch(fieldValueChanged({ nodeId, fieldName, value: image }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (postUploadAction?.type === 'TOAST_UPLOADED') {
|
||||
dispatch(addToast({ title: 'Image Uploaded', status: 'success' }));
|
||||
dispatch(fieldValueChanged({ nodeId, fieldName, value: imageDTO }));
|
||||
dispatch(
|
||||
addToast({
|
||||
...DEFAULT_UPLOADED_TOAST,
|
||||
description: `Set as node field ${fieldName}`,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
@ -84,10 +141,10 @@ export const addImageUploadedFulfilledListener = () => {
|
||||
|
||||
export const addImageUploadedRejectedListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: imageUploaded.rejected,
|
||||
matcher: imagesApi.endpoints.uploadImage.matchRejected,
|
||||
effect: (action, { dispatch }) => {
|
||||
const { formData, ...rest } = action.meta.arg;
|
||||
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } };
|
||||
const { file, postUploadAction, ...rest } = action.meta.arg.originalArgs;
|
||||
const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
|
||||
moduleLog.error({ data: sanitizedData }, 'Image upload failed');
|
||||
dispatch(
|
||||
addToast({
|
||||
|
@ -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'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
@ -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 { selectImagesById } from 'features/gallery/store/gallerySlice';
|
||||
import { isImageDTO } from 'services/api/guards';
|
||||
import { initialImageSelected } from 'features/parameters/store/actions';
|
||||
import { initialImageChanged } from 'features/parameters/store/generationSlice';
|
||||
import { addToast } from 'features/system/store/systemSlice';
|
||||
import { t } from 'i18next';
|
||||
import { startAppListening } from '..';
|
||||
|
||||
export const addInitialImageSelectedListener = () => {
|
||||
startAppListening({
|
||||
@ -20,25 +18,7 @@ export const addInitialImageSelectedListener = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageDTO(action.payload)) {
|
||||
dispatch(initialImageChanged(action.payload));
|
||||
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(initialImageChanged(action.payload));
|
||||
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
|
||||
},
|
||||
});
|
||||
|
@ -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'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
@ -1,9 +1,17 @@
|
||||
import { log } from 'app/logging/useLogger';
|
||||
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 { boardImagesApi } from 'services/api/endpoints/boardImages';
|
||||
import {
|
||||
SYSTEM_BOARDS,
|
||||
imagesAdapter,
|
||||
imagesApi,
|
||||
} from 'services/api/endpoints/images';
|
||||
import { isImageOutput } from 'services/api/guards';
|
||||
import { imageDTOReceived } from 'services/api/thunks/image';
|
||||
import { sessionCanceled } from 'services/api/thunks/session';
|
||||
import {
|
||||
appSocketInvocationComplete,
|
||||
@ -22,7 +30,6 @@ export const addInvocationCompleteEventListener = () => {
|
||||
{ data: action.payload },
|
||||
`Invocation complete (${action.payload.data.node.type})`
|
||||
);
|
||||
|
||||
const session_id = action.payload.data.graph_execution_state_id;
|
||||
|
||||
const { cancelType, isCancelScheduled, boardIdToAddTo } =
|
||||
@ -39,33 +46,70 @@ export const addInvocationCompleteEventListener = () => {
|
||||
// This complete event has an associated image output
|
||||
if (isImageOutput(result) && !nodeDenylist.includes(node.type)) {
|
||||
const { image_name } = result.image;
|
||||
const { canvas, gallery } = getState();
|
||||
|
||||
// Get its metadata
|
||||
dispatch(
|
||||
imageDTOReceived({
|
||||
image_name,
|
||||
})
|
||||
);
|
||||
const imageDTO = await dispatch(
|
||||
imagesApi.endpoints.getImageDTO.initiate(image_name)
|
||||
).unwrap();
|
||||
|
||||
const [{ payload: imageDTO }] = await take(
|
||||
imageDTOReceived.fulfilled.match
|
||||
);
|
||||
|
||||
// Handle canvas image
|
||||
// Add canvas images to the staging area
|
||||
if (
|
||||
graph_execution_state_id ===
|
||||
getState().canvas.layerState.stagingArea.sessionId
|
||||
graph_execution_state_id === canvas.layerState.stagingArea.sessionId
|
||||
) {
|
||||
dispatch(addImageToStagingArea(imageDTO));
|
||||
}
|
||||
|
||||
if (boardIdToAddTo && !imageDTO.is_intermediate) {
|
||||
if (!imageDTO.is_intermediate) {
|
||||
// update the cache for 'All Images'
|
||||
dispatch(
|
||||
boardImagesApi.endpoints.addImageToBoard.initiate({
|
||||
board_id: boardIdToAddTo,
|
||||
image_name,
|
||||
})
|
||||
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,
|
||||
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));
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
|
||||
import { startAppListening } from '..';
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { imageUpdated } from 'services/api/thunks/image';
|
||||
import { imageUpserted } from 'features/gallery/store/gallerySlice';
|
||||
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
|
||||
import { addToast } from 'features/system/store/systemSlice';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { startAppListening } from '..';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'canvas' });
|
||||
|
||||
@ -11,41 +10,27 @@ export const addStagingAreaImageSavedListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: stagingAreaImageSaved,
|
||||
effect: async (action, { dispatch, getState, take }) => {
|
||||
const { imageName } = action.payload;
|
||||
const { imageDTO } = action.payload;
|
||||
|
||||
dispatch(
|
||||
imageUpdated({
|
||||
image_name: imageName,
|
||||
is_intermediate: false,
|
||||
imagesApi.endpoints.updateImage.initiate({
|
||||
imageDTO,
|
||||
changes: { is_intermediate: false },
|
||||
})
|
||||
);
|
||||
|
||||
const [imageUpdatedAction] = await take(
|
||||
(action) =>
|
||||
(imageUpdated.fulfilled.match(action) ||
|
||||
imageUpdated.rejected.match(action)) &&
|
||||
action.meta.arg.image_name === imageName
|
||||
);
|
||||
|
||||
if (imageUpdated.rejected.match(imageUpdatedAction)) {
|
||||
moduleLog.error(
|
||||
{ data: { arg: imageUpdatedAction.meta.arg } },
|
||||
'Image saving failed'
|
||||
);
|
||||
dispatch(
|
||||
addToast({
|
||||
title: 'Image Saving Failed',
|
||||
description: imageUpdatedAction.error.message,
|
||||
status: 'error',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageUpdated.fulfilled.match(imageUpdatedAction)) {
|
||||
dispatch(imageUpserted(imageUpdatedAction.payload));
|
||||
dispatch(addToast({ title: 'Image Saved', status: 'success' }));
|
||||
}
|
||||
)
|
||||
.unwrap()
|
||||
.then((image) => {
|
||||
dispatch(addToast({ title: 'Image Saved', status: 'success' }));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(
|
||||
addToast({
|
||||
title: 'Image Saving Failed',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
@ -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 { canvasGraphBuilt } from 'features/nodes/store/actions';
|
||||
import { imageUpdated, imageUploaded } from 'services/api/thunks/image';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { userInvoked } from 'app/store/actions';
|
||||
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
|
||||
import {
|
||||
canvasSessionIdChanged,
|
||||
stagingAreaInitialized,
|
||||
} 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 { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
|
||||
import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
|
||||
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
|
||||
import { canvasGraphBuilt } from 'features/nodes/store/actions';
|
||||
import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
|
||||
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' });
|
||||
|
||||
@ -74,7 +74,7 @@ export const addUserInvokedCanvasListener = () => {
|
||||
if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) {
|
||||
// upload the image, saving the request id
|
||||
const { requestId: initImageUploadedRequestId } = dispatch(
|
||||
imageUploaded({
|
||||
imagesApi.endpoints.uploadImage.initiate({
|
||||
file: new File([baseBlob], 'canvasInitImage.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
@ -85,19 +85,20 @@ export const addUserInvokedCanvasListener = () => {
|
||||
|
||||
// Wait for the image to be uploaded, matching by request id
|
||||
const [{ payload }] = await take(
|
||||
(action): action is ReturnType<typeof imageUploaded.fulfilled> =>
|
||||
imageUploaded.fulfilled.match(action) &&
|
||||
// TODO: figure out how to narrow this action's type
|
||||
(action) =>
|
||||
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
|
||||
action.meta.requestId === initImageUploadedRequestId
|
||||
);
|
||||
|
||||
canvasInitImage = payload;
|
||||
canvasInitImage = payload as ImageDTO;
|
||||
}
|
||||
|
||||
// For inpaint/outpaint, we also need to upload the mask layer
|
||||
if (['inpaint', 'outpaint'].includes(generationMode)) {
|
||||
// upload the image, saving the request id
|
||||
const { requestId: maskImageUploadedRequestId } = dispatch(
|
||||
imageUploaded({
|
||||
imagesApi.endpoints.uploadImage.initiate({
|
||||
file: new File([maskBlob], 'canvasMaskImage.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
@ -108,12 +109,13 @@ export const addUserInvokedCanvasListener = () => {
|
||||
|
||||
// Wait for the image to be uploaded, matching by request id
|
||||
const [{ payload }] = await take(
|
||||
(action): action is ReturnType<typeof imageUploaded.fulfilled> =>
|
||||
imageUploaded.fulfilled.match(action) &&
|
||||
// TODO: figure out how to narrow this action's type
|
||||
(action) =>
|
||||
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
|
||||
action.meta.requestId === maskImageUploadedRequestId
|
||||
);
|
||||
|
||||
canvasMaskImage = payload;
|
||||
canvasMaskImage = payload as ImageDTO;
|
||||
}
|
||||
|
||||
const graph = buildCanvasGraph(
|
||||
@ -144,9 +146,9 @@ export const addUserInvokedCanvasListener = () => {
|
||||
// Associate the init image with the session, now that we have the session ID
|
||||
if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) {
|
||||
dispatch(
|
||||
imageUpdated({
|
||||
image_name: canvasInitImage.image_name,
|
||||
session_id: sessionId,
|
||||
imagesApi.endpoints.updateImage.initiate({
|
||||
imageDTO: canvasInitImage,
|
||||
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
|
||||
if (['inpaint'].includes(generationMode) && canvasMaskImage) {
|
||||
dispatch(
|
||||
imageUpdated({
|
||||
image_name: canvasMaskImage.image_name,
|
||||
session_id: sessionId,
|
||||
imagesApi.endpoints.updateImage.initiate({
|
||||
imageDTO: canvasMaskImage,
|
||||
changes: { session_id: sessionId },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -11,13 +11,15 @@ import {
|
||||
TypesafeDroppableData,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
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 { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
|
||||
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
|
||||
import { PostUploadAction } from 'services/api/thunks/image';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { ImageDTO, PostUploadAction } from 'services/api/types';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import IAIDraggable from './IAIDraggable';
|
||||
import IAIDroppable from './IAIDroppable';
|
||||
@ -46,6 +48,7 @@ type IAIDndImageProps = {
|
||||
isSelected?: boolean;
|
||||
thumbnail?: boolean;
|
||||
noContentFallback?: ReactElement;
|
||||
useThumbailFallback?: boolean;
|
||||
};
|
||||
|
||||
const IAIDndImage = (props: IAIDndImageProps) => {
|
||||
@ -71,6 +74,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
||||
resetTooltip = 'Reset',
|
||||
resetIcon = <FaUndo />,
|
||||
noContentFallback = <IAINoContentFallback icon={FaImage} />,
|
||||
useThumbailFallback,
|
||||
} = props;
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
@ -126,9 +130,14 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
||||
<Image
|
||||
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
|
||||
fallbackStrategy="beforeLoadOrError"
|
||||
// If we fall back to thumbnail, it feels much snappier than the skeleton...
|
||||
fallbackSrc={imageDTO.thumbnail_url}
|
||||
// fallback={<IAILoadingImageFallback image={imageDTO} />}
|
||||
fallbackSrc={
|
||||
useThumbailFallback ? imageDTO.thumbnail_url : undefined
|
||||
}
|
||||
fallback={
|
||||
useThumbailFallback ? undefined : (
|
||||
<IAILoadingImageFallback image={imageDTO} />
|
||||
)
|
||||
}
|
||||
width={imageDTO.width}
|
||||
height={imageDTO.height}
|
||||
onError={onError}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Flex, Text, useColorMode } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo, useRef } from 'react';
|
||||
import { ReactNode, memo, useRef } from 'react';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type Props = {
|
||||
isOver: boolean;
|
||||
label?: string;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
export const IAIDropOverlay = (props: Props) => {
|
||||
@ -57,16 +57,16 @@ export const IAIDropOverlay = (props: Props) => {
|
||||
<Flex
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
insetInlineStart: 0,
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
top: 0.5,
|
||||
insetInlineStart: 0.5,
|
||||
insetInlineEnd: 0.5,
|
||||
bottom: 0.5,
|
||||
opacity: 1,
|
||||
borderWidth: 3,
|
||||
borderWidth: 2,
|
||||
borderColor: isOver
|
||||
? mode('base.50', 'base.200')(colorMode)
|
||||
: mode('base.100', 'base.500')(colorMode),
|
||||
borderRadius: 'base',
|
||||
? mode('base.50', 'base.50')(colorMode)
|
||||
: mode('base.200', 'base.300')(colorMode),
|
||||
borderRadius: 'lg',
|
||||
borderStyle: 'dashed',
|
||||
transitionProperty: 'common',
|
||||
transitionDuration: '0.1s',
|
||||
@ -78,10 +78,10 @@ export const IAIDropOverlay = (props: Props) => {
|
||||
sx={{
|
||||
fontSize: '2xl',
|
||||
fontWeight: 600,
|
||||
transform: isOver ? 'scale(1.02)' : 'scale(1)',
|
||||
transform: isOver ? 'scale(1.1)' : 'scale(1)',
|
||||
color: isOver
|
||||
? mode('base.50', 'base.50')(colorMode)
|
||||
: mode('base.100', 'base.200')(colorMode),
|
||||
: mode('base.200', 'base.300')(colorMode),
|
||||
transitionProperty: 'common',
|
||||
transitionDuration: '0.1s',
|
||||
}}
|
||||
|
@ -5,12 +5,12 @@ import {
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { memo, useRef } from 'react';
|
||||
import { ReactNode, memo, useRef } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import IAIDropOverlay from './IAIDropOverlay';
|
||||
|
||||
type IAIDroppableProps = {
|
||||
dropLabel?: string;
|
||||
dropLabel?: ReactNode;
|
||||
disabled?: boolean;
|
||||
data?: TypesafeDroppableData;
|
||||
};
|
||||
|
@ -68,6 +68,7 @@ export const IAINoContentFallback = (props: IAINoImageFallbackProps) => {
|
||||
flexDir: 'column',
|
||||
gap: 2,
|
||||
userSelect: 'none',
|
||||
opacity: 0.7,
|
||||
color: 'base.700',
|
||||
_dark: {
|
||||
color: 'base.500',
|
||||
|
@ -32,27 +32,57 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
opacity: 0.4,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
rowGap: 4,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
insetInlineStart: 0,
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
bg: 'base.700',
|
||||
_dark: { bg: 'base.900' },
|
||||
opacity: 0.7,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: 'base.900',
|
||||
boxShadow: `inset 0 0 20rem 1rem var(--invokeai-colors-${
|
||||
isDragAccept ? 'accent' : 'error'
|
||||
}-500)`,
|
||||
transitionProperty: 'common',
|
||||
transitionDuration: '0.1s',
|
||||
}}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
insetInlineStart: 0,
|
||||
width: 'full',
|
||||
height: 'full',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
{isDragAccept ? (
|
||||
<Heading size="lg">Drop to Upload</Heading>
|
||||
) : (
|
||||
<>
|
||||
<Heading size="lg">Invalid Upload</Heading>
|
||||
<Heading size="md">Must be single JPEG or PNG image</Heading>
|
||||
</>
|
||||
)}
|
||||
<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 ? (
|
||||
<Heading size="lg">Drop to Upload</Heading>
|
||||
) : (
|
||||
<>
|
||||
<Heading size="lg">Invalid Upload</Heading>
|
||||
<Heading size="md">Must be single JPEG or PNG image</Heading>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
|
@ -1,35 +1,43 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import useImageUploader from 'common/hooks/useImageUploader';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
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 {
|
||||
KeyboardEvent,
|
||||
memo,
|
||||
ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FileRejection, useDropzone } from 'react-dropzone';
|
||||
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 { useAppToaster } from 'app/components/Toaster';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { systemSelector } from 'features/system/store/systemSelectors';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
const selector = createSelector(
|
||||
[systemSelector, activeTabNameSelector],
|
||||
(system, activeTabName) => {
|
||||
const { isConnected, isUploading } = system;
|
||||
[activeTabNameSelector],
|
||||
(activeTabName) => {
|
||||
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 {
|
||||
isUploaderDisabled,
|
||||
activeTabName,
|
||||
postUploadAction,
|
||||
};
|
||||
}
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
type ImageUploaderProps = {
|
||||
@ -38,12 +46,13 @@ type ImageUploaderProps = {
|
||||
|
||||
const ImageUploader = (props: ImageUploaderProps) => {
|
||||
const { children } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
const { isUploaderDisabled, activeTabName } = useAppSelector(selector);
|
||||
const { postUploadAction } = useAppSelector(selector);
|
||||
const isBusy = useAppSelector(selectIsBusy);
|
||||
const toaster = useAppToaster();
|
||||
const { t } = useTranslation();
|
||||
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
|
||||
const { setOpenUploaderFunction } = useImageUploader();
|
||||
|
||||
const [uploadImage] = useUploadImageMutation();
|
||||
|
||||
const fileRejectionCallback = useCallback(
|
||||
(rejection: FileRejection) => {
|
||||
@ -60,16 +69,14 @@ const ImageUploader = (props: ImageUploaderProps) => {
|
||||
|
||||
const fileAcceptedCallback = useCallback(
|
||||
async (file: File) => {
|
||||
dispatch(
|
||||
imageUploaded({
|
||||
file,
|
||||
image_category: 'user',
|
||||
is_intermediate: false,
|
||||
postUploadAction: { type: 'TOAST_UPLOADED' },
|
||||
})
|
||||
);
|
||||
uploadImage({
|
||||
file,
|
||||
image_category: 'user',
|
||||
is_intermediate: false,
|
||||
postUploadAction,
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
[postUploadAction, uploadImage]
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
@ -101,13 +108,12 @@ const ImageUploader = (props: ImageUploaderProps) => {
|
||||
isDragReject,
|
||||
isDragActive,
|
||||
inputRef,
|
||||
open,
|
||||
} = useDropzone({
|
||||
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
|
||||
noClick: true,
|
||||
onDrop,
|
||||
onDragOver: () => setIsHandlingUpload(true),
|
||||
disabled: isUploaderDisabled,
|
||||
disabled: isBusy,
|
||||
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
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
setOpenUploaderFunction(() => {
|
||||
return;
|
||||
});
|
||||
};
|
||||
}, [inputRef, open, setOpenUploaderFunction]);
|
||||
}, [inputRef]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@ -150,13 +150,30 @@ const ImageUploader = (props: ImageUploaderProps) => {
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
{isDragActive && isHandlingUpload && (
|
||||
<ImageUploadOverlay
|
||||
isDragAccept={isDragAccept}
|
||||
isDragReject={isDragReject}
|
||||
setIsHandlingUpload={setIsHandlingUpload}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{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
|
||||
isDragAccept={isDragAccept}
|
||||
isDragReject={isDragReject}
|
||||
setIsHandlingUpload={setIsHandlingUpload}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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;
|
@ -1,7 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useCallback } from 'react';
|
||||
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 = {
|
||||
postUploadAction?: PostUploadAction;
|
||||
@ -12,7 +12,7 @@ type UseImageUploadButtonArgs = {
|
||||
* Provides image uploader functionality to any component.
|
||||
*
|
||||
* @example
|
||||
* const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
|
||||
* const { getUploadButtonProps, getUploadInputProps, openUploader } = useImageUploadButton({
|
||||
* postUploadAction: {
|
||||
* type: 'SET_CONTROLNET_IMAGE',
|
||||
* controlNetId: '12345',
|
||||
@ -20,6 +20,9 @@ type UseImageUploadButtonArgs = {
|
||||
* isDisabled: getIsUploadDisabled(),
|
||||
* });
|
||||
*
|
||||
* // open the uploaded directly
|
||||
* const handleSomething = () => { openUploader() }
|
||||
*
|
||||
* // in the render function
|
||||
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click
|
||||
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
|
||||
@ -28,24 +31,23 @@ export const useImageUploadButton = ({
|
||||
postUploadAction,
|
||||
isDisabled,
|
||||
}: UseImageUploadButtonArgs) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [uploadImage] = useUploadImageMutation();
|
||||
const onDropAccepted = useCallback(
|
||||
(files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
imageUploaded({
|
||||
file,
|
||||
image_category: 'user',
|
||||
is_intermediate: false,
|
||||
postUploadAction,
|
||||
})
|
||||
);
|
||||
uploadImage({
|
||||
file,
|
||||
image_category: 'user',
|
||||
is_intermediate: false,
|
||||
postUploadAction: postUploadAction ?? { type: 'TOAST' },
|
||||
});
|
||||
},
|
||||
[dispatch, postUploadAction]
|
||||
[postUploadAction, uploadImage]
|
||||
);
|
||||
|
||||
const {
|
||||
|
@ -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;
|
@ -26,6 +26,8 @@ import {
|
||||
FaSave,
|
||||
} from 'react-icons/fa';
|
||||
import { stagingAreaImageSaved } from '../store/actions';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
|
||||
const selector = createSelector(
|
||||
[canvasSelector],
|
||||
@ -123,6 +125,10 @@ const IAICanvasStagingAreaToolbar = () => {
|
||||
[dispatch, sessionId]
|
||||
);
|
||||
|
||||
const { data: imageDTO } = useGetImageDTOQuery(
|
||||
currentStagingAreaImage?.imageName ?? skipToken
|
||||
);
|
||||
|
||||
if (!currentStagingAreaImage) return null;
|
||||
|
||||
return (
|
||||
@ -173,14 +179,19 @@ const IAICanvasStagingAreaToolbar = () => {
|
||||
<IAIIconButton
|
||||
tooltip={t('unifiedCanvas.saveToGallery')}
|
||||
aria-label={t('unifiedCanvas.saveToGallery')}
|
||||
isDisabled={!imageDTO || !imageDTO.is_intermediate}
|
||||
icon={<FaSave />}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
stagingAreaImageSaved({
|
||||
imageName: currentStagingAreaImage.imageName,
|
||||
imageDTO,
|
||||
})
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
colorScheme="accent"
|
||||
/>
|
||||
<IAIIconButton
|
||||
|
@ -2,7 +2,6 @@ import { Box, ButtonGroup, Flex } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import useImageUploader from 'common/hooks/useImageUploader';
|
||||
import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick';
|
||||
import {
|
||||
canvasSelector,
|
||||
@ -25,6 +24,7 @@ import { systemSelector } from 'features/system/store/systemSelectors';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import {
|
||||
canvasCopiedToClipboard,
|
||||
canvasDownloadedAsImage,
|
||||
@ -82,7 +82,9 @@ const IAICanvasToolbar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isClipboardAPIAvailable } = useCopyImageToClipboard();
|
||||
|
||||
const { openUploader } = useImageUploader();
|
||||
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
|
||||
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
|
||||
});
|
||||
|
||||
useHotkeys(
|
||||
['v'],
|
||||
@ -288,9 +290,10 @@ const IAICanvasToolbar = () => {
|
||||
aria-label={`${t('common.upload')}`}
|
||||
tooltip={`${t('common.upload')}`}
|
||||
icon={<FaUpload />}
|
||||
onClick={openUploader}
|
||||
isDisabled={isStaging}
|
||||
{...getUploadButtonProps()}
|
||||
/>
|
||||
<input {...getUploadInputProps()} />
|
||||
<IAIIconButton
|
||||
aria-label={`${t('unifiedCanvas.clearCanvas')}`}
|
||||
tooltip={`${t('unifiedCanvas.clearCanvas')}`}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery');
|
||||
|
||||
@ -12,6 +13,6 @@ export const canvasDownloadedAsImage = createAction(
|
||||
|
||||
export const canvasMerged = createAction('canvas/canvasMerged');
|
||||
|
||||
export const stagingAreaImageSaved = createAction<{ imageName: string }>(
|
||||
export const stagingAreaImageSaved = createAction<{ imageDTO: ImageDTO }>(
|
||||
'canvas/stagingAreaImageSaved'
|
||||
);
|
||||
|
@ -11,8 +11,8 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { PostUploadAction } from 'services/api/thunks/image';
|
||||
import { controlNetImageChanged } from '../store/controlNetSlice';
|
||||
import { PostUploadAction } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
controlNetId: string;
|
||||
|
@ -2,7 +2,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas';
|
||||
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 { appSocketInvocationError } from 'services/events/actions';
|
||||
import { controlNetImageProcessed } from './actions';
|
||||
@ -300,21 +300,6 @@ export const controlNetSlice = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
builder.addCase(imageDeleted.pending, (state, action) => {
|
||||
// Preemptively remove the image from all controlnets
|
||||
// TODO: doesn't the imageusage stuff do this for us?
|
||||
const { image_name } = action.meta.arg;
|
||||
forEach(state.controlNets, (c) => {
|
||||
if (c.controlImage === image_name) {
|
||||
c.controlImage = null;
|
||||
c.processedControlImage = null;
|
||||
}
|
||||
if (c.processedControlImage === image_name) {
|
||||
c.processedControlImage = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
builder.addCase(appSocketInvocationError, (state, action) => {
|
||||
state.pendingControlImages = [];
|
||||
});
|
||||
@ -322,6 +307,24 @@ export const controlNetSlice = createSlice({
|
||||
builder.addMatcher(isAnySessionRejected, (state, action) => {
|
||||
state.pendingControlImages = [];
|
||||
});
|
||||
|
||||
builder.addMatcher(
|
||||
imagesApi.endpoints.deleteImage.matchFulfilled,
|
||||
(state, action) => {
|
||||
// Preemptively remove the image from all controlnets
|
||||
// TODO: doesn't the imageusage stuff do this for us?
|
||||
const { image_name } = action.meta.arg.originalArgs;
|
||||
forEach(state.controlNets, (c) => {
|
||||
if (c.controlImage === image_name) {
|
||||
c.controlImage = null;
|
||||
c.processedControlImage = null;
|
||||
}
|
||||
if (c.processedControlImage === image_name) {
|
||||
c.processedControlImage = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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;
|
@ -1,29 +1,48 @@
|
||||
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import {
|
||||
IMAGE_CATEGORIES,
|
||||
INITIAL_IMAGE_LIMIT,
|
||||
boardIdSelected,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { FaImages } 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: IMAGE_CATEGORIES,
|
||||
offset: 0,
|
||||
limit: INITIAL_IMAGE_LIMIT,
|
||||
is_intermediate: false,
|
||||
};
|
||||
|
||||
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleAllImagesBoardClick = () => {
|
||||
dispatch(boardIdSelected('all'));
|
||||
const handleClick = () => {
|
||||
dispatch(boardIdSelected('images'));
|
||||
};
|
||||
|
||||
const droppableData: MoveBoardDropData = {
|
||||
id: 'all-images-board',
|
||||
actionType: 'MOVE_BOARD',
|
||||
context: { boardId: null },
|
||||
};
|
||||
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: 'images' },
|
||||
// };
|
||||
|
||||
return (
|
||||
<GenericBoard
|
||||
droppableData={droppableData}
|
||||
onClick={handleAllImagesBoardClick}
|
||||
onClick={handleClick}
|
||||
isSelected={isSelected}
|
||||
icon={FaImages}
|
||||
label="All Images"
|
||||
badgeCount={total}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,27 +1,27 @@
|
||||
import { CloseIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
Collapse,
|
||||
Flex,
|
||||
Grid,
|
||||
GridItem,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
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 { setBoardSearchText } from 'features/gallery/store/boardSlice';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
|
||||
import AddBoardButton from './AddBoardButton';
|
||||
import AllAssetsBoard from './AllAssetsBoard';
|
||||
import AllImagesBoard from './AllImagesBoard';
|
||||
import BatchBoard from './BatchBoard';
|
||||
import BoardsSearch from './BoardsSearch';
|
||||
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(
|
||||
[stateSelector],
|
||||
@ -39,110 +39,91 @@ type Props = {
|
||||
|
||||
const BoardsList = (props: Props) => {
|
||||
const { isOpen } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
const { selectedBoardId, searchText } = useAppSelector(selector);
|
||||
|
||||
const { data: boards } = useListAllBoardsQuery();
|
||||
|
||||
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
|
||||
|
||||
const filteredBoards = searchText
|
||||
? boards?.filter((board) =>
|
||||
board.board_name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
: boards;
|
||||
|
||||
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
|
||||
const [searchMode, setSearchMode] = useState(false);
|
||||
|
||||
const handleBoardSearch = (searchTerm: string) => {
|
||||
setSearchMode(searchTerm.length > 0);
|
||||
dispatch(setBoardSearchText(searchTerm));
|
||||
};
|
||||
const clearBoardSearch = () => {
|
||||
setSearchMode(false);
|
||||
dispatch(setBoardSearchText(''));
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Flex
|
||||
layerStyle={'first'}
|
||||
sx={{
|
||||
flexDir: 'column',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
mt: 2,
|
||||
borderRadius: 'base',
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<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>
|
||||
<AddBoardButton />
|
||||
</Flex>
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
options={{
|
||||
scrollbars: {
|
||||
visibility: 'auto',
|
||||
autoHide: 'move',
|
||||
autoHideDelay: 1300,
|
||||
theme: 'os-theme-dark',
|
||||
},
|
||||
<>
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Flex
|
||||
layerStyle={'first'}
|
||||
sx={{
|
||||
flexDir: 'column',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
mt: 2,
|
||||
borderRadius: 'base',
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
className="list-container"
|
||||
sx={{
|
||||
gridTemplateRows: '6.5rem 6.5rem',
|
||||
gridAutoFlow: 'column dense',
|
||||
gridAutoColumns: '5rem',
|
||||
<Flex sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<BoardsSearch setSearchMode={setSearchMode} />
|
||||
<AddBoardButton />
|
||||
</Flex>
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
options={{
|
||||
scrollbars: {
|
||||
visibility: 'auto',
|
||||
autoHide: 'move',
|
||||
autoHideDelay: 1300,
|
||||
theme: 'os-theme-dark',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!searchMode && (
|
||||
<>
|
||||
<GridItem sx={{ p: 1.5 }}>
|
||||
<AllImagesBoard isSelected={selectedBoardId === 'all'} />
|
||||
</GridItem>
|
||||
{isBatchEnabled && (
|
||||
<Grid
|
||||
className="list-container"
|
||||
sx={{
|
||||
gridTemplateRows: '6.5rem 6.5rem',
|
||||
gridAutoFlow: 'column dense',
|
||||
gridAutoColumns: '5rem',
|
||||
}}
|
||||
>
|
||||
{!searchMode && (
|
||||
<>
|
||||
<GridItem sx={{ p: 1.5 }}>
|
||||
<BatchBoard isSelected={selectedBoardId === 'batch'} />
|
||||
<AllImagesBoard isSelected={selectedBoardId === 'images'} />
|
||||
</GridItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{filteredBoards &&
|
||||
filteredBoards.map((board) => (
|
||||
<GridItem key={board.board_id} sx={{ p: 1.5 }}>
|
||||
<GalleryBoard
|
||||
board={board}
|
||||
isSelected={selectedBoardId === board.board_id}
|
||||
/>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
</OverlayScrollbarsComponent>
|
||||
</Flex>
|
||||
</Collapse>
|
||||
<GridItem sx={{ p: 1.5 }}>
|
||||
<AllAssetsBoard isSelected={selectedBoardId === 'assets'} />
|
||||
</GridItem>
|
||||
<GridItem sx={{ p: 1.5 }}>
|
||||
<NoBoardBoard isSelected={selectedBoardId === 'no_board'} />
|
||||
</GridItem>
|
||||
{isBatchEnabled && (
|
||||
<GridItem sx={{ p: 1.5 }}>
|
||||
<BatchBoard isSelected={selectedBoardId === 'batch'} />
|
||||
</GridItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{filteredBoards &&
|
||||
filteredBoards.map((board) => (
|
||||
<GridItem key={board.board_id} sx={{ p: 1.5 }}>
|
||||
<GalleryBoard
|
||||
board={board}
|
||||
isSelected={selectedBoardId === board.board_id}
|
||||
setBoardToDelete={setBoardToDelete}
|
||||
/>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
</OverlayScrollbarsComponent>
|
||||
</Flex>
|
||||
</Collapse>
|
||||
<DeleteBoardModal
|
||||
boardToDelete={boardToDelete}
|
||||
setBoardToDelete={setBoardToDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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);
|
@ -8,217 +8,208 @@ import {
|
||||
Image,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Text,
|
||||
useColorMode,
|
||||
} 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 { 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 { 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 { DeleteBoardImagesContext } from '../../../../../app/contexts/DeleteBoardImagesContext';
|
||||
|
||||
interface GalleryBoardProps {
|
||||
board: BoardDTO;
|
||||
isSelected: boolean;
|
||||
setBoardToDelete: (board?: BoardDTO) => void;
|
||||
}
|
||||
|
||||
const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const GalleryBoard = memo(
|
||||
({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { currentData: coverImage } = useGetImageDTOQuery(
|
||||
board.cover_image_name ?? skipToken
|
||||
);
|
||||
const { currentData: coverImage } = useGetImageDTOQuery(
|
||||
board.cover_image_name ?? skipToken
|
||||
);
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
const { colorMode } = useColorMode();
|
||||
const { board_name, board_id } = board;
|
||||
const handleSelectBoard = useCallback(() => {
|
||||
dispatch(boardIdSelected(board_id));
|
||||
}, [board_id, dispatch]);
|
||||
|
||||
const { board_name, board_id } = board;
|
||||
const [updateBoard, { isLoading: isUpdateBoardLoading }] =
|
||||
useUpdateBoardMutation();
|
||||
|
||||
const { onClickDeleteBoardImages } = useContext(DeleteBoardImagesContext);
|
||||
const handleUpdateBoardName = (newBoardName: string) => {
|
||||
updateBoard({ board_id, changes: { board_name: newBoardName } });
|
||||
};
|
||||
|
||||
const handleSelectBoard = useCallback(() => {
|
||||
dispatch(boardIdSelected(board_id));
|
||||
}, [board_id, dispatch]);
|
||||
const handleDeleteBoard = useCallback(() => {
|
||||
setBoardToDelete(board);
|
||||
}, [board, setBoardToDelete]);
|
||||
|
||||
const [updateBoard, { isLoading: isUpdateBoardLoading }] =
|
||||
useUpdateBoardMutation();
|
||||
const droppableData: MoveBoardDropData = useMemo(
|
||||
() => ({
|
||||
id: board_id,
|
||||
actionType: 'MOVE_BOARD',
|
||||
context: { boardId: board_id },
|
||||
}),
|
||||
[board_id]
|
||||
);
|
||||
|
||||
const [deleteBoard, { isLoading: isDeleteBoardLoading }] =
|
||||
useDeleteBoardMutation();
|
||||
|
||||
const handleUpdateBoardName = (newBoardName: string) => {
|
||||
updateBoard({ board_id, changes: { board_name: newBoardName } });
|
||||
};
|
||||
|
||||
const handleDeleteBoard = useCallback(() => {
|
||||
deleteBoard(board_id);
|
||||
}, [board_id, deleteBoard]);
|
||||
|
||||
const handleAddBoardToBatch = useCallback(() => {
|
||||
// dispatch(boardAddedToBatch({ board_id }));
|
||||
}, []);
|
||||
|
||||
const handleDeleteBoardAndImages = useCallback(() => {
|
||||
onClickDeleteBoardImages(board);
|
||||
}, [board, onClickDeleteBoardImages]);
|
||||
|
||||
const droppableData: MoveBoardDropData = useMemo(
|
||||
() => ({
|
||||
id: board_id,
|
||||
actionType: 'MOVE_BOARD',
|
||||
context: { boardId: board_id },
|
||||
}),
|
||||
[board_id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ touchAction: 'none', height: 'full' }}>
|
||||
<ContextMenu<HTMLDivElement>
|
||||
menuProps={{ size: 'sm', isLazy: true }}
|
||||
renderMenu={() => (
|
||||
<MenuList sx={{ visibility: 'visible !important' }}>
|
||||
{board.image_count > 0 && (
|
||||
<>
|
||||
<MenuItem
|
||||
isDisabled={!board.image_count}
|
||||
icon={<FaImages />}
|
||||
onClickCapture={handleAddBoardToBatch}
|
||||
>
|
||||
Add Board to Batch
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
|
||||
icon={<FaTrash />}
|
||||
onClickCapture={handleDeleteBoardAndImages}
|
||||
>
|
||||
Delete Board and Images
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
|
||||
icon={<FaTrash />}
|
||||
onClickCapture={handleDeleteBoard}
|
||||
return (
|
||||
<Box sx={{ touchAction: 'none', height: 'full' }}>
|
||||
<ContextMenu<HTMLDivElement>
|
||||
menuProps={{ size: 'sm', isLazy: true }}
|
||||
menuButtonProps={{
|
||||
bg: 'transparent',
|
||||
_hover: { bg: 'transparent' },
|
||||
}}
|
||||
renderMenu={() => (
|
||||
<MenuList
|
||||
sx={{ visibility: 'visible !important' }}
|
||||
motionProps={menuListMotionProps}
|
||||
>
|
||||
Delete Board
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
)}
|
||||
>
|
||||
{(ref) => (
|
||||
<Flex
|
||||
key={board_id}
|
||||
userSelect="none"
|
||||
ref={ref}
|
||||
sx={{
|
||||
flexDir: 'column',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
}}
|
||||
>
|
||||
{board.image_count > 0 && (
|
||||
<>
|
||||
{/* <MenuItem
|
||||
isDisabled={!board.image_count}
|
||||
icon={<FaImages />}
|
||||
onClickCapture={handleAddBoardToBatch}
|
||||
>
|
||||
Add Board to Batch
|
||||
</MenuItem> */}
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
|
||||
icon={<FaTrash />}
|
||||
onClickCapture={handleDeleteBoard}
|
||||
>
|
||||
Delete Board
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
)}
|
||||
>
|
||||
{(ref) => (
|
||||
<Flex
|
||||
onClick={handleSelectBoard}
|
||||
key={board_id}
|
||||
userSelect="none"
|
||||
ref={ref}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
justifyContent: 'center',
|
||||
flexDir: 'column',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderRadius: 'base',
|
||||
cursor: 'pointer',
|
||||
w: 'full',
|
||||
aspectRatio: '1/1',
|
||||
overflow: 'hidden',
|
||||
shadow: isSelected ? 'selected.light' : undefined,
|
||||
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
|
||||
flexShrink: 0,
|
||||
h: 'full',
|
||||
}}
|
||||
>
|
||||
{board.cover_image_name && coverImage?.image_url && (
|
||||
<Image src={coverImage?.image_url} draggable={false} />
|
||||
)}
|
||||
{!(board.cover_image_name && coverImage?.image_url) && (
|
||||
<IAINoContentFallback
|
||||
boxSize={8}
|
||||
icon={FaFolder}
|
||||
<Flex
|
||||
onClick={handleSelectBoard}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 'base',
|
||||
w: 'full',
|
||||
aspectRatio: '1/1',
|
||||
overflow: 'hidden',
|
||||
shadow: isSelected ? 'selected.light' : undefined,
|
||||
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{board.cover_image_name && coverImage?.thumbnail_url && (
|
||||
<Image src={coverImage?.thumbnail_url} draggable={false} />
|
||||
)}
|
||||
{!(board.cover_image_name && coverImage?.thumbnail_url) && (
|
||||
<IAINoContentFallback
|
||||
boxSize={8}
|
||||
icon={FaUser}
|
||||
sx={{
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'base.200',
|
||||
_dark: {
|
||||
borderColor: 'base.800',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Flex
|
||||
sx={{
|
||||
border: '2px solid var(--invokeai-colors-base-200)',
|
||||
_dark: {
|
||||
border: '2px solid var(--invokeai-colors-base-800)',
|
||||
},
|
||||
position: 'absolute',
|
||||
insetInlineEnd: 0,
|
||||
top: 0,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Badge variant="solid">{board.image_count}</Badge>
|
||||
</Flex>
|
||||
<IAIDroppable
|
||||
data={droppableData}
|
||||
dropLabel={<Text fontSize="md">Move</Text>}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
insetInlineEnd: 0,
|
||||
top: 0,
|
||||
p: 1,
|
||||
width: 'full',
|
||||
height: 'full',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Badge variant="solid">{board.image_count}</Badge>
|
||||
<Editable
|
||||
defaultValue={board_name}
|
||||
submitOnBlur={false}
|
||||
onSubmit={(nextValue) => {
|
||||
handleUpdateBoardName(nextValue);
|
||||
}}
|
||||
sx={{ maxW: 'full' }}
|
||||
>
|
||||
<EditablePreview
|
||||
sx={{
|
||||
color: isSelected
|
||||
? mode('base.900', 'base.50')(colorMode)
|
||||
: mode('base.700', 'base.200')(colorMode),
|
||||
fontWeight: isSelected ? 600 : undefined,
|
||||
fontSize: 'xs',
|
||||
textAlign: 'center',
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
noOfLines={1}
|
||||
/>
|
||||
<EditableInput
|
||||
sx={{
|
||||
color: mode('base.900', 'base.50')(colorMode),
|
||||
fontSize: 'xs',
|
||||
borderColor: mode('base.500', 'base.500')(colorMode),
|
||||
p: 0,
|
||||
outline: 0,
|
||||
}}
|
||||
/>
|
||||
</Editable>
|
||||
</Flex>
|
||||
<IAIDroppable data={droppableData} />
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
sx={{
|
||||
width: 'full',
|
||||
height: 'full',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Editable
|
||||
defaultValue={board_name}
|
||||
submitOnBlur={false}
|
||||
onSubmit={(nextValue) => {
|
||||
handleUpdateBoardName(nextValue);
|
||||
}}
|
||||
>
|
||||
<EditablePreview
|
||||
sx={{
|
||||
color: isSelected
|
||||
? mode('base.900', 'base.50')(colorMode)
|
||||
: mode('base.700', 'base.200')(colorMode),
|
||||
fontWeight: isSelected ? 600 : undefined,
|
||||
fontSize: 'xs',
|
||||
textAlign: 'center',
|
||||
p: 0,
|
||||
}}
|
||||
noOfLines={1}
|
||||
/>
|
||||
<EditableInput
|
||||
sx={{
|
||||
color: mode('base.900', 'base.50')(colorMode),
|
||||
fontSize: 'xs',
|
||||
borderColor: mode('base.500', 'base.500')(colorMode),
|
||||
p: 0,
|
||||
outline: 0,
|
||||
}}
|
||||
/>
|
||||
</Editable>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
)}
|
||||
</ContextMenu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GalleryBoard.displayName = 'HoverableBoard';
|
||||
|
||||
|
@ -2,18 +2,34 @@ import { As, Badge, Flex } from '@chakra-ui/react';
|
||||
import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
import IAIDroppable from 'common/components/IAIDroppable';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type GenericBoardProps = {
|
||||
droppableData: TypesafeDroppableData;
|
||||
droppableData?: TypesafeDroppableData;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
icon: As;
|
||||
label: string;
|
||||
dropLabel?: ReactNode;
|
||||
badgeCount?: number;
|
||||
};
|
||||
|
||||
const formatBadgeCount = (count: number) =>
|
||||
Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(count);
|
||||
|
||||
const GenericBoard = (props: GenericBoardProps) => {
|
||||
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props;
|
||||
const {
|
||||
droppableData,
|
||||
onClick,
|
||||
isSelected,
|
||||
icon,
|
||||
label,
|
||||
badgeCount,
|
||||
dropLabel,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@ -59,10 +75,10 @@ const GenericBoard = (props: GenericBoardProps) => {
|
||||
}}
|
||||
>
|
||||
{badgeCount !== undefined && (
|
||||
<Badge variant="solid">{badgeCount}</Badge>
|
||||
<Badge variant="solid">{formatBadgeCount(badgeCount)}</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
<IAIDroppable data={droppableData} />
|
||||
<IAIDroppable data={droppableData} dropLabel={dropLabel} />
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
|
@ -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;
|
@ -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);
|
@ -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);
|
@ -17,6 +17,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer';
|
||||
import NextPrevImageButtons from '../NextPrevImageButtons';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { FaImage } from 'react-icons/fa';
|
||||
|
||||
export const imagesSelector = createSelector(
|
||||
[stateSelector, selectLastSelectedImage],
|
||||
@ -168,7 +170,11 @@ const CurrentImagePreview = () => {
|
||||
draggableData={draggableData}
|
||||
isUploadDisabled={true}
|
||||
fitContainer
|
||||
useThumbailFallback
|
||||
dropLabel="Set as Current Image"
|
||||
noContentFallback={
|
||||
<IAINoContentFallback icon={FaImage} label="No image selected" />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{shouldShowImageDetails && imageDTO && (
|
||||
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -1,13 +1,8 @@
|
||||
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 { MouseEvent, memo, useCallback, useMemo } from 'react';
|
||||
import { MouseEvent, memo, useCallback } from 'react';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { menuListMotionProps } from 'theme/components/menu';
|
||||
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
|
||||
import SingleSelectionMenuItems from './SingleSelectionMenuItems';
|
||||
|
||||
type Props = {
|
||||
@ -16,23 +11,23 @@ type Props = {
|
||||
};
|
||||
|
||||
const ImageContextMenu = ({ imageDTO, children }: Props) => {
|
||||
const selector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[stateSelector],
|
||||
({ gallery }) => {
|
||||
const selectionCount = gallery.selection.length;
|
||||
// const selector = useMemo(
|
||||
// () =>
|
||||
// createSelector(
|
||||
// [stateSelector],
|
||||
// ({ gallery }) => {
|
||||
// const selectionCount = gallery.selection.length;
|
||||
|
||||
return { selectionCount };
|
||||
},
|
||||
defaultSelectorOptions
|
||||
),
|
||||
[]
|
||||
);
|
||||
// return { selectionCount };
|
||||
// },
|
||||
// defaultSelectorOptions
|
||||
// ),
|
||||
// []
|
||||
// );
|
||||
|
||||
const { selectionCount } = useAppSelector(selector);
|
||||
// const { selectionCount } = useAppSelector(selector);
|
||||
|
||||
const handleContextMenu = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
@ -48,13 +43,9 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
|
||||
<MenuList
|
||||
sx={{ visibility: 'visible !important' }}
|
||||
motionProps={menuListMotionProps}
|
||||
onContextMenu={handleContextMenu}
|
||||
onContextMenu={skipEvent}
|
||||
>
|
||||
{selectionCount === 1 ? (
|
||||
<SingleSelectionMenuItems imageDTO={imageDTO} />
|
||||
) : (
|
||||
<MultipleSelectionMenuItems />
|
||||
)}
|
||||
<SingleSelectionMenuItems imageDTO={imageDTO} />
|
||||
</MenuList>
|
||||
) : null
|
||||
}
|
||||
|
@ -28,8 +28,10 @@ import {
|
||||
FaShare,
|
||||
FaTrash,
|
||||
} from 'react-icons/fa';
|
||||
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
|
||||
import { useGetImageMetadataQuery } from 'services/api/endpoints/images';
|
||||
import {
|
||||
useGetImageMetadataQuery,
|
||||
useRemoveImageFromBoardMutation,
|
||||
} from 'services/api/endpoints/images';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
|
||||
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
|
||||
@ -128,15 +130,8 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
|
||||
if (!imageDTO.board_id) {
|
||||
return;
|
||||
}
|
||||
removeFromBoard({
|
||||
board_id: imageDTO.board_id,
|
||||
image_name: imageDTO.image_name,
|
||||
});
|
||||
}, [imageDTO.board_id, imageDTO.image_name, removeFromBoard]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
window.open(imageDTO.image_url, '_blank');
|
||||
}, [imageDTO.image_url]);
|
||||
removeFromBoard({ imageDTO });
|
||||
}, [imageDTO, removeFromBoard]);
|
||||
|
||||
const handleAddToBatch = useCallback(() => {
|
||||
dispatch(imagesAddedToBatch([imageDTO.image_name]));
|
||||
@ -149,10 +144,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
|
||||
return (
|
||||
<>
|
||||
<Link href={imageDTO.image_url} target="_blank">
|
||||
<MenuItem
|
||||
icon={<FaExternalLinkAlt />}
|
||||
onClickCapture={handleOpenInNewTab}
|
||||
>
|
||||
<MenuItem icon={<FaExternalLinkAlt />}>
|
||||
{t('common.openInNewTab')}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
@ -161,6 +153,11 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
|
||||
{t('parameters.copyImage')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<Link download={true} href={imageDTO.image_url} target="_blank">
|
||||
<MenuItem icon={<FaDownload />} w="100%">
|
||||
{t('parameters.downloadImage')}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem
|
||||
icon={<FaQuoteRight />}
|
||||
onClickCapture={handleRecallPrompt}
|
||||
@ -219,11 +216,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
|
||||
Remove from Board
|
||||
</MenuItem>
|
||||
)}
|
||||
<Link download={true} href={imageDTO.image_url} target="_blank">
|
||||
<MenuItem icon={<FaDownload />} w="100%">
|
||||
{t('parameters.downloadImage')}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem
|
||||
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
|
||||
icon={<FaTrash />}
|
||||
|
@ -1,113 +1,34 @@
|
||||
import {
|
||||
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 { Box, Flex, VStack, useDisclosure } 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 { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
|
||||
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import { memo, useRef } from 'react';
|
||||
import BoardsList from './Boards/BoardsList/BoardsList';
|
||||
import GalleryBoardName from './GalleryBoardName';
|
||||
import GalleryPinButton from './GalleryPinButton';
|
||||
import GallerySettingsPopover from './GallerySettingsPopover';
|
||||
import BatchImageGrid from './ImageGrid/BatchImageGrid';
|
||||
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
|
||||
|
||||
const selector = createSelector(
|
||||
[stateSelector],
|
||||
(state) => {
|
||||
const {
|
||||
selectedBoardId,
|
||||
galleryImageMinimumWidth,
|
||||
galleryView,
|
||||
shouldAutoSwitch,
|
||||
} = state.gallery;
|
||||
const { shouldPinGallery } = state.ui;
|
||||
const { selectedBoardId } = state.gallery;
|
||||
|
||||
return {
|
||||
selectedBoardId,
|
||||
shouldPinGallery,
|
||||
galleryImageMinimumWidth,
|
||||
shouldAutoSwitch,
|
||||
galleryView,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const ImageGalleryContent = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const resizeObserverRef = useRef<HTMLDivElement>(null);
|
||||
const galleryGridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
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]);
|
||||
const { selectedBoardId } = useAppSelector(selector);
|
||||
const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } =
|
||||
useDisclosure();
|
||||
|
||||
return (
|
||||
<VStack
|
||||
@ -127,95 +48,12 @@ const ImageGalleryContent = () => {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<ButtonGroup isAttached>
|
||||
<IAIIconButton
|
||||
tooltip={t('gallery.images')}
|
||||
aria-label={t('gallery.images')}
|
||||
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 />}
|
||||
<GallerySettingsPopover />
|
||||
<GalleryBoardName
|
||||
isOpen={isBoardListOpen}
|
||||
onToggle={onToggleBoardList}
|
||||
/>
|
||||
<GalleryPinButton />
|
||||
</Flex>
|
||||
<Box>
|
||||
<BoardsList isOpen={isBoardListOpen} />
|
||||
|
@ -1,16 +1,13 @@
|
||||
import { Box, Spinner } from '@chakra-ui/react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
|
||||
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
|
||||
import {
|
||||
imageRangeEndSelected,
|
||||
imageSelected,
|
||||
imageSelectionToggled,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
|
||||
import { MouseEvent, memo, useCallback, useMemo } from 'react';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
@ -84,7 +81,7 @@ const GalleryImage = (props: HoverableImageProps) => {
|
||||
}, [imageDTO, selection, selectionCount]);
|
||||
|
||||
if (!imageDTO) {
|
||||
return <Spinner />;
|
||||
return <IAIFillSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -1,124 +1,70 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { Box, Spinner } from '@chakra-ui/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { 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 { FaExclamationCircle, FaImage } from 'react-icons/fa';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { receivedPageOfImages } from 'services/api/thunks/image';
|
||||
import { useListBoardImagesQuery } from '../../../../services/api/endpoints/boardImages';
|
||||
import {
|
||||
useLazyListImagesQuery,
|
||||
useListImagesQuery,
|
||||
} from 'services/api/endpoints/images';
|
||||
import GalleryImage from './GalleryImage';
|
||||
import ImageGridItemContainer from './ImageGridItemContainer';
|
||||
import ImageGridListContainer from './ImageGridListContainer';
|
||||
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
|
||||
const selector = createSelector(
|
||||
[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,
|
||||
};
|
||||
const overlayScrollbarsConfig: UseOverlayScrollbarsParams = {
|
||||
defer: true,
|
||||
options: {
|
||||
scrollbars: {
|
||||
visibility: 'auto',
|
||||
autoHide: 'leave',
|
||||
autoHideDelay: 1300,
|
||||
theme: 'os-theme-dark',
|
||||
},
|
||||
overflow: { x: 'hidden' },
|
||||
},
|
||||
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,
|
||||
options: {
|
||||
scrollbars: {
|
||||
visibility: 'auto',
|
||||
autoHide: 'leave',
|
||||
autoHideDelay: 1300,
|
||||
theme: 'os-theme-dark',
|
||||
},
|
||||
overflow: { x: 'hidden' },
|
||||
},
|
||||
});
|
||||
const [initialize, osInstance] = useOverlayScrollbars(
|
||||
overlayScrollbarsConfig
|
||||
);
|
||||
|
||||
const [didInitialFetch, setDidInitialFetch] = useState(false);
|
||||
const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentData, isFetching, isSuccess, isError } =
|
||||
useListImagesQuery(queryArgs);
|
||||
|
||||
const {
|
||||
galleryImageMinimumWidth,
|
||||
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(() => {
|
||||
if (selectedBoardId === 'all') {
|
||||
return imageNamesAll; // already sorted by images/uploads in gallery selector
|
||||
} else {
|
||||
const categories =
|
||||
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
|
||||
const imageList = (imagesForBoard?.items || []).filter((img) =>
|
||||
categories.includes(img.image_category)
|
||||
);
|
||||
return imageList.map((img) => img.image_name);
|
||||
}
|
||||
}, [selectedBoardId, galleryView, imagesForBoard, imageNamesAll]);
|
||||
const [listImages] = useLazyListImagesQuery();
|
||||
|
||||
const areMoreAvailable = useMemo(() => {
|
||||
return selectedBoardId === 'all' ? totalAll > imageNamesAll.length : false;
|
||||
}, [selectedBoardId, imageNamesAll.length, totalAll]);
|
||||
|
||||
const isLoading = useMemo(() => {
|
||||
return selectedBoardId === 'all' ? isLoadingAll : isLoadingImagesForBoard;
|
||||
}, [selectedBoardId, isLoadingAll, isLoadingImagesForBoard]);
|
||||
if (!currentData) {
|
||||
return false;
|
||||
}
|
||||
return currentData.ids.length < currentData.total;
|
||||
}, [currentData]);
|
||||
|
||||
const handleLoadMoreImages = useCallback(() => {
|
||||
dispatch(
|
||||
receivedPageOfImages({
|
||||
categories:
|
||||
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
|
||||
is_intermediate: false,
|
||||
offset: imageNames.length,
|
||||
limit: IMAGE_LIMIT,
|
||||
})
|
||||
);
|
||||
}, [dispatch, imageNames.length, galleryView]);
|
||||
listImages({
|
||||
...queryArgs,
|
||||
offset: currentData?.ids.length ?? 0,
|
||||
limit: IMAGE_LIMIT,
|
||||
});
|
||||
}, [listImages, queryArgs, currentData?.ids.length]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set up gallery scroler
|
||||
// Initialize the gallery's custom scrollbar
|
||||
const { current: root } = rootRef;
|
||||
if (scroller && root) {
|
||||
initialize({
|
||||
@ -131,47 +77,17 @@ const GalleryImageGrid = () => {
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
const handleEndReached = useMemo(() => {
|
||||
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) {
|
||||
if (!currentData) {
|
||||
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
|
||||
label={t('gallery.noImagesInGallery')}
|
||||
icon={FaImage}
|
||||
@ -180,27 +96,28 @@ const GalleryImageGrid = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'rejected') {
|
||||
if (isSuccess && currentData) {
|
||||
return (
|
||||
<>
|
||||
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
|
||||
<VirtuosoGrid
|
||||
style={{ height: '100%' }}
|
||||
data={imageNames}
|
||||
data={currentData.ids}
|
||||
endReached={handleLoadMoreImages}
|
||||
components={{
|
||||
Item: ImageGridItemContainer,
|
||||
List: ImageGridListContainer,
|
||||
}}
|
||||
scrollerRef={setScroller}
|
||||
itemContent={(index, imageName) => (
|
||||
<GalleryImage key={imageName} imageName={imageName} />
|
||||
<GalleryImage key={imageName} imageName={imageName as string} />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<IAIButton
|
||||
onClick={handleLoadMoreImages}
|
||||
isDisabled={!areMoreAvailable}
|
||||
isLoading={status === 'pending'}
|
||||
isLoading={isFetching}
|
||||
loadingText="Loading"
|
||||
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);
|
||||
|
@ -11,11 +11,9 @@ const ImageMetadataActions = (props: Props) => {
|
||||
const { metadata } = props;
|
||||
|
||||
const {
|
||||
recallBothPrompts,
|
||||
recallPositivePrompt,
|
||||
recallNegativePrompt,
|
||||
recallSeed,
|
||||
recallInitialImage,
|
||||
recallCfgScale,
|
||||
recallModel,
|
||||
recallScheduler,
|
||||
@ -23,7 +21,6 @@ const ImageMetadataActions = (props: Props) => {
|
||||
recallWidth,
|
||||
recallHeight,
|
||||
recallStrength,
|
||||
recallAllParameters,
|
||||
} = useRecallParameters();
|
||||
|
||||
const handleRecallPositivePrompt = useCallback(() => {
|
||||
|
@ -2,61 +2,76 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
IMAGE_LIMIT,
|
||||
imageSelected,
|
||||
selectImagesById,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { clamp, isEqual } from 'lodash-es';
|
||||
import { useCallback } from 'react';
|
||||
import { receivedPageOfImages } from 'services/api/thunks/image';
|
||||
import { selectFilteredImages } from '../store/gallerySelectors';
|
||||
import {
|
||||
ListImagesArgs,
|
||||
imagesAdapter,
|
||||
imagesApi,
|
||||
useLazyListImagesQuery,
|
||||
} from 'services/api/endpoints/images';
|
||||
import { selectListImagesBaseQueryArgs } from '../store/gallerySelectors';
|
||||
|
||||
export const nextPrevImageButtonsSelector = createSelector(
|
||||
[stateSelector, selectFilteredImages],
|
||||
(state, filteredImages) => {
|
||||
const { total, isFetching } = state.gallery;
|
||||
[stateSelector, selectListImagesBaseQueryArgs],
|
||||
(state, baseQueryArgs) => {
|
||||
const { data, status } =
|
||||
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
|
||||
|
||||
const lastSelectedImage =
|
||||
state.gallery.selection[state.gallery.selection.length - 1];
|
||||
|
||||
if (!lastSelectedImage || filteredImages.length === 0) {
|
||||
const isFetching = status === 'pending';
|
||||
|
||||
if (!data || !lastSelectedImage || data.total === 0) {
|
||||
return {
|
||||
isFetching,
|
||||
queryArgs: baseQueryArgs,
|
||||
isOnFirstImage: 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
|
||||
);
|
||||
const nextImageIndex = clamp(
|
||||
currentImageIndex + 1,
|
||||
0,
|
||||
filteredImages.length - 1
|
||||
);
|
||||
const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
|
||||
|
||||
const prevImageIndex = clamp(
|
||||
currentImageIndex - 1,
|
||||
0,
|
||||
filteredImages.length - 1
|
||||
);
|
||||
const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
|
||||
|
||||
const nextImageId = filteredImages[nextImageIndex].image_name;
|
||||
const prevImageId = filteredImages[prevImageIndex].image_name;
|
||||
const nextImageId = images[nextImageIndex].image_name;
|
||||
const prevImageId = images[prevImageIndex].image_name;
|
||||
|
||||
const nextImage = selectImagesById(state, nextImageId);
|
||||
const prevImage = selectImagesById(state, prevImageId);
|
||||
const nextImage = selectors.selectById(data, nextImageId);
|
||||
const prevImage = selectors.selectById(data, prevImageId);
|
||||
|
||||
const imagesLength = filteredImages.length;
|
||||
const imagesLength = images.length;
|
||||
|
||||
return {
|
||||
isOnFirstImage: currentImageIndex === 0,
|
||||
isOnLastImage:
|
||||
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
|
||||
areMoreImagesAvailable: total > imagesLength,
|
||||
isFetching,
|
||||
areMoreImagesAvailable: data?.total ?? 0 > imagesLength,
|
||||
isFetching: status === 'pending',
|
||||
nextImage,
|
||||
prevImage,
|
||||
nextImageId,
|
||||
prevImageId,
|
||||
queryArgs,
|
||||
};
|
||||
},
|
||||
{
|
||||
@ -76,6 +91,7 @@ export const useNextPrevImage = () => {
|
||||
prevImageId,
|
||||
areMoreImagesAvailable,
|
||||
isFetching,
|
||||
queryArgs,
|
||||
} = useAppSelector(nextPrevImageButtonsSelector);
|
||||
|
||||
const handlePrevImage = useCallback(() => {
|
||||
@ -86,13 +102,11 @@ export const useNextPrevImage = () => {
|
||||
nextImageId && dispatch(imageSelected(nextImageId));
|
||||
}, [dispatch, nextImageId]);
|
||||
|
||||
const [listImages] = useLazyListImagesQuery();
|
||||
|
||||
const handleLoadMoreImages = useCallback(() => {
|
||||
dispatch(
|
||||
receivedPageOfImages({
|
||||
is_intermediate: false,
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
listImages(queryArgs);
|
||||
}, [listImages, queryArgs]);
|
||||
|
||||
return {
|
||||
handlePrevImage,
|
||||
|
@ -1,136 +1,38 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { clamp, keyBy } from 'lodash-es';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { ListImagesArgs } from 'services/api/endpoints/images';
|
||||
import { INITIAL_IMAGE_LIMIT } from './gallerySlice';
|
||||
import {
|
||||
ASSETS_CATEGORIES,
|
||||
BoardId,
|
||||
IMAGE_CATEGORIES,
|
||||
imagesAdapter,
|
||||
initialGalleryState,
|
||||
} from './gallerySlice';
|
||||
getBoardIdQueryParamForBoard,
|
||||
getCategoriesQueryParamForBoard,
|
||||
} from './util';
|
||||
|
||||
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(
|
||||
(state: RootState) => state,
|
||||
(state) => state.gallery.selection[state.gallery.selection.length - 1],
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
export const selectSelectedImages = createSelector(
|
||||
(state: RootState) => state,
|
||||
(state) =>
|
||||
imagesAdapter
|
||||
.getSelectors()
|
||||
.selectAll(state.gallery)
|
||||
.filter((i) => state.gallery.selection.includes(i.image_name)),
|
||||
defaultSelectorOptions
|
||||
);
|
||||
export const selectListImagesBaseQueryArgs = createSelector(
|
||||
[(state: RootState) => state],
|
||||
(state) => {
|
||||
const { selectedBoardId } = state.gallery;
|
||||
|
||||
export const selectNextImageToSelectLocal = createSelector(
|
||||
[
|
||||
(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 categories = getCategoriesQueryParamForBoard(selectedBoardId);
|
||||
const board_id = getBoardIdQueryParamForBoard(selectedBoardId);
|
||||
|
||||
const deletedImageIndex = ids.findIndex(
|
||||
(result) => result.toString() === image_name
|
||||
);
|
||||
const listImagesBaseQueryArgs: ListImagesArgs = {
|
||||
categories,
|
||||
board_id,
|
||||
offset: 0,
|
||||
limit: INITIAL_IMAGE_LIMIT,
|
||||
is_intermediate: false,
|
||||
};
|
||||
|
||||
const filteredIds = ids.filter((id) => id.toString() !== image_name);
|
||||
|
||||
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);
|
||||
return listImagesBaseQueryArgs;
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
@ -1,20 +1,8 @@
|
||||
import type { PayloadAction, Update } from '@reduxjs/toolkit';
|
||||
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { dateComparator } from 'common/util/dateComparator';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { boardsApi } from 'services/api/endpoints/boards';
|
||||
import {
|
||||
imageUrlsReceived,
|
||||
receivedPageOfImages,
|
||||
} from 'services/api/thunks/image';
|
||||
import { ImageCategory, ImageDTO } from 'services/api/types';
|
||||
import { selectFilteredImagesLocal } from './gallerySelectors';
|
||||
|
||||
export const imagesAdapter = createEntityAdapter<ImageDTO>({
|
||||
selectId: (image) => image.image_name,
|
||||
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
|
||||
});
|
||||
import { ImageCategory } from 'services/api/types';
|
||||
|
||||
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
|
||||
export const ASSETS_CATEGORIES: ImageCategory[] = [
|
||||
@ -26,113 +14,74 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
|
||||
export const INITIAL_IMAGE_LIMIT = 100;
|
||||
export const IMAGE_LIMIT = 20;
|
||||
|
||||
export type GalleryView = 'images' | 'assets';
|
||||
// export type GalleryView = 'images' | 'assets';
|
||||
export type BoardId =
|
||||
| 'all'
|
||||
| 'none'
|
||||
| 'images'
|
||||
| 'assets'
|
||||
| 'no_board'
|
||||
| 'batch'
|
||||
| (string & Record<never, never>);
|
||||
|
||||
type AdditionaGalleryState = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
type GalleryState = {
|
||||
selection: string[];
|
||||
shouldAutoSwitch: boolean;
|
||||
galleryImageMinimumWidth: number;
|
||||
galleryView: GalleryView;
|
||||
selectedBoardId: BoardId;
|
||||
isInitialized: boolean;
|
||||
batchImageNames: string[];
|
||||
isBatchEnabled: boolean;
|
||||
};
|
||||
|
||||
export const initialGalleryState =
|
||||
imagesAdapter.getInitialState<AdditionaGalleryState>({
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
selection: [],
|
||||
shouldAutoSwitch: true,
|
||||
galleryImageMinimumWidth: 96,
|
||||
galleryView: 'images',
|
||||
selectedBoardId: 'all',
|
||||
isInitialized: false,
|
||||
batchImageNames: [],
|
||||
isBatchEnabled: false,
|
||||
});
|
||||
export const initialGalleryState: GalleryState = {
|
||||
selection: [],
|
||||
shouldAutoSwitch: true,
|
||||
galleryImageMinimumWidth: 96,
|
||||
selectedBoardId: 'images',
|
||||
batchImageNames: [],
|
||||
isBatchEnabled: false,
|
||||
};
|
||||
|
||||
export const gallerySlice = createSlice({
|
||||
name: 'gallery',
|
||||
initialState: initialGalleryState,
|
||||
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[]>) => {
|
||||
imagesAdapter.removeMany(state, action.payload);
|
||||
state.batchImageNames = state.batchImageNames.filter(
|
||||
(name) => !action.payload.includes(name)
|
||||
);
|
||||
// TODO: port all instances of this to use RTK Query cache
|
||||
// imagesAdapter.removeMany(state, action.payload);
|
||||
// state.batchImageNames = state.batchImageNames.filter(
|
||||
// (name) => !action.payload.includes(name)
|
||||
// );
|
||||
},
|
||||
imageRangeEndSelected: (state, action: PayloadAction<string>) => {
|
||||
const rangeEndImageName = action.payload;
|
||||
const lastSelectedImage = state.selection[state.selection.length - 1];
|
||||
|
||||
const filteredImages = selectFilteredImagesLocal(state);
|
||||
|
||||
const lastClickedIndex = filteredImages.findIndex(
|
||||
(n) => n.image_name === lastSelectedImage
|
||||
);
|
||||
|
||||
const currentClickedIndex = filteredImages.findIndex(
|
||||
(n) => n.image_name === rangeEndImageName
|
||||
);
|
||||
|
||||
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
|
||||
// We have a valid range!
|
||||
const start = Math.min(lastClickedIndex, currentClickedIndex);
|
||||
const end = Math.max(lastClickedIndex, currentClickedIndex);
|
||||
|
||||
const imagesToSelect = filteredImages
|
||||
.slice(start, end + 1)
|
||||
.map((i) => i.image_name);
|
||||
|
||||
state.selection = uniq(state.selection.concat(imagesToSelect));
|
||||
}
|
||||
// const rangeEndImageName = action.payload;
|
||||
// const lastSelectedImage = state.selection[state.selection.length - 1];
|
||||
// const filteredImages = selectFilteredImagesLocal(state);
|
||||
// const lastClickedIndex = filteredImages.findIndex(
|
||||
// (n) => n.image_name === lastSelectedImage
|
||||
// );
|
||||
// const currentClickedIndex = filteredImages.findIndex(
|
||||
// (n) => n.image_name === rangeEndImageName
|
||||
// );
|
||||
// if (lastClickedIndex > -1 && currentClickedIndex > -1) {
|
||||
// // We have a valid range!
|
||||
// const start = Math.min(lastClickedIndex, currentClickedIndex);
|
||||
// const end = Math.max(lastClickedIndex, currentClickedIndex);
|
||||
// const imagesToSelect = filteredImages
|
||||
// .slice(start, end + 1)
|
||||
// .map((i) => i.image_name);
|
||||
// state.selection = uniq(state.selection.concat(imagesToSelect));
|
||||
// }
|
||||
},
|
||||
imageSelectionToggled: (state, action: PayloadAction<string>) => {
|
||||
if (
|
||||
state.selection.includes(action.payload) &&
|
||||
state.selection.length > 1
|
||||
) {
|
||||
state.selection = state.selection.filter(
|
||||
(imageName) => imageName !== action.payload
|
||||
);
|
||||
} else {
|
||||
state.selection = uniq(state.selection.concat(action.payload));
|
||||
}
|
||||
// if (
|
||||
// state.selection.includes(action.payload) &&
|
||||
// state.selection.length > 1
|
||||
// ) {
|
||||
// state.selection = state.selection.filter(
|
||||
// (imageName) => imageName !== action.payload
|
||||
// );
|
||||
// } else {
|
||||
// state.selection = uniq(state.selection.concat(action.payload));
|
||||
// }
|
||||
},
|
||||
imageSelected: (state, action: PayloadAction<string | null>) => {
|
||||
state.selection = action.payload ? [action.payload] : [];
|
||||
@ -143,15 +92,9 @@ export const gallerySlice = createSlice({
|
||||
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
|
||||
state.galleryImageMinimumWidth = action.payload;
|
||||
},
|
||||
setGalleryView: (state, action: PayloadAction<GalleryView>) => {
|
||||
state.galleryView = action.payload;
|
||||
},
|
||||
boardIdSelected: (state, action: PayloadAction<BoardId>) => {
|
||||
state.selectedBoardId = action.payload;
|
||||
},
|
||||
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isLoading = action.payload;
|
||||
},
|
||||
isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isBatchEnabled = action.payload;
|
||||
},
|
||||
@ -182,47 +125,11 @@ export const gallerySlice = createSlice({
|
||||
},
|
||||
},
|
||||
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(
|
||||
boardsApi.endpoints.deleteBoard.matchFulfilled,
|
||||
(state, action) => {
|
||||
if (action.meta.arg.originalArgs === state.selectedBoardId) {
|
||||
state.selectedBoardId = 'all';
|
||||
state.selectedBoardId = 'images';
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -230,26 +137,13 @@ export const gallerySlice = createSlice({
|
||||
});
|
||||
|
||||
export const {
|
||||
selectAll: selectImagesAll,
|
||||
selectById: selectImagesById,
|
||||
selectEntities: selectImagesEntities,
|
||||
selectIds: selectImagesIds,
|
||||
selectTotal: selectImagesTotal,
|
||||
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery);
|
||||
|
||||
export const {
|
||||
imageUpserted,
|
||||
imageUpdatedOne,
|
||||
imageRemoved,
|
||||
imagesRemoved,
|
||||
imageRangeEndSelected,
|
||||
imageSelectionToggled,
|
||||
imageSelected,
|
||||
shouldAutoSwitchChanged,
|
||||
setGalleryImageMinimumWidth,
|
||||
setGalleryView,
|
||||
boardIdSelected,
|
||||
isLoadingChanged,
|
||||
isBatchEnabledChanged,
|
||||
imagesAddedToBatch,
|
||||
imagesRemovedFromBatch,
|
||||
|
54
invokeai/frontend/web/src/features/gallery/store/util.ts
Normal file
54
invokeai/frontend/web/src/features/gallery/store/util.ts
Normal 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';
|
||||
};
|
@ -2,9 +2,17 @@ import { some } from 'lodash-es';
|
||||
import { memo } from 'react';
|
||||
import { ImageUsage } from '../store/imageDeletionSlice';
|
||||
import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||
|
||||
const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
|
||||
const { imageUsage } = props;
|
||||
type Props = {
|
||||
imageUsage?: ImageUsage;
|
||||
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) {
|
||||
return null;
|
||||
@ -16,16 +24,14 @@ const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>This image is currently in use in the following features:</Text>
|
||||
<Text>{topMessage}</Text>
|
||||
<UnorderedList sx={{ paddingInlineStart: 6 }}>
|
||||
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
|
||||
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
|
||||
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
|
||||
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
|
||||
</UnorderedList>
|
||||
<Text>
|
||||
If you delete this image, those features will immediately be reset.
|
||||
</Text>
|
||||
<Text>{bottomMessage}</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -51,10 +51,42 @@ export type ImageUsage = {
|
||||
isControlNetImage: boolean;
|
||||
};
|
||||
|
||||
export const getImageUsage = (state: RootState, image_name: string) => {
|
||||
const { generation, canvas, nodes, controlNet } = state;
|
||||
const isInitialImage = generation.initialImage?.imageName === image_name;
|
||||
|
||||
const isCanvasImage = canvas.layerState.objects.some(
|
||||
(obj) => obj.kind === 'image' && obj.imageName === image_name
|
||||
);
|
||||
|
||||
const isNodesImage = nodes.nodes.some((node) => {
|
||||
return some(
|
||||
node.data.inputs,
|
||||
(input) =>
|
||||
input.type === 'image' && input.value?.image_name === image_name
|
||||
);
|
||||
});
|
||||
|
||||
const isControlNetImage = some(
|
||||
controlNet.controlNets,
|
||||
(c) =>
|
||||
c.controlImage === image_name || c.processedControlImage === image_name
|
||||
);
|
||||
|
||||
const imageUsage: ImageUsage = {
|
||||
isInitialImage,
|
||||
isCanvasImage,
|
||||
isNodesImage,
|
||||
isControlNetImage,
|
||||
};
|
||||
|
||||
return imageUsage;
|
||||
};
|
||||
|
||||
export const selectImageUsage = createSelector(
|
||||
[(state: RootState) => state],
|
||||
({ imageDeletion, generation, canvas, nodes, controlNet }) => {
|
||||
const { imageToDelete } = imageDeletion;
|
||||
(state) => {
|
||||
const { imageToDelete } = state.imageDeletion;
|
||||
|
||||
if (!imageToDelete) {
|
||||
return;
|
||||
@ -62,32 +94,7 @@ export const selectImageUsage = createSelector(
|
||||
|
||||
const { image_name } = imageToDelete;
|
||||
|
||||
const isInitialImage = generation.initialImage?.imageName === image_name;
|
||||
|
||||
const isCanvasImage = canvas.layerState.objects.some(
|
||||
(obj) => obj.kind === 'image' && obj.imageName === image_name
|
||||
);
|
||||
|
||||
const isNodesImage = nodes.nodes.some((node) => {
|
||||
return some(
|
||||
node.data.inputs,
|
||||
(input) =>
|
||||
input.type === 'image' && input.value?.image_name === image_name
|
||||
);
|
||||
});
|
||||
|
||||
const isControlNetImage = some(
|
||||
controlNet.controlNets,
|
||||
(c) =>
|
||||
c.controlImage === image_name || c.processedControlImage === image_name
|
||||
);
|
||||
|
||||
const imageUsage: ImageUsage = {
|
||||
isInitialImage,
|
||||
isCanvasImage,
|
||||
isNodesImage,
|
||||
isControlNetImage,
|
||||
};
|
||||
const imageUsage = getImageUsage(state, image_name);
|
||||
|
||||
return imageUsage;
|
||||
},
|
||||
|
@ -1,33 +1,46 @@
|
||||
import { Flex, Image } from '@chakra-ui/react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { InvocationValue } from '../types/types';
|
||||
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
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 IAINodeResizer from './IAINode/IAINodeResizer';
|
||||
import NodeWrapper from './NodeWrapper';
|
||||
|
||||
const ProgressImageNode = (props: NodeProps<InvocationValue>) => {
|
||||
const progressImage = useAppSelector((state) => state.system.progressImage);
|
||||
const ProgressImageNode = (props: NodeProps) => {
|
||||
const progressImage = useSelector(
|
||||
(state: RootState) => state.system.progressImage
|
||||
);
|
||||
const progressNodeSize = useSelector(
|
||||
(state: RootState) => state.nodes.progressNodeSize
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const { selected } = props;
|
||||
|
||||
const handleResize: OnResize = (_, newSize) => {
|
||||
dispatch(setProgressNodeSize(newSize));
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeWrapper selected={selected}>
|
||||
<IAINodeHeader
|
||||
title="Progress Image"
|
||||
description="Displays the progress image in the Node Editor"
|
||||
/>
|
||||
|
||||
<Flex
|
||||
className="nopan"
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
borderBottomRadius: 'md',
|
||||
p: 2,
|
||||
bg: 'base.200',
|
||||
_dark: { bg: 'base.800' },
|
||||
width: progressNodeSize.width - 2,
|
||||
height: progressNodeSize.height - 2,
|
||||
minW: 250,
|
||||
minH: 250,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{progressImage ? (
|
||||
@ -42,22 +55,17 @@ const ProgressImageNode = (props: NodeProps<InvocationValue>) => {
|
||||
) : (
|
||||
<Flex
|
||||
sx={{
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
minW: 32,
|
||||
minH: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minW: 250,
|
||||
minH: 250,
|
||||
width: progressNodeSize.width - 2,
|
||||
height: progressNodeSize.height - 2,
|
||||
}}
|
||||
>
|
||||
<IAINoContentFallback />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<IAINodeResizer
|
||||
maxHeight={progressImage?.height ?? 512}
|
||||
maxWidth={progressImage?.width ?? 512}
|
||||
/>
|
||||
<IAINodeResizer onResize={handleResize} />
|
||||
</NodeWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -15,8 +15,8 @@ import {
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { PostUploadAction } from 'services/api/thunks/image';
|
||||
import { FieldComponentProps } from './types';
|
||||
import { PostUploadAction } from 'services/api/types';
|
||||
|
||||
const ImageInputFieldComponent = (
|
||||
props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate>
|
||||
|
@ -35,6 +35,7 @@ export type NodesState = {
|
||||
shouldShowFieldTypeLegend: boolean;
|
||||
shouldShowMinimapPanel: boolean;
|
||||
editorInstance: ReactFlowInstance | undefined;
|
||||
progressNodeSize: { width: number; height: number };
|
||||
};
|
||||
|
||||
export const initialNodesState: NodesState = {
|
||||
@ -47,6 +48,7 @@ export const initialNodesState: NodesState = {
|
||||
shouldShowFieldTypeLegend: false,
|
||||
shouldShowMinimapPanel: true,
|
||||
editorInstance: undefined,
|
||||
progressNodeSize: { width: 512, height: 512 },
|
||||
};
|
||||
|
||||
const nodesSlice = createSlice({
|
||||
@ -157,6 +159,12 @@ const nodesSlice = createSlice({
|
||||
loadFileEdges: (state, action: PayloadAction<Edge[]>) => {
|
||||
state.edges = action.payload;
|
||||
},
|
||||
setProgressNodeSize: (
|
||||
state,
|
||||
action: PayloadAction<{ width: number; height: number }>
|
||||
) => {
|
||||
state.progressNodeSize = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => {
|
||||
@ -182,6 +190,7 @@ export const {
|
||||
setEditorInstance,
|
||||
loadFileNodes,
|
||||
loadFileEdges,
|
||||
setProgressNodeSize,
|
||||
} = nodesSlice.actions;
|
||||
|
||||
export default nodesSlice.reducer;
|
||||
|
@ -29,6 +29,7 @@ export const addControlNetToLinearGraph = (
|
||||
const controlNetIterateNode: CollectInvocation = {
|
||||
id: CONTROL_NET_COLLECT,
|
||||
type: 'collect',
|
||||
is_intermediate: true,
|
||||
};
|
||||
graph.nodes[CONTROL_NET_COLLECT] = controlNetIterateNode;
|
||||
graph.edges.push({
|
||||
@ -55,6 +56,7 @@ export const addControlNetToLinearGraph = (
|
||||
const controlNetNode: ControlNetInvocation = {
|
||||
id: `control_net_${controlNetId}`,
|
||||
type: 'controlnet',
|
||||
is_intermediate: true,
|
||||
begin_step_percent: beginStepPct,
|
||||
end_step_percent: endStepPct,
|
||||
control_mode: controlMode,
|
||||
|
@ -43,6 +43,7 @@ export const addDynamicPromptsToGraph = (
|
||||
const dynamicPromptNode: DynamicPromptInvocation = {
|
||||
id: DYNAMIC_PROMPT,
|
||||
type: 'dynamic_prompt',
|
||||
is_intermediate: true,
|
||||
max_prompts: combinatorial ? maxPrompts : iterations,
|
||||
combinatorial,
|
||||
prompt: positivePrompt,
|
||||
@ -51,6 +52,7 @@ export const addDynamicPromptsToGraph = (
|
||||
const iterateNode: IterateInvocation = {
|
||||
id: ITERATE,
|
||||
type: 'iterate',
|
||||
is_intermediate: true,
|
||||
};
|
||||
|
||||
graph.nodes[DYNAMIC_PROMPT] = dynamicPromptNode;
|
||||
@ -99,6 +101,7 @@ export const addDynamicPromptsToGraph = (
|
||||
const randomIntNode: RandomIntInvocation = {
|
||||
id: RANDOM_INT,
|
||||
type: 'rand_int',
|
||||
is_intermediate: true,
|
||||
};
|
||||
|
||||
graph.nodes[RANDOM_INT] = randomIntNode;
|
||||
@ -133,6 +136,7 @@ export const addDynamicPromptsToGraph = (
|
||||
const rangeOfSizeNode: RangeOfSizeInvocation = {
|
||||
id: RANGE_OF_SIZE,
|
||||
type: 'range_of_size',
|
||||
is_intermediate: true,
|
||||
size: iterations,
|
||||
step: 1,
|
||||
};
|
||||
@ -140,6 +144,7 @@ export const addDynamicPromptsToGraph = (
|
||||
const iterateNode: IterateInvocation = {
|
||||
id: ITERATE,
|
||||
type: 'iterate',
|
||||
is_intermediate: true,
|
||||
};
|
||||
|
||||
graph.nodes[ITERATE] = iterateNode;
|
||||
@ -186,6 +191,7 @@ export const addDynamicPromptsToGraph = (
|
||||
const randomIntNode: RandomIntInvocation = {
|
||||
id: RANDOM_INT,
|
||||
type: 'rand_int',
|
||||
is_intermediate: true,
|
||||
};
|
||||
|
||||
graph.nodes[RANDOM_INT] = randomIntNode;
|
||||
|
@ -60,6 +60,7 @@ export const addLoRAsToGraph = (
|
||||
const loraLoaderNode: LoraLoaderInvocation = {
|
||||
type: 'lora_loader',
|
||||
id: currentLoraNodeId,
|
||||
is_intermediate: true,
|
||||
lora: { model_name, base_model },
|
||||
weight,
|
||||
};
|
||||
|
@ -28,6 +28,7 @@ export const addVAEToGraph = (
|
||||
graph.nodes[VAE_LOADER] = {
|
||||
type: 'vae_loader',
|
||||
id: VAE_LOADER,
|
||||
is_intermediate: true,
|
||||
vae_model: vae,
|
||||
};
|
||||
}
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { RootState } from 'app/store/store';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { log } from 'app/logging/useLogger';
|
||||
import { forEach } from 'lodash-es';
|
||||
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { NonNullableGraph } from 'features/nodes/types/types';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { buildCanvasImageToImageGraph } from './buildCanvasImageToImageGraph';
|
||||
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
|
||||
import { buildCanvasTextToImageGraph } from './buildCanvasTextToImageGraph';
|
||||
|
||||
const moduleLog = log.child({ namespace: 'nodes' });
|
||||
@ -31,9 +30,5 @@ export const buildCanvasGraph = (
|
||||
graph = buildCanvasInpaintGraph(state, canvasInitImage, canvasMaskImage);
|
||||
}
|
||||
|
||||
forEach(graph.nodes, (node) => {
|
||||
graph.nodes[node.id].is_intermediate = true;
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
@ -50,6 +50,8 @@ export const buildCanvasImageToImageGraph = (
|
||||
// The bounding box determines width and height, not the width and height params
|
||||
const { width, height } = state.canvas.boundingBoxDimensions;
|
||||
|
||||
const { shouldAutoSave } = state.canvas;
|
||||
|
||||
if (!model) {
|
||||
moduleLog.error('No model found in state');
|
||||
throw new Error('No model found in state');
|
||||
@ -75,35 +77,42 @@ export const buildCanvasImageToImageGraph = (
|
||||
[POSITIVE_CONDITIONING]: {
|
||||
type: 'compel',
|
||||
id: POSITIVE_CONDITIONING,
|
||||
is_intermediate: true,
|
||||
prompt: positivePrompt,
|
||||
},
|
||||
[NEGATIVE_CONDITIONING]: {
|
||||
type: 'compel',
|
||||
id: NEGATIVE_CONDITIONING,
|
||||
is_intermediate: true,
|
||||
prompt: negativePrompt,
|
||||
},
|
||||
[NOISE]: {
|
||||
type: 'noise',
|
||||
id: NOISE,
|
||||
is_intermediate: true,
|
||||
use_cpu,
|
||||
},
|
||||
[MAIN_MODEL_LOADER]: {
|
||||
type: 'main_model_loader',
|
||||
id: MAIN_MODEL_LOADER,
|
||||
is_intermediate: true,
|
||||
model,
|
||||
},
|
||||
[CLIP_SKIP]: {
|
||||
type: 'clip_skip',
|
||||
id: CLIP_SKIP,
|
||||
is_intermediate: true,
|
||||
skipped_layers: clipSkip,
|
||||
},
|
||||
[LATENTS_TO_IMAGE]: {
|
||||
is_intermediate: !shouldAutoSave,
|
||||
type: 'l2i',
|
||||
id: LATENTS_TO_IMAGE,
|
||||
},
|
||||
[LATENTS_TO_LATENTS]: {
|
||||
type: 'l2l',
|
||||
id: LATENTS_TO_LATENTS,
|
||||
is_intermediate: true,
|
||||
cfg_scale,
|
||||
scheduler,
|
||||
steps,
|
||||
@ -112,6 +121,7 @@ export const buildCanvasImageToImageGraph = (
|
||||
[IMAGE_TO_LATENTS]: {
|
||||
type: 'i2l',
|
||||
id: IMAGE_TO_LATENTS,
|
||||
is_intermediate: true,
|
||||
// must be set manually later, bc `fit` parameter may require a resize node inserted
|
||||
// image: {
|
||||
// image_name: initialImage.image_name,
|
||||
|
@ -61,12 +61,17 @@ export const buildCanvasInpaintGraph = (
|
||||
const { width, height } = state.canvas.boundingBoxDimensions;
|
||||
|
||||
// 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 = {
|
||||
id: INPAINT_GRAPH,
|
||||
nodes: {
|
||||
[INPAINT]: {
|
||||
is_intermediate: !shouldAutoSave,
|
||||
type: 'inpaint',
|
||||
id: INPAINT,
|
||||
steps,
|
||||
@ -100,26 +105,31 @@ export const buildCanvasInpaintGraph = (
|
||||
[POSITIVE_CONDITIONING]: {
|
||||
type: 'compel',
|
||||
id: POSITIVE_CONDITIONING,
|
||||
is_intermediate: true,
|
||||
prompt: positivePrompt,
|
||||
},
|
||||
[NEGATIVE_CONDITIONING]: {
|
||||
type: 'compel',
|
||||
id: NEGATIVE_CONDITIONING,
|
||||
is_intermediate: true,
|
||||
prompt: negativePrompt,
|
||||
},
|
||||
[MAIN_MODEL_LOADER]: {
|
||||
type: 'main_model_loader',
|
||||
id: MAIN_MODEL_LOADER,
|
||||
is_intermediate: true,
|
||||
model,
|
||||
},
|
||||
[CLIP_SKIP]: {
|
||||
type: 'clip_skip',
|
||||
id: CLIP_SKIP,
|
||||
is_intermediate: true,
|
||||
skipped_layers: clipSkip,
|
||||
},
|
||||
[RANGE_OF_SIZE]: {
|
||||
type: 'range_of_size',
|
||||
id: RANGE_OF_SIZE,
|
||||
is_intermediate: true,
|
||||
// seed - must be connected manually
|
||||
// start: 0,
|
||||
size: iterations,
|
||||
@ -128,6 +138,7 @@ export const buildCanvasInpaintGraph = (
|
||||
[ITERATE]: {
|
||||
type: 'iterate',
|
||||
id: ITERATE,
|
||||
is_intermediate: true,
|
||||
},
|
||||
},
|
||||
edges: [
|
||||
|
@ -41,6 +41,8 @@ export const buildCanvasTextToImageGraph = (
|
||||
// The bounding box determines width and height, not the width and height params
|
||||
const { width, height } = state.canvas.boundingBoxDimensions;
|
||||
|
||||
const { shouldAutoSave } = state.canvas;
|
||||
|
||||
if (!model) {
|
||||
moduleLog.error('No model found in state');
|
||||
throw new Error('No model found in state');
|
||||
@ -66,16 +68,19 @@ export const buildCanvasTextToImageGraph = (
|
||||
[POSITIVE_CONDITIONING]: {
|
||||
type: 'compel',
|
||||
id: POSITIVE_CONDITIONING,
|
||||
is_intermediate: true,
|
||||
prompt: positivePrompt,
|
||||
},
|
||||
[NEGATIVE_CONDITIONING]: {
|
||||
type: 'compel',
|
||||
id: NEGATIVE_CONDITIONING,
|
||||
is_intermediate: true,
|
||||
prompt: negativePrompt,
|
||||
},
|
||||
[NOISE]: {
|
||||
type: 'noise',
|
||||
id: NOISE,
|
||||
is_intermediate: true,
|
||||
width,
|
||||
height,
|
||||
use_cpu,
|
||||
@ -83,6 +88,7 @@ export const buildCanvasTextToImageGraph = (
|
||||
[TEXT_TO_LATENTS]: {
|
||||
type: 't2l',
|
||||
id: TEXT_TO_LATENTS,
|
||||
is_intermediate: true,
|
||||
cfg_scale,
|
||||
scheduler,
|
||||
steps,
|
||||
@ -90,16 +96,19 @@ export const buildCanvasTextToImageGraph = (
|
||||
[MAIN_MODEL_LOADER]: {
|
||||
type: 'main_model_loader',
|
||||
id: MAIN_MODEL_LOADER,
|
||||
is_intermediate: true,
|
||||
model,
|
||||
},
|
||||
[CLIP_SKIP]: {
|
||||
type: 'clip_skip',
|
||||
id: CLIP_SKIP,
|
||||
is_intermediate: true,
|
||||
skipped_layers: clipSkip,
|
||||
},
|
||||
[LATENTS_TO_IMAGE]: {
|
||||
type: 'l2i',
|
||||
id: LATENTS_TO_IMAGE,
|
||||
is_intermediate: !shouldAutoSave,
|
||||
},
|
||||
},
|
||||
edges: [
|
||||
|
@ -5,12 +5,11 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import useImageUploader from 'common/hooks/useImageUploader';
|
||||
import { clearInitialImage } from 'features/parameters/store/generationSlice';
|
||||
import { useCallback } from 'react';
|
||||
import { FaUndo, FaUpload } from 'react-icons/fa';
|
||||
import { PostUploadAction } from 'services/api/thunks/image';
|
||||
import InitialImage from './InitialImage';
|
||||
import { PostUploadAction } from 'services/api/types';
|
||||
|
||||
const selector = createSelector(
|
||||
[stateSelector],
|
||||
@ -30,7 +29,6 @@ const postUploadAction: PostUploadAction = {
|
||||
const InitialImageDisplay = () => {
|
||||
const { isResetButtonDisabled } = useAppSelector(selector);
|
||||
const dispatch = useAppDispatch();
|
||||
const { openUploader } = useImageUploader();
|
||||
|
||||
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
|
||||
postUploadAction,
|
||||
@ -40,10 +38,6 @@ const InitialImageDisplay = () => {
|
||||
dispatch(clearInitialImage());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
openUploader();
|
||||
}, [openUploader]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
layerStyle={'first'}
|
||||
@ -85,7 +79,6 @@ const InitialImageDisplay = () => {
|
||||
tooltip={'Upload Initial Image'}
|
||||
aria-label={'Upload Initial Image'}
|
||||
icon={<FaUpload />}
|
||||
onClick={handleUpload}
|
||||
{...getUploadButtonProps()}
|
||||
/>
|
||||
<IAIIconButton
|
||||
|
@ -244,22 +244,7 @@ export const useRecallParameters = () => {
|
||||
[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
|
||||
*/
|
||||
const sendToImageToImage = useCallback(
|
||||
@ -330,7 +315,6 @@ export const useRecallParameters = () => {
|
||||
recallPositivePrompt,
|
||||
recallNegativePrompt,
|
||||
recallSeed,
|
||||
recallInitialImage,
|
||||
recallCfgScale,
|
||||
recallModel,
|
||||
recallScheduler,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { ImageDTO, MainModelField } from 'services/api/types';
|
||||
|
||||
export const initialImageSelected = createAction<ImageDTO | string | undefined>(
|
||||
export const initialImageSelected = createAction<ImageDTO | undefined>(
|
||||
'generation/initialImageSelected'
|
||||
);
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -11,7 +11,7 @@ import {
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { createSelector, current } from '@reduxjs/toolkit';
|
||||
import { VALID_LOG_LEVELS } from 'app/logging/useLogger';
|
||||
import { LOCALSTORAGE_KEYS, LOCALSTORAGE_PREFIX } from 'app/store/constants';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
@ -23,6 +23,7 @@ import {
|
||||
SystemState,
|
||||
consoleLogLevelChanged,
|
||||
setEnableImageDebugging,
|
||||
setIsNodesEnabled,
|
||||
setShouldConfirmOnDelete,
|
||||
setShouldDisplayGuides,
|
||||
shouldAntialiasProgressImageChanged,
|
||||
@ -48,6 +49,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogLevelName } from 'roarr';
|
||||
import SettingsSchedulers from './SettingsSchedulers';
|
||||
import SettingsClearIntermediates from './SettingsClearIntermediates';
|
||||
|
||||
const selector = createSelector(
|
||||
[systemSelector, uiSelector],
|
||||
@ -59,6 +61,7 @@ const selector = createSelector(
|
||||
consoleLogLevel,
|
||||
shouldLogToConsole,
|
||||
shouldAntialiasProgressImage,
|
||||
isNodesEnabled,
|
||||
} = system;
|
||||
|
||||
const {
|
||||
@ -79,6 +82,7 @@ const selector = createSelector(
|
||||
shouldLogToConsole,
|
||||
shouldAntialiasProgressImage,
|
||||
shouldShowAdvancedOptions,
|
||||
isNodesEnabled,
|
||||
};
|
||||
},
|
||||
{
|
||||
@ -91,6 +95,8 @@ type ConfigOptions = {
|
||||
shouldShowResetWebUiText: boolean;
|
||||
shouldShowBetaLayout: boolean;
|
||||
shouldShowAdvancedOptionsSettings: boolean;
|
||||
shouldShowClearIntermediates: boolean;
|
||||
shouldShowNodesToggle: boolean;
|
||||
};
|
||||
|
||||
type SettingsModalProps = {
|
||||
@ -109,6 +115,9 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
|
||||
const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true;
|
||||
const shouldShowAdvancedOptionsSettings =
|
||||
config?.shouldShowAdvancedOptionsSettings ?? true;
|
||||
const shouldShowClearIntermediates =
|
||||
config?.shouldShowClearIntermediates ?? true;
|
||||
const shouldShowNodesToggle = config?.shouldShowNodesToggle ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowDeveloperSettings) {
|
||||
@ -139,6 +148,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
|
||||
shouldLogToConsole,
|
||||
shouldAntialiasProgressImage,
|
||||
shouldShowAdvancedOptions,
|
||||
isNodesEnabled,
|
||||
} = useAppSelector(selector);
|
||||
|
||||
const handleClickResetWebUI = useCallback(() => {
|
||||
@ -169,6 +179,13 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleToggleNodes = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setIsNodesEnabled(e.target.checked));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{cloneElement(children, {
|
||||
@ -253,6 +270,13 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{shouldShowNodesToggle && (
|
||||
<IAISwitch
|
||||
label="Enable Nodes Editor (Experimental)"
|
||||
isChecked={isNodesEnabled}
|
||||
onChange={handleToggleNodes}
|
||||
/>
|
||||
)}
|
||||
</StyledFlex>
|
||||
|
||||
{shouldShowDeveloperSettings && (
|
||||
@ -280,6 +304,8 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
|
||||
</StyledFlex>
|
||||
)}
|
||||
|
||||
{shouldShowClearIntermediates && <SettingsClearIntermediates />}
|
||||
|
||||
<StyledFlex>
|
||||
<Heading size="sm">{t('settings.resetWebUI')}</Heading>
|
||||
<IAIButton colorScheme="error" onClick={handleClickResetWebUI}>
|
||||
@ -328,7 +354,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
|
||||
|
||||
export default SettingsModal;
|
||||
|
||||
const StyledFlex = (props: PropsWithChildren) => {
|
||||
export const StyledFlex = (props: PropsWithChildren) => {
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
|
@ -6,7 +6,6 @@ import { userInvoked } from 'app/store/actions';
|
||||
import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice';
|
||||
import { t } from 'i18next';
|
||||
import { LogLevelName } from 'roarr';
|
||||
import { imageUploaded } from 'services/api/thunks/image';
|
||||
import {
|
||||
isAnySessionRejected,
|
||||
sessionCanceled,
|
||||
@ -86,6 +85,7 @@ export interface SystemState {
|
||||
language: keyof typeof LANGUAGES;
|
||||
isUploading: boolean;
|
||||
boardIdToAddTo?: string;
|
||||
isNodesEnabled: boolean;
|
||||
}
|
||||
|
||||
export const initialSystemState: SystemState = {
|
||||
@ -118,6 +118,7 @@ export const initialSystemState: SystemState = {
|
||||
isPersisted: false,
|
||||
language: 'en',
|
||||
isUploading: false,
|
||||
isNodesEnabled: false,
|
||||
};
|
||||
|
||||
export const systemSlice = createSlice({
|
||||
@ -193,6 +194,9 @@ export const systemSlice = createSlice({
|
||||
progressImageSet(state, action: PayloadAction<ProgressImage | null>) {
|
||||
state.progressImage = action.payload;
|
||||
},
|
||||
setIsNodesEnabled(state, action: PayloadAction<boolean>) {
|
||||
state.isNodesEnabled = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
/**
|
||||
@ -360,27 +364,6 @@ export const systemSlice = createSlice({
|
||||
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 ***
|
||||
|
||||
/**
|
||||
@ -422,6 +405,7 @@ export const {
|
||||
shouldAntialiasProgressImageChanged,
|
||||
languageChanged,
|
||||
progressImageSet,
|
||||
setIsNodesEnabled,
|
||||
} = systemSlice.actions;
|
||||
|
||||
export default systemSlice.reducer;
|
||||
|
@ -37,6 +37,7 @@ import NodesTab from './tabs/Nodes/NodesTab';
|
||||
import ResizeHandle from './tabs/ResizeHandle';
|
||||
import TextToImageTab from './tabs/TextToImage/TextToImageTab';
|
||||
import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab';
|
||||
import { systemSelector } from '../../system/store/systemSelectors';
|
||||
|
||||
export interface InvokeTabInfo {
|
||||
id: InvokeTabName;
|
||||
@ -84,11 +85,20 @@ const tabs: InvokeTabInfo[] = [
|
||||
];
|
||||
|
||||
const enabledTabsSelector = createSelector(
|
||||
configSelector,
|
||||
(config) => {
|
||||
[configSelector, systemSelector],
|
||||
(config, system) => {
|
||||
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 },
|
||||
|
@ -17,14 +17,14 @@ type ModelListProps = {
|
||||
setSelectedModelId: (name: string | undefined) => void;
|
||||
};
|
||||
|
||||
type ModelFormat = 'all' | 'checkpoint' | 'diffusers';
|
||||
type ModelFormat = 'images' | 'checkpoint' | 'diffusers';
|
||||
|
||||
const ModelList = (props: ModelListProps) => {
|
||||
const { selectedModelId, setSelectedModelId } = props;
|
||||
const { t } = useTranslation();
|
||||
const [nameFilter, setNameFilter] = useState<string>('');
|
||||
const [modelFormatFilter, setModelFormatFilter] =
|
||||
useState<ModelFormat>('all');
|
||||
useState<ModelFormat>('images');
|
||||
|
||||
const { filteredDiffusersModels } = useGetMainModelsQuery(undefined, {
|
||||
selectFromResult: ({ data }) => ({
|
||||
@ -47,8 +47,8 @@ const ModelList = (props: ModelListProps) => {
|
||||
<Flex flexDirection="column" gap={4} paddingInlineEnd={4}>
|
||||
<ButtonGroup isAttached>
|
||||
<IAIButton
|
||||
onClick={() => setModelFormatFilter('all')}
|
||||
isChecked={modelFormatFilter === 'all'}
|
||||
onClick={() => setModelFormatFilter('images')}
|
||||
isChecked={modelFormatFilter === 'images'}
|
||||
size="sm"
|
||||
>
|
||||
{t('modelManager.allModels')}
|
||||
@ -75,7 +75,7 @@ const ModelList = (props: ModelListProps) => {
|
||||
labelPos="side"
|
||||
/>
|
||||
|
||||
{['all', 'diffusers'].includes(modelFormatFilter) &&
|
||||
{['images', 'diffusers'].includes(modelFormatFilter) &&
|
||||
filteredDiffusersModels.length > 0 && (
|
||||
<StyledModelContainer>
|
||||
<Flex sx={{ gap: 2, flexDir: 'column' }}>
|
||||
@ -93,7 +93,7 @@ const ModelList = (props: ModelListProps) => {
|
||||
</Flex>
|
||||
</StyledModelContainer>
|
||||
)}
|
||||
{['all', 'checkpoint'].includes(modelFormatFilter) &&
|
||||
{['images', 'checkpoint'].includes(modelFormatFilter) &&
|
||||
filteredCheckpointModels.length > 0 && (
|
||||
<StyledModelContainer>
|
||||
<Flex sx={{ gap: 2, flexDir: 'column' }}>
|
||||
|
@ -1,22 +1,28 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { FaUpload } from 'react-icons/fa';
|
||||
|
||||
export default function UnifiedCanvasFileUploader() {
|
||||
const isStaging = useAppSelector(isStagingSelector);
|
||||
const { openUploader } = useImageUploader();
|
||||
|
||||
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
|
||||
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<IAIIconButton
|
||||
aria-label={t('common.upload')}
|
||||
tooltip={t('common.upload')}
|
||||
icon={<FaUpload />}
|
||||
onClick={openUploader}
|
||||
isDisabled={isStaging}
|
||||
/>
|
||||
<>
|
||||
<IAIIconButton
|
||||
aria-label={t('common.upload')}
|
||||
tooltip={t('common.upload')}
|
||||
icon={<FaUpload />}
|
||||
isDisabled={isStaging}
|
||||
{...getUploadButtonProps()}
|
||||
/>
|
||||
<input {...getUploadInputProps()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user