Compare commits

...

20 Commits

Author SHA1 Message Date
704cfd8ff5 wip attempt to rewrite to use no adapter 2023-07-13 15:32:02 +10:00
2990fa23fe wip 2023-07-13 15:31:44 +10:00
58cb5fefd0 feat(ui): wip again sry lol 2023-07-13 15:31:15 +10:00
2ef5919475 feat(ui): wip again sry 2023-07-13 15:31:15 +10:00
7f07528b08 feat(ui): wip sry 2023-07-13 15:31:15 +10:00
a2f944a657 feat(db,api): changes to db and api to support batch operations
will squash and describe all changes later sry
2023-07-13 15:30:54 +10:00
0317cc158a feat(ui): organise gallery slice 2023-07-13 15:30:54 +10:00
8648332b4f chore(ui): regen types 2023-07-13 15:30:54 +10:00
c2aee42fa3 fix(ui): fix board changes invalidating image tags
Caused a bazillion extraneous network requests
2023-07-13 15:30:54 +10:00
a77f6b0c18 feat(ui): hide noisy rtk query redux actions 2023-07-13 15:30:54 +10:00
8771e32ed2 fix(ui): fixes deleting image in use @ nodes resets node templates 2023-07-13 15:30:54 +10:00
5e1ed63076 fix(ui): fix IAIDraggable/IAIDroppable absolute positioning 2023-07-13 15:30:54 +10:00
cad358dc9a feat(db,api): list images board_id="none" gets images without a board 2023-07-13 15:30:54 +10:00
8501ca0843 feat(ui): improve IAIDndImage performance
`dnd-kit` has a problem where, when drag events start and stop, every item that uses the library rerenders. This occurs due to its use of context.

The dnd library needs to listen for pointer events to handle dragging. Because our images are both clickable (selectable) and draggable, every time you click an image, the dnd necessarily sees this event, its context updates and all other dnd-enabled components rerender.

With a lot of images in gallery and/or batch manager, this leads to some jank.

There is an open PR to address this: https://github.com/clauderic/dnd-kit/pull/1096

But unfortunately, the maintainer hasn't accepted any changes for a few months, and its not clear if this will be merged any time soon :/

This change simply extracts the draggable and droppable logic out of IAIDndImage into their own minimal components. Now only these need to rerender when the dnd context is changed. The rerenders are far less impactful now.

Hopefully the linked PR is accepted and we get even more efficient dnd functionality in the future.

Also changed dnd activation constraint to distance (currently 10px) instead of delay and updated the stacking context of IAIDndImage subcomponents so that the reset and upload buttons still work.
2023-07-13 15:30:54 +10:00
560a59123a feat(ui): improve IAIDndImage performance
`dnd-kit` has a problem where, when drag events start and stop, every item that uses the library rerenders. This occurs due to its use of context.

The dnd library needs to listen for pointer events to handle dragging. Because our images are both clickable (selectable) and draggable, every time you click an image, the dnd necessarily sees this event, its context updates and all other dnd-enabled components rerender.

With a lot of images in gallery and/or batch manager, this leads to some jank.

There is an open PR to address this: https://github.com/clauderic/dnd-kit/pull/1096

But unfortunately, the maintainer hasn't accepted any changes for a few months, and its not clear if this will be merged any time soon :/

This change simply extracts the draggable and droppable logic out of IAIDndImage into their own minimal components. Now only these need to rerender when the dnd context is changed. The rerenders are far less impactful now.

Hopefully the linked PR is accepted and we get even more efficient dnd functionality in the future.
2023-07-13 15:30:54 +10:00
62b700b908 feat(ui): fix listeners for adding selection to board via dnd 2023-07-13 15:30:54 +10:00
9aedf84ac2 fix: fix rebase conflicts 2023-07-13 15:30:54 +10:00
a08179bf34 feat(api,ui): wip batch image actions 2023-07-13 15:30:54 +10:00
0b9aaf1b0b feat(ui): wip multi image ops 2023-07-13 15:30:54 +10:00
da98f281ee feat(api): implement delete many images & add many images to board
Add new routes & high-level service methods for each operation.
2023-07-13 15:30:54 +10:00
79 changed files with 3062 additions and 1563 deletions

View File

@ -1,9 +1,9 @@
from fastapi import Body, HTTPException, Path, Query
from fastapi import Body, HTTPException, Path
from fastapi.routing import APIRouter
from invokeai.app.services.board_record_storage import BoardRecord, BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO
from invokeai.app.services.models.image_record import ImageDTO
from invokeai.app.models.image import (AddManyImagesToBoardResult,
GetAllBoardImagesForBoardResult,
RemoveManyImagesFromBoardResult)
from ..dependencies import ApiDependencies
@ -11,7 +11,7 @@ board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
@board_images_router.post(
"/",
"/{board_id}",
operation_id="create_board_image",
responses={
201: {"description": "The image was added to a board successfully"},
@ -19,16 +19,19 @@ board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
status_code=201,
)
async def create_board_image(
board_id: str = Body(description="The id of the board to add to"),
board_id: str = Path(description="The id of the board to add to"),
image_name: str = Body(description="The name of the image to add"),
):
"""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",
@ -38,32 +41,78 @@ async def create_board_image(
status_code=201,
)
async def remove_board_image(
board_id: str = Body(description="The id of the board"),
image_name: str = Body(description="The name of the image to remove"),
image_name: str = Body(
description="The name of the image to remove from its board"
),
):
"""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(
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],
operation_id="get_all_board_images_for_board",
response_model=GetAllBoardImagesForBoardResult,
)
async def list_board_images(
async def get_all_board_images_for_board(
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"""
) -> GetAllBoardImagesForBoardResult:
"""Gets all image names for a board"""
results = ApiDependencies.invoker.services.board_images.get_images_for_board(
board_id,
result = (
ApiDependencies.invoker.services.board_images.get_all_board_images_for_board(
board_id,
)
)
return result
@board_images_router.patch(
"/{board_id}/images",
operation_id="create_multiple_board_images",
responses={
201: {"description": "The images were added to the board successfully"},
},
status_code=201,
)
async def create_multiple_board_images(
board_id: str = Path(description="The id of the board"),
image_names: list[str] = Body(
description="The names of the images to add to the board"
),
) -> AddManyImagesToBoardResult:
"""Add many images to a board"""
results = ApiDependencies.invoker.services.board_images.add_many_images_to_board(
board_id, image_names
)
return results
@board_images_router.post(
"/images",
operation_id="delete_multiple_board_images",
responses={
201: {"description": "The images were removed from their boards successfully"},
},
status_code=201,
)
async def delete_multiple_board_images(
image_names: list[str] = Body(
description="The names of the images to remove from their boards, if they have one"
),
) -> RemoveManyImagesFromBoardResult:
"""Remove many images from their boards, if they have one"""
results = (
ApiDependencies.invoker.services.board_images.remove_many_images_from_board(
image_names
)
)
return results

View File

@ -1,11 +1,13 @@
from typing import Optional, Union
from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
from invokeai.app.models.image import DeleteManyImagesResult
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"])
@ -69,25 +71,26 @@ 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=DeleteManyImagesResult)
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:
) -> DeleteManyImagesResult:
"""Deletes a board"""
try:
if include_images is True:
ApiDependencies.invoker.services.images.delete_images_on_board(
result = ApiDependencies.invoker.services.images.delete_images_on_board(
board_id=board_id
)
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
else:
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
result = DeleteManyImagesResult(deleted_images=[])
return result
except Exception as e:
# TODO: Does this need any exception handling at all?
pass
raise HTTPException(status_code=500, detail="Failed to delete images on board")
@boards_router.get(

View File

@ -1,20 +1,19 @@
import io
from typing import Optional
from fastapi import Body, HTTPException, Path, Query, Request, Response, UploadFile
from fastapi.routing import APIRouter
from fastapi import (Body, HTTPException, Path, Query, Request, Response,
UploadFile)
from fastapi.responses import FileResponse
from fastapi.routing import APIRouter
from PIL import Image
from invokeai.app.models.image import (
ImageCategory,
ResourceOrigin,
)
from invokeai.app.models.image import (DeleteManyImagesResult, ImageCategory,
ResourceOrigin)
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.image_record import (
ImageDTO,
ImageRecordChanges,
ImageUrlsDTO,
)
from invokeai.app.services.item_storage import PaginatedResults
from invokeai.app.services.models.image_record import (GetImagesByNamesResult,
ImageDTO,
ImageRecordChanges,
ImageUrlsDTO)
from ..dependencies import ApiDependencies
@ -22,7 +21,7 @@ images_router = APIRouter(prefix="/v1/images", tags=["images"])
@images_router.post(
"/",
"/upload",
operation_id="upload_image",
responses={
201: {"description": "The image was uploaded successfully"},
@ -103,14 +102,14 @@ async def update_image(
@images_router.get(
"/{image_name}/metadata",
operation_id="get_image_metadata",
"/{image_name}",
operation_id="get_image",
response_model=ImageDTO,
)
async def get_image_metadata(
async def get_image_dto(
image_name: str = Path(description="The name of image to get"),
) -> ImageDTO:
"""Gets an image's metadata"""
"""Gets an image's DTO"""
try:
return ApiDependencies.invoker.services.images.get_dto(image_name)
@ -119,8 +118,8 @@ async def get_image_metadata(
@images_router.get(
"/{image_name}",
operation_id="get_image_full",
"/{image_name}/full_size",
operation_id="get_image_full_size",
response_class=Response,
responses={
200: {
@ -130,7 +129,7 @@ async def get_image_metadata(
404: {"description": "Image not found"},
},
)
async def get_image_full(
async def get_image_full_size(
image_name: str = Path(description="The name of full-resolution image file to get"),
) -> FileResponse:
"""Gets a full-resolution image file"""
@ -208,10 +207,10 @@ async def get_image_urls(
@images_router.get(
"/",
operation_id="list_images_with_metadata",
operation_id="get_many_images",
response_model=OffsetPaginatedResults[ImageDTO],
)
async def list_images_with_metadata(
async def get_many_images(
image_origin: Optional[ResourceOrigin] = Query(
default=None, description="The origin of images to list"
),
@ -222,7 +221,8 @@ async def list_images_with_metadata(
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, provide 'none' for 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"),
@ -239,3 +239,36 @@ async def list_images_with_metadata(
)
return image_dtos
@images_router.post(
"/",
operation_id="get_images_by_names",
response_model=GetImagesByNamesResult,
)
async def get_images_by_names(
image_names: list[str] = Body(description="The names of the images to get"),
) -> GetImagesByNamesResult:
"""Gets a list of images"""
result = ApiDependencies.invoker.services.images.get_images_by_names(
image_names
)
return result
@images_router.post(
"/delete",
operation_id="delete_many_images",
response_model=DeleteManyImagesResult,
)
async def delete_many_images(
image_names: list[str] = Body(description="The names of the images to delete"),
) -> DeleteManyImagesResult:
"""Deletes many images"""
try:
return ApiDependencies.invoker.services.images.delete_many(image_names)
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to delete images")

View File

@ -1,5 +1,6 @@
from enum import Enum
from typing import Optional, Tuple
from pydantic import BaseModel, Field
from invokeai.app.util.metaenum import MetaEnum
@ -88,3 +89,41 @@ class ProgressImage(BaseModel):
width: int = Field(description="The effective width of the image in pixels")
height: int = Field(description="The effective height of the image in pixels")
dataURL: str = Field(description="The image data as a b64 data URL")
class DeleteManyImagesResult(BaseModel):
"""The result of a delete many image operation."""
deleted_images: list[str] = Field(
description="The names of the images that were successfully deleted"
)
class AddManyImagesToBoardResult(BaseModel):
"""The result of an add many images to board operation."""
board_id: str = Field(description="The id of the board the images were added to")
added_images: list[str] = Field(
description="The names of the images that were successfully added"
)
total: int = Field(description="The total number of images on the board")
class RemoveManyImagesFromBoardResult(BaseModel):
"""The result of a remove many images from their boards operation."""
removed_images: list[str] = Field(
description="The names of the images that were successfully removed from their boards"
)
class GetAllBoardImagesForBoardResult(BaseModel):
"""The result of a get all image names for board operation."""
board_id: str = Field(
description="The id of the board with which the images are associated"
)
image_names: list[str] = Field(
description="The names of the images that are associated with the board"
)

View File

@ -1,13 +1,12 @@
from abc import ABC, abstractmethod
import sqlite3
import threading
from abc import ABC, abstractmethod
from typing import Optional, cast
from invokeai.app.models.image import GetAllBoardImagesForBoardResult
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.image_record import (
ImageRecord,
deserialize_image_record,
)
ImageRecord, deserialize_image_record)
class BoardImageRecordStorageBase(ABC):
@ -25,18 +24,17 @@ class BoardImageRecordStorageBase(ABC):
@abstractmethod
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
"""Removes an image from a board."""
pass
@abstractmethod
def get_images_for_board(
def get_all_board_images_for_board(
self,
board_id: str,
) -> OffsetPaginatedResults[ImageRecord]:
"""Gets images for a board."""
) -> GetAllBoardImagesForBoardResult:
"""Gets all image names for a board."""
pass
@abstractmethod
@ -154,7 +152,6 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
try:
@ -162,9 +159,9 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
self._cursor.execute(
"""--sql
DELETE FROM board_images
WHERE board_id = ? AND image_name = ?;
WHERE image_name = ?;
""",
(board_id, image_name),
(image_name,),
)
self._conn.commit()
except sqlite3.Error as e:
@ -173,42 +170,32 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
finally:
self._lock.release()
def get_images_for_board(
def get_all_board_images_for_board(
self,
board_id: str,
offset: int = 0,
limit: int = 10,
) -> OffsetPaginatedResults[ImageRecord]:
# TODO: this isn't paginated yet?
) -> GetAllBoardImagesForBoardResult:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT images.*
SELECT image_name
FROM board_images
INNER JOIN images ON board_images.image_name = images.image_name
WHERE board_images.board_id = ?
ORDER BY board_images.updated_at DESC;
WHERE board_id = ?
ORDER BY updated_at DESC;
""",
(board_id,),
)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
images = list(map(lambda r: deserialize_image_record(dict(r)), result))
self._cursor.execute(
"""--sql
SELECT COUNT(*) FROM images WHERE 1=1;
"""
)
count = cast(int, self._cursor.fetchone()[0])
result = cast(list[sqlite3.Row], self._cursor.fetchall())
image_names = list(map(lambda r: r[0], result))
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
return OffsetPaginatedResults(
items=images, offset=offset, limit=limit, total=count
return GetAllBoardImagesForBoardResult(
board_id=board_id, image_names=image_names
)
def get_board_for_image(

View File

@ -1,18 +1,19 @@
from abc import ABC, abstractmethod
from logging import Logger
from typing import List, Union, Optional
from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase
from invokeai.app.services.board_record_storage import (
BoardRecord,
BoardRecordStorageBase,
)
from typing import List, Optional, Union
from invokeai.app.services.image_record_storage import (
ImageRecordStorageBase,
OffsetPaginatedResults,
)
from invokeai.app.models.image import (AddManyImagesToBoardResult,
GetAllBoardImagesForBoardResult,
RemoveManyImagesFromBoardResult)
from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase
from invokeai.app.services.board_record_storage import (BoardRecord,
BoardRecordStorageBase)
from invokeai.app.services.image_record_storage import (ImageRecordStorageBase,
OffsetPaginatedResults)
from invokeai.app.services.models.board_record import BoardDTO
from invokeai.app.services.models.image_record import ImageDTO, image_record_to_dto
from invokeai.app.services.models.image_record import (ImageDTO,
image_record_to_dto)
from invokeai.app.services.urls import UrlServiceBase
@ -25,24 +26,40 @@ class BoardImagesServiceABC(ABC):
board_id: str,
image_name: str,
) -> None:
"""Adds an image to a board."""
"""Adds an image to a board. If the image is on a different board, it is removed from that board."""
pass
@abstractmethod
def add_many_images_to_board(
self,
board_id: str,
image_names: list[str],
) -> AddManyImagesToBoardResult:
"""Adds many images to a board. If an image is on a different board, it is removed from that board."""
pass
@abstractmethod
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
"""Removes an image from a board."""
"""Removes an image from its board."""
pass
@abstractmethod
def get_images_for_board(
def remove_many_images_from_board(
self,
image_names: list[str],
) -> RemoveManyImagesFromBoardResult:
"""Removes many images from their board, if they had one."""
pass
@abstractmethod
def get_all_board_images_for_board(
self,
board_id: str,
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets images for a board."""
) -> GetAllBoardImagesForBoardResult:
"""Gets all image names for a board."""
pass
@abstractmethod
@ -91,37 +108,59 @@ class BoardImagesService(BoardImagesServiceABC):
) -> None:
self._services.board_image_records.add_image_to_board(board_id, image_name)
def add_many_images_to_board(
self,
board_id: str,
image_names: list[str],
) -> AddManyImagesToBoardResult:
added_images: list[str] = []
for image_name in image_names:
try:
self._services.board_image_records.add_image_to_board(
board_id, image_name
)
added_images.append(image_name)
except Exception as e:
self._services.logger.exception(e)
total = self._services.board_image_records.get_image_count_for_board(board_id)
return AddManyImagesToBoardResult(
board_id=board_id, added_images=added_images, total=total
)
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
self._services.board_image_records.remove_image_from_board(board_id, image_name)
self._services.board_image_records.remove_image_from_board(image_name)
def get_images_for_board(
def remove_many_images_from_board(
self,
image_names: list[str],
) -> RemoveManyImagesFromBoardResult:
removed_images: list[str] = []
for image_name in image_names:
try:
self._services.board_image_records.remove_image_from_board(image_name)
removed_images.append(image_name)
except Exception as e:
self._services.logger.exception(e)
return RemoveManyImagesFromBoardResult(
removed_images=removed_images,
)
def get_all_board_images_for_board(
self,
board_id: str,
) -> OffsetPaginatedResults[ImageDTO]:
image_records = self._services.board_image_records.get_images_for_board(
) -> GetAllBoardImagesForBoardResult:
result = self._services.board_image_records.get_all_board_images_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,
)
return result
def get_board_for_image(
self,
@ -136,7 +175,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,
)

View File

@ -80,6 +80,11 @@ class ImageRecordStorageBase(ABC):
"""Gets a page of image records."""
pass
@abstractmethod
def get_by_names(self, image_names: list[str]) -> list[ImageRecord]:
"""Gets a list of image records by name."""
pass
# TODO: The database has a nullable `deleted_at` column, currently unused.
# Should we implement soft deletes? Would need coordination with ImageFileStorage.
@abstractmethod
@ -329,11 +334,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
query_params.append(is_intermediate)
if board_id is not None:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
else:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
query_pagination = """--sql
ORDER BY images.created_at DESC LIMIT ? OFFSET ?
@ -365,6 +374,30 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
items=images, offset=offset, limit=limit, total=count
)
def get_by_names(self, image_names: list[str]) -> list[ImageRecord]:
try:
placeholders = ",".join("?" for _ in image_names)
self._lock.acquire()
# Construct the SQLite query with the placeholders
query = f"""--sql
SELECT * FROM images
WHERE image_name IN ({placeholders})
"""
# Execute the query with the list of IDs as parameters
self._cursor.execute(query, image_names)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
images = list(map(lambda r: deserialize_image_record(dict(r)), result))
return images
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
def delete(self, image_name: str) -> None:
try:
self._lock.acquire()
@ -465,9 +498,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
finally:
self._lock.release()
def get_most_recent_image_for_board(
self, board_id: str
) -> Optional[ImageRecord]:
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
try:
self._lock.acquire()
self._cursor.execute(

View File

@ -1,37 +1,27 @@
from abc import ABC, abstractmethod
from logging import Logger
from typing import Optional, TYPE_CHECKING, Union
from typing import TYPE_CHECKING, Optional
from PIL.Image import Image as PILImageType
from invokeai.app.models.image import (
ImageCategory,
ResourceOrigin,
InvalidImageCategoryException,
InvalidOriginException,
)
from invokeai.app.models.image import (DeleteManyImagesResult, ImageCategory,
InvalidImageCategoryException,
InvalidOriginException, ResourceOrigin)
from invokeai.app.models.metadata import ImageMetadata
from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase
from invokeai.app.services.image_record_storage import (
ImageRecordDeleteException,
ImageRecordNotFoundException,
ImageRecordSaveException,
ImageRecordStorageBase,
OffsetPaginatedResults,
)
from invokeai.app.services.models.image_record import (
ImageRecord,
ImageDTO,
ImageRecordChanges,
image_record_to_dto,
)
from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase
from invokeai.app.services.image_file_storage import (
ImageFileDeleteException,
ImageFileNotFoundException,
ImageFileSaveException,
ImageFileStorageBase,
)
from invokeai.app.services.item_storage import ItemStorageABC, PaginatedResults
ImageFileDeleteException, ImageFileNotFoundException,
ImageFileSaveException, ImageFileStorageBase)
from invokeai.app.services.image_record_storage import (
ImageRecordDeleteException, ImageRecordNotFoundException,
ImageRecordSaveException, ImageRecordStorageBase, OffsetPaginatedResults)
from invokeai.app.services.item_storage import ItemStorageABC
from invokeai.app.services.metadata import MetadataServiceBase
from invokeai.app.services.models.image_record import (GetImagesByNamesResult,
ImageDTO, ImageRecord,
ImageRecordChanges,
image_record_to_dto)
from invokeai.app.services.resource_name import NameServiceBase
from invokeai.app.services.urls import UrlServiceBase
@ -107,13 +97,23 @@ class ImageServiceABC(ABC):
"""Gets a paginated list of image DTOs."""
pass
@abstractmethod
def get_images_by_names(self, image_names: list[str]) -> GetImagesByNamesResult:
"""Gets image DTOs by list of names."""
pass
@abstractmethod
def delete(self, image_name: str):
"""Deletes an image."""
pass
@abstractmethod
def delete_images_on_board(self, board_id: str):
def delete_many(self, image_names: list[str]) -> DeleteManyImagesResult:
"""Deletes many images."""
pass
@abstractmethod
def delete_images_on_board(self, board_id: str) -> DeleteManyImagesResult:
"""Deletes all images on a board."""
pass
@ -332,6 +332,28 @@ class ImageService(ImageServiceABC):
self._services.logger.error("Problem getting paginated image DTOs")
raise e
def get_images_by_names(self, image_names: list[str]) -> GetImagesByNamesResult:
try:
image_records = self._services.image_records.get_by_names(image_names)
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),
self._services.board_image_records.get_board_for_image(
r.image_name
),
),
image_records,
)
)
return GetImagesByNamesResult(image_dtos=image_dtos)
except Exception as e:
self._services.logger.error("Problem getting image DTOs from names")
raise e
def delete(self, image_name: str):
try:
self._services.image_files.delete(image_name)
@ -346,18 +368,36 @@ class ImageService(ImageServiceABC):
self._services.logger.error("Problem deleting image record and file")
raise e
def delete_images_on_board(self, board_id: str):
def delete_many(self, image_names: list[str]) -> DeleteManyImagesResult:
deleted_images: list[str] = []
for image_name in image_names:
try:
self._services.image_files.delete(image_name)
self._services.image_records.delete(image_name)
deleted_images.append(image_name)
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image record")
deleted_images.append(image_name)
except ImageFileDeleteException:
self._services.logger.error(f"Failed to delete image file")
deleted_images.append(image_name)
except Exception as e:
self._services.logger.error("Problem deleting image record and file")
deleted_images.append(image_name)
return DeleteManyImagesResult(deleted_images=deleted_images)
def delete_images_on_board(self, board_id: str) -> DeleteManyImagesResult:
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,
board_images = (
self._services.board_image_records.get_all_board_images_for_board(
board_id
)
)
image_name_list = board_images.image_names
for image_name in image_name_list:
self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list)
return DeleteManyImagesResult(deleted_images=board_images.image_names)
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records")
raise

View File

@ -1,6 +1,8 @@
import datetime
from typing import Optional, Union
from pydantic import BaseModel, Extra, Field, StrictBool, StrictStr
from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.models.metadata import ImageMetadata
from invokeai.app.util.misc import get_iso_timestamp
@ -95,8 +97,19 @@ class ImageDTO(ImageRecord, ImageUrlsDTO):
pass
class GetImagesByNamesResult(BaseModel):
"""The result of a get all image names for board operation."""
image_dtos: list[ImageDTO] = Field(
description="The names of the images that are associated with the board"
)
def image_record_to_dto(
image_record: ImageRecord, image_url: str, thumbnail_url: str, board_id: Optional[str]
image_record: ImageRecord,
image_url: str,
thumbnail_url: str,
board_id: Optional[str],
) -> ImageDTO:
"""Converts an image record to an image DTO."""
return ImageDTO(

View File

@ -22,4 +22,4 @@ class LocalUrlService(UrlServiceBase):
if thumbnail:
return f"{self._base_url}/images/{image_basename}/thumbnail"
return f"{self._base_url}/images/{image_basename}"
return f"{self._base_url}/images/{image_basename}/full_size"

View File

@ -128,13 +128,13 @@
"@types/react-redux": "^7.1.25",
"@types/react-transition-group": "^4.4.6",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"axios": "^1.4.0",
"babel-plugin-transform-imports": "^2.0.0",
"concurrently": "^8.2.0",
"eslint": "^8.43.0",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
@ -151,6 +151,8 @@
"rollup-plugin-visualizer": "^5.9.2",
"terser": "^5.18.1",
"ts-toolbelt": "^9.6.0",
"typescript": "^5.1.6",
"typescript-eslint": "^0.0.1-alpha.0",
"vite": "^4.3.9",
"vite-plugin-css-injected-by-js": "^3.1.1",
"vite-plugin-dts": "^2.3.0",

View File

@ -118,7 +118,7 @@
"pinGallery": "Pin Gallery",
"allImagesLoaded": "All Images Loaded",
"loadMore": "Load More",
"noImagesInGallery": "No Images In Gallery",
"noImagesInGallery": "No Images to Display",
"deleteImage": "Delete Image",
"deleteImageBin": "Deleted images will be sent to your operating system's Bin.",
"deleteImagePermanent": "Deleted images cannot be restored.",

View File

@ -82,7 +82,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
);
}
if (props.dragData.payloadType === 'BATCH_SELECTION') {
if (props.dragData.payloadType === 'IMAGE_NAMES') {
return (
<Flex
sx={{
@ -95,26 +95,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
...STYLES,
}}
>
<Heading>{batchSelectionCount}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);
}
if (props.dragData.payloadType === 'GALLERY_SELECTION') {
return (
<Flex
sx={{
cursor: 'none',
userSelect: 'none',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
flexDir: 'column',
...STYLES,
}}
>
<Heading>{gallerySelectionCount}</Heading>
<Heading>{props.dragData.payload.image_names.length}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);

View File

@ -6,18 +6,18 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
import { useAppDispatch } from 'app/store/storeHooks';
import { AnimatePresence, motion } from 'framer-motion';
import { PropsWithChildren, memo, useCallback, useState } from 'react';
import DragPreview from './DragPreview';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { AnimatePresence, motion } from 'framer-motion';
import {
DndContext,
DragEndEvent,
DragStartEvent,
TypesafeDraggableData,
} from './typesafeDnd';
import { useAppDispatch } from 'app/store/storeHooks';
import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
type ImageDndContextProps = PropsWithChildren;
@ -42,18 +42,18 @@ const ImageDndContext = (props: ImageDndContextProps) => {
if (!activeData || !overData) {
return;
}
dispatch(imageDropped({ overData, activeData }));
dispatch(dndDropped({ overData, activeData }));
setActiveDragData(null);
},
[dispatch]
);
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 150, tolerance: 5 },
activationConstraint: { distance: 10 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 5 },
activationConstraint: { distance: 10 },
});
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos

View File

@ -77,18 +77,14 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO };
};
export type GallerySelectionDraggableData = BaseDragData & {
payloadType: 'GALLERY_SELECTION';
};
export type BatchSelectionDraggableData = BaseDragData & {
payloadType: 'BATCH_SELECTION';
export type ImageNamesDraggableData = BaseDragData & {
payloadType: 'IMAGE_NAMES';
payload: { image_names: string[] };
};
export type TypesafeDraggableData =
| ImageDraggableData
| GallerySelectionDraggableData
| BatchSelectionDraggableData;
| ImageNamesDraggableData;
interface UseDroppableTypesafeArguments
extends Omit<UseDroppableArguments, 'data'> {
@ -159,13 +155,11 @@ export const isValidDrop = (
case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_MULTI_NODES_IMAGE':
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION';
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION';
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD':
return (
payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION' || 'BATCH_SELECTION'
);
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
default:
return false;
}

View File

@ -1,7 +1,7 @@
import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { useAddBoardImageMutation } from 'services/api/endpoints/boardImages';
import { ImageDTO } from 'services/api/types';
import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages';
export type ImageUsage = {
isInitialImage: boolean;
@ -41,7 +41,7 @@ export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure();
const [addImageToBoard, result] = useAddImageToBoardMutation();
const [addImageToBoard, result] = useAddBoardImageMutation();
// Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => {

View File

@ -1,3 +1,4 @@
import { initialBatchState } from 'features/batch/store/batchSlice';
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlNetState } from 'features/controlNet/store/controlNetSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
@ -17,6 +18,7 @@ const initialStates: {
} = {
canvas: initialCanvasState,
gallery: initialGalleryState,
batch: initialBatchState,
generation: initialGenerationState,
lightbox: initialLightboxState,
nodes: initialNodesState,

View File

@ -1,4 +1,8 @@
/**
* This is a list of actions that should be excluded in the Redux DevTools.
*/
export const actionsDenylist = [
// very spammy canvas actions
'canvas/setCursorPosition',
'canvas/setStageCoordinates',
'canvas/setStageScale',
@ -7,7 +11,11 @@ export const actionsDenylist = [
'canvas/setBoundingBoxDimensions',
'canvas/setIsDrawing',
'canvas/addPointToCurrentLine',
// bazillions during generation
'socket/socketGeneratorProgress',
'socket/appSocketGeneratorProgress',
// every time user presses shift
'hotkeys/shiftKeyPressed',
// this happens after every state change
'@@REMEMBER_PERSISTED',
];

View File

@ -7,6 +7,8 @@ import {
} from '@reduxjs/toolkit';
import type { AppDispatch, RootState } from '../../store';
import { addBoardApiListeners } from './listeners/addBoardApiListeners';
import { addAddBoardToBatchListener } from './listeners/addBoardToBatch';
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
import { addAppStartedListener } from './listeners/appStarted';
import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
@ -18,9 +20,9 @@ import { addCanvasSavedToGalleryListener } from './listeners/canvasSavedToGaller
import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess';
import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed';
import {
addImageAddedToBoardFulfilledListener,
addImageAddedToBoardRejectedListener,
} from './listeners/imageAddedToBoard';
addImageDTOReceivedFulfilledListener,
addImageDTOReceivedRejectedListener,
} from './listeners/imageDTOReceived';
import {
addImageDeletedFulfilledListener,
addImageDeletedPendingListener,
@ -28,14 +30,6 @@ import {
addRequestedImageDeletionListener,
} from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped';
import {
addImageMetadataReceivedFulfilledListener,
addImageMetadataReceivedRejectedListener,
} from './listeners/imageMetadataReceived';
import {
addImageRemovedFromBoardFulfilledListener,
addImageRemovedFromBoardRejectedListener,
} from './listeners/imageRemovedFromBoard';
import { addImageToDeleteSelectedListener } from './listeners/imageToDeleteSelected';
import {
addImageUpdatedFulfilledListener,
@ -45,18 +39,11 @@ import {
addImageUploadedFulfilledListener,
addImageUploadedRejectedListener,
} from './listeners/imageUploaded';
import {
addImageUrlsReceivedFulfilledListener,
addImageUrlsReceivedRejectedListener,
} from './listeners/imageUrlsReceived';
import { addImagesLoadedListener } from './listeners/imagesLoaded';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addModelSelectedListener } from './listeners/modelSelected';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import {
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import { addSelectionAddedToBatchListener } from './listeners/selectionAddedToBatch';
import { addReceivedPageOfImagesListener } from './listeners/receivedPageOfImages';
import {
addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener,
@ -132,12 +119,8 @@ addRequestedBoardImageDeletionListener();
addImageToDeleteSelectedListener();
// Image metadata
addImageMetadataReceivedFulfilledListener();
addImageMetadataReceivedRejectedListener();
// Image URLs
addImageUrlsReceivedFulfilledListener();
addImageUrlsReceivedRejectedListener();
addImageDTOReceivedFulfilledListener();
addImageDTOReceivedRejectedListener();
// User Invoked
addUserInvokedCanvasListener();
@ -193,8 +176,8 @@ addSessionCanceledFulfilledListener();
addSessionCanceledRejectedListener();
// Fetching images
addReceivedPageOfImagesFulfilledListener();
addReceivedPageOfImagesRejectedListener();
addReceivedPageOfImagesListener();
addImagesLoadedListener();
// ControlNet
addControlNetImageProcessedListener();
@ -204,17 +187,15 @@ addControlNetAutoProcessListener();
// addUpdateImageUrlsOnConnectListener();
// Boards
addImageAddedToBoardFulfilledListener();
addImageAddedToBoardRejectedListener();
addImageRemovedFromBoardFulfilledListener();
addImageRemovedFromBoardRejectedListener();
addBoardApiListeners();
addBoardIdSelectedListener();
// Node schemas
addReceivedOpenAPISchemaListener();
// Batches
addSelectionAddedToBatchListener();
// addSelectionAddedToBatchListener();
addAddBoardToBatchListener();
// DND
addImageDroppedListener();

View File

@ -0,0 +1,105 @@
import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
export const addBoardApiListeners = () => {
// add image to board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.addBoardImage.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
},
});
// add image to board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.addBoardImage.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
// remove image from board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.deleteBoardImage.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { image_name } = action.meta.arg.originalArgs;
moduleLog.debug({ data: { image_name } }, 'Image removed from board');
},
});
// remove image from board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.deleteBoardImage.matchRejected,
effect: (action, { getState, dispatch }) => {
const image_name = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { image_name } },
'Problem removing image from board'
);
},
});
// many images added to board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.addManyBoardImages.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_names } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_names } },
'Images added to board'
);
},
});
// many images added to board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.addManyBoardImages.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_names } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_names } },
'Problem adding many images to board'
);
},
});
// remove many images from board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.deleteManyBoardImages.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { image_names } = action.meta.arg.originalArgs;
moduleLog.debug({ data: { image_names } }, 'Images removed from board');
},
});
// remove many images from board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.deleteManyBoardImages.matchRejected,
effect: (action, { getState, dispatch }) => {
const image_names = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { image_names } },
'Problem removing many images from board'
);
},
});
};

View File

@ -0,0 +1,31 @@
import { createAction } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger';
import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
import { boardImageNamesReceived } from 'services/api/thunks/boardImages';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'batch' });
export const boardAddedToBatch = createAction<{ board_id: string }>(
'batch/boardAddedToBatch'
);
export const addAddBoardToBatchListener = () => {
startAppListening({
actionCreator: boardAddedToBatch,
effect: async (action, { dispatch, getState, take }) => {
const { board_id } = action.payload;
const { requestId } = dispatch(boardImageNamesReceived({ board_id }));
const [{ payload }] = await take(
(
action
): action is ReturnType<typeof boardImageNamesReceived.fulfilled> =>
action.meta.requestId === requestId
);
dispatch(imagesAddedToBatch(payload.image_names));
},
});
};

View File

@ -1,9 +1,4 @@
import { createAction } from '@reduxjs/toolkit';
import {
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');
@ -15,29 +10,27 @@ export const addAppStartedListener = () => {
action,
{ getState, dispatch, unsubscribe, cancelActiveListeners }
) => {
cancelActiveListeners();
unsubscribe();
// fill up the gallery tab with images
await dispatch(
receivedPageOfImages({
categories: ['general'],
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
// fill up the assets tab with images
await dispatch(
receivedPageOfImages({
categories: ['control', 'mask', 'user', 'other'],
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
dispatch(isLoadingChanged(false));
// cancelActiveListeners();
// unsubscribe();
// // fill up the gallery tab with images
// await dispatch(
// receivedPageOfImages({
// categories: ['general'],
// is_intermediate: false,
// offset: 0,
// // limit: INITIAL_IMAGE_LIMIT,
// })
// );
// // fill up the assets tab with images
// await dispatch(
// receivedPageOfImages({
// categories: ['control', 'mask', 'user', 'other'],
// is_intermediate: false,
// offset: 0,
// // limit: INITIAL_IMAGE_LIMIT,
// })
// );
// dispatch(isLoadingChanged(false));
},
});
};

View File

@ -1,15 +1,6 @@
import { log } from 'app/logging/useLogger';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { startAppListening } from '..';
import {
imageSelected,
selectImagesAll,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import {
IMAGES_PER_PAGE,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { boardsApi } from 'services/api/endpoints/boards';
const moduleLog = log.child({ namespace: 'boards' });
@ -17,49 +8,40 @@ export const addBoardIdSelectedListener = () => {
startAppListening({
actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => {
const board_id = action.payload;
// we need to check if we need to fetch more images
const state = getState();
const allImages = selectImagesAll(state);
if (!board_id) {
// a board was unselected
dispatch(imageSelected(allImages[0]?.image_name));
return;
}
const { categories } = state.gallery;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = board_id ? i.board_id === board_id : true;
return isInCategory && isInSelectedBoard;
});
// 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(imageSelected(allImages[0]?.image_name));
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 })
);
}
// const board_id = action.payload;
// // we need to check if we need to fetch more images
// const state = getState();
// const allImages = selectImagesAll(state);
// if (!board_id) {
// // a board was unselected
// dispatch(imageSelected(allImages[0]?.image_name));
// return;
// }
// const { categories } = state.gallery;
// const filteredImages = allImages.filter((i) => {
// const isInCategory = categories.includes(i.image_category);
// const isInSelectedBoard = board_id ? i.board_id === board_id : true;
// return isInCategory && isInSelectedBoard;
// });
// // 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(imageSelected(allImages[0]?.image_name));
// 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 })
// );
// }
},
});
};
@ -68,43 +50,36 @@ export const addBoardIdSelected_changeSelectedImage_listener = () => {
startAppListening({
actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => {
const board_id = action.payload;
const state = getState();
// we need to check if we need to fetch more images
if (!board_id) {
// a board was unselected - we don't need to do anything
return;
}
const { categories } = state.gallery;
const filteredImages = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = board_id ? i.board_id === board_id : true;
return isInCategory && isInSelectedBoard;
});
// 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...
return;
}
// 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 })
);
}
// const board_id = action.payload;
// const state = getState();
// // we need to check if we need to fetch more images
// if (!board_id) {
// // a board was unselected - we don't need to do anything
// return;
// }
// const { categories } = state.gallery;
// const filteredImages = selectImagesAll(state).filter((i) => {
// const isInCategory = categories.includes(i.image_category);
// const isInSelectedBoard = board_id ? i.board_id === board_id : true;
// return isInCategory && isInSelectedBoard;
// });
// // 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...
// return;
// }
// // 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 })
// );
// }
},
});
};

View File

@ -1,21 +1,19 @@
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 { requestedBoardImagesDeletion as requestedBoardAndImagesDeletion } from 'features/gallery/store/actions';
import {
imageSelected,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { LIST_TAG, api } from 'services/api';
import { startAppListening } from '..';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addRequestedBoardImageDeletionListener = () => {
startAppListening({
actionCreator: requestedBoardImagesDeletion,
actionCreator: requestedBoardAndImagesDeletion,
effect: async (action, { dispatch, getState, condition }) => {
const { board, imagesUsage } = action.payload;
@ -51,20 +49,12 @@ export const addRequestedBoardImageDeletionListener = () => {
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;
const { isSuccess, data } = result;
// Wait for successful deletion, then trigger boards to re-fetch
const wasBoardDeleted = await condition(() => !!isSuccess, 30000);

View File

@ -1,10 +1,10 @@
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 { imageUploaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
@ -49,7 +49,11 @@ export const addCanvasSavedToGalleryListener = () => {
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
dispatch(imageUpserted(uploadedImageDTO));
imagesApi.util.upsertQueryData(
'getImageDTO',
uploadedImageDTO.image_name,
uploadedImageDTO
);
},
});
};

View File

@ -1,13 +1,13 @@
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { Graph } from 'services/api/types';
import { sessionCreated } from 'services/api/thunks/session';
import { sessionReadyToInvoke } from 'features/system/store/actions';
import { socketInvocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/api/guards';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { sessionReadyToInvoke } from 'features/system/store/actions';
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 { socketInvocationComplete } from 'services/events/actions';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'controlNet' });
@ -63,10 +63,8 @@ export const addControlNetImageProcessedListener = () => {
// Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take(
(
action
): action is ReturnType<typeof imageMetadataReceived.fulfilled> =>
imageMetadataReceived.fulfilled.match(action) &&
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
imageDTOReceived.fulfilled.match(action) &&
action.payload.image_name === image_name
);
const processedControlImage = imageMetadataReceivedAction.payload;

View File

@ -1,40 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'boards' });
export const addImageAddedToBoardFulfilledListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageMetadataReceived({
image_name,
})
);
},
});
};
export const addImageAddedToBoardRejectedListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
};

View File

@ -1,13 +1,13 @@
import { log } from 'app/logging/useLogger';
import { imagesApi } from 'services/api/endpoints/images';
import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
import { imageMetadataReceived, imageUpdated } from 'services/api/thunks/image';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
export const addImageMetadataReceivedFulfilledListener = () => {
export const addImageDTOReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageMetadataReceived.fulfilled,
actionCreator: imageDTOReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
@ -33,14 +33,14 @@ export const addImageMetadataReceivedFulfilledListener = () => {
}
moduleLog.debug({ data: { image } }, 'Image metadata received');
dispatch(imageUpserted(image));
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image);
},
});
};
export const addImageMetadataReceivedRejectedListener = () => {
export const addImageDTOReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageMetadataReceived.rejected,
actionCreator: imageDTOReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },

View File

@ -1,11 +1,8 @@
import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import {
imageRemoved,
imageSelected,
selectFilteredImages,
} from 'features/gallery/store/gallerySlice';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
imageDeletionConfirmed,
isModalOpenChanged,
@ -80,9 +77,6 @@ export const addRequestedImageDeletionListener = () => {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// Delete from server
const { requestId } = dispatch(imageDeleted({ image_name }));
@ -91,7 +85,7 @@ export const addRequestedImageDeletionListener = () => {
(action): action is ReturnType<typeof imageDeleted.fulfilled> =>
imageDeleted.fulfilled.match(action) &&
action.meta.requestId === requestId,
30000
30_000
);
if (wasImageDeleted) {

View File

@ -21,57 +21,66 @@ import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' });
export const imageDropped = createAction<{
export const dndDropped = createAction<{
overData: TypesafeDroppableData;
activeData: TypesafeDraggableData;
}>('dnd/imageDropped');
}>('dnd/dndDropped');
export const addImageDroppedListener = () => {
startAppListening({
actionCreator: imageDropped,
effect: (action, { dispatch, getState }) => {
actionCreator: dndDropped,
effect: async (action, { dispatch, getState, take }) => {
const { activeData, overData } = action.payload;
const { actionType } = overData;
const state = getState();
moduleLog.debug(
{ data: { activeData, overData } },
'Image or selection dropped'
);
// set current image
if (
actionType === 'SET_CURRENT_IMAGE' &&
overData.actionType === 'SET_CURRENT_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageSelected(activeData.payload.imageDTO.image_name));
return;
}
// set initial image
if (
actionType === 'SET_INITIAL_IMAGE' &&
overData.actionType === 'SET_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(initialImageChanged(activeData.payload.imageDTO));
return;
}
// add image to batch
if (
actionType === 'ADD_TO_BATCH' &&
overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name));
return;
}
// add multiple images to batch
if (
actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'GALLERY_SELECTION'
overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_NAMES'
) {
dispatch(imagesAddedToBatch(state.gallery.selection));
dispatch(imagesAddedToBatch(activeData.payload.image_names));
return;
}
// set control image
if (
actionType === 'SET_CONTROLNET_IMAGE' &&
overData.actionType === 'SET_CONTROLNET_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@ -82,20 +91,22 @@ export const addImageDroppedListener = () => {
controlNetId,
})
);
return;
}
// set canvas image
if (
actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
overData.actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(setInitialCanvasImage(activeData.payload.imageDTO));
return;
}
// set nodes image
if (
actionType === 'SET_NODES_IMAGE' &&
overData.actionType === 'SET_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@ -107,11 +118,12 @@ export const addImageDroppedListener = () => {
value: activeData.payload.imageDTO,
})
);
return;
}
// set multiple nodes images (single image handler)
if (
actionType === 'SET_MULTI_NODES_IMAGE' &&
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@ -123,43 +135,30 @@ export const addImageDroppedListener = () => {
value: [activeData.payload.imageDTO],
})
);
return;
}
// set multiple nodes images (multiple images handler)
if (
actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'GALLERY_SELECTION'
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_NAMES'
) {
const { fieldName, nodeId } = overData.context;
dispatch(
imageCollectionFieldValueChanged({
nodeId,
fieldName,
value: state.gallery.selection.map((image_name) => ({
value: activeData.payload.image_names.map((image_name) => ({
image_name,
})),
})
);
return;
}
// remove image from board
// TODO: remove board_id from `removeImageFromBoard()` endpoint
// TODO: handle multiple images
// if (
// actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_DTO' &&
// activeData.payload.imageDTO &&
// overData.boardId !== null
// ) {
// const { image_name } = activeData.payload.imageDTO;
// dispatch(
// boardImagesApi.endpoints.removeImageFromBoard.initiate({ image_name })
// );
// }
// add image to board
if (
actionType === 'MOVE_BOARD' &&
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO &&
overData.context.boardId
@ -167,22 +166,89 @@ export const addImageDroppedListener = () => {
const { image_name } = activeData.payload.imageDTO;
const { boardId } = overData.context;
dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({
boardImagesApi.endpoints.addBoardImage.initiate({
image_name,
board_id: boardId,
})
);
return;
}
// add multiple images to board
// TODO: add endpoint
// if (
// actionType === 'ADD_TO_BATCH' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// activeData.payload.imageDTONames
// ) {
// dispatch(boardImagesApi.endpoints.addImagesToBoard.intiate({}));
// }
// remove image from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO &&
overData.context.boardId === null
) {
const { image_name } = activeData.payload.imageDTO;
dispatch(
boardImagesApi.endpoints.deleteBoardImage.initiate({ image_name })
);
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;
}
// 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;
}
},
});
};

View File

@ -1,40 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'boards' });
export const addImageRemovedFromBoardFulfilledListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageMetadataReceived({
image_name,
})
);
},
});
};
export const addImageRemovedFromBoardRejectedListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
};

View File

@ -1,13 +1,13 @@
import { startAppListening } from '..';
import { imageUploaded } from 'services/api/thunks/image';
import { addToast } from 'features/system/store/systemSlice';
import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { imageAddedToBatch } from 'features/batch/store/batchSlice';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { imageAddedToBatch } from 'features/batch/store/batchSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { imageUploaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' });
@ -24,7 +24,8 @@ export const addImageUploadedFulfilledListener = () => {
return;
}
dispatch(imageUpserted(image));
// update RTK query cache
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image);
const { postUploadAction } = action.meta.arg;
@ -84,8 +85,8 @@ export const addImageUploadedRejectedListener = () => {
startAppListening({
actionCreator: imageUploaded.rejected,
effect: (action, { dispatch }) => {
const { formData, ...rest } = action.meta.arg;
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } };
const { file, ...rest } = action.meta.arg;
const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
moduleLog.error({ data: sanitizedData }, 'Image upload failed');
dispatch(
addToast({

View File

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

View File

@ -0,0 +1,38 @@
import { log } from 'app/logging/useLogger';
import { serializeError } from 'serialize-error';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesLoaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' });
export const addImagesLoadedListener = () => {
startAppListening({
actionCreator: imagesLoaded.fulfilled,
effect: (action, { getState, dispatch }) => {
const { items } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Loaded ${items.length} images`
);
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
startAppListening({
actionCreator: imagesLoaded.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem loading images'
);
}
},
});
};

View File

@ -0,0 +1,38 @@
import { log } from 'app/logging/useLogger';
import { serializeError } from 'serialize-error';
import { imagesApi } from 'services/api/endpoints/images';
import { receivedListOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedListOfImagesListener = () => {
startAppListening({
actionCreator: receivedListOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
const { image_dtos } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Received ${image_dtos.length} images`
);
image_dtos.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
startAppListening({
actionCreator: receivedListOfImages.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem receiving images'
);
}
},
});
};

View File

@ -1,12 +1,12 @@
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';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedPageOfImagesFulfilledListener = () => {
export const addReceivedPageOfImagesListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
@ -16,6 +16,8 @@ export const addReceivedPageOfImagesFulfilledListener = () => {
`Received ${items.length} images`
);
// inject the received images into the RTK Query cache so consumers of the useGetImageDTOQuery
// hook can get their data from the cache instead of fetching the data again
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
@ -23,9 +25,7 @@ export const addReceivedPageOfImagesFulfilledListener = () => {
});
},
});
};
export const addReceivedPageOfImagesRejectedListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.rejected,
effect: (action, { getState, dispatch }) => {

View File

@ -1,19 +1,38 @@
import { startAppListening } from '..';
import { createAction } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger';
import {
imagesAddedToBatch,
selectionAddedToBatch,
} from 'features/batch/store/batchSlice';
import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { receivedListOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'batch' });
export const selectionAddedToBatch = createAction<{ images_names: string[] }>(
'batch/selectionAddedToBatch'
);
export const addSelectionAddedToBatchListener = () => {
startAppListening({
actionCreator: selectionAddedToBatch,
effect: (action, { dispatch, getState }) => {
const { selection } = getState().gallery;
effect: async (action, { dispatch, getState, take }) => {
const { requestId } = dispatch(
receivedListOfImages(action.payload.images_names)
);
dispatch(imagesAddedToBatch(selection));
const [{ payload }] = await take(
(action): action is ReturnType<typeof receivedListOfImages.fulfilled> =>
action.meta.requestId === requestId
);
moduleLog.debug({ data: { payload } }, 'receivedListOfImages');
dispatch(imagesAddedToBatch(payload.image_dtos));
payload.image_dtos.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
};

View File

@ -1,15 +1,16 @@
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { startAppListening } from '../..';
import { log } from 'app/logging/useLogger';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { 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,
socketInvocationComplete,
} from 'services/events/actions';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session';
import { isImageOutput } from 'services/api/guards';
import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { startAppListening } from '../..';
const moduleLog = log.child({ namespace: 'socketio' });
const nodeDenylist = ['dataURL_image'];
@ -41,14 +42,16 @@ export const addInvocationCompleteEventListener = () => {
const { image_name } = result.image;
// Get its metadata
dispatch(
imageMetadataReceived({
const { requestId } = dispatch(
imageDTOReceived({
image_name,
})
);
const [{ payload: imageDTO }] = await take(
imageMetadataReceived.fulfilled.match
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
imageDTOReceived.fulfilled.match(action) &&
action.meta.requestId === requestId
);
// Handle canvas image
@ -59,13 +62,33 @@ export const addInvocationCompleteEventListener = () => {
dispatch(addImageToStagingArea(imageDTO));
}
// Update the RTK Query cache
dispatch(
imagesApi.util.upsertQueryData(
'getImageDTO',
imageDTO.image_name,
imageDTO
)
);
if (boardIdToAddTo && !imageDTO.is_intermediate) {
dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({
boardImagesApi.endpoints.addBoardImage.initiate({
board_id: boardIdToAddTo,
image_name,
})
);
// Set the board_id on the image in the RTK Query cache
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
imageDTO.image_name,
(draft) => {
Object.assign(draft, { board_id: boardIdToAddTo });
}
)
);
}
dispatch(progressImageSet(null));

View File

@ -1,9 +1,9 @@
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 { imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvas' });
@ -43,7 +43,10 @@ export const addStagingAreaImageSavedListener = () => {
}
if (imageUpdated.fulfilled.match(imageUpdatedAction)) {
dispatch(imageUpserted(imageUpdatedAction.payload));
// update cache
imagesApi.util.updateQueryData('getImageDTO', imageName, (draft) => {
Object.assign(draft, { is_intermediate: false });
});
dispatch(addToast({ title: 'Image Saved', status: 'success' }));
}
},

View File

@ -96,10 +96,26 @@ export const store = configureStore({
.concat(dynamicMiddlewares)
.prepend(listenerMiddleware.middleware),
devTools: {
actionsDenylist,
actionSanitizer,
stateSanitizer,
trace: true,
predicate: (state, action) => {
// TODO: hook up to the log level param in system slice
// manually type state, cannot type the arg
// const typedState = state as ReturnType<typeof rootReducer>;
// if (action.type.startsWith('api/')) {
// // don't log api actions, with manual cache updates they are extremely noisy
// return false;
// }
if (actionsDenylist.includes(action.type)) {
// don't log other noisy actions
return false;
}
return true;
},
},
});

View File

@ -6,30 +6,24 @@ import {
useColorMode,
useColorModeValue,
} from '@chakra-ui/react';
import { useCombinedRefs } from '@dnd-kit/utilities';
import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd';
import IAIIconButton from 'common/components/IAIIconButton';
import {
IAILoadingImageFallback,
IAINoContentFallback,
} from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { AnimatePresence } from 'framer-motion';
import { MouseEvent, ReactElement, SyntheticEvent } from 'react';
import { memo, useRef } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { ImageDTO } from 'services/api/types';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
import { PostUploadAction } from 'services/api/thunks/image';
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 { mode } from 'theme/util/mode';
import {
TypesafeDraggableData,
TypesafeDroppableData,
isValidDrop,
useDraggable,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import IAIDraggable from './IAIDraggable';
import IAIDroppable from './IAIDroppable';
type IAIDndImageProps = {
imageDTO: ImageDTO | undefined;
@ -83,28 +77,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
const { colorMode } = useColorMode();
const dndId = useRef(uuidv4());
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
active,
} = useDraggable({
id: dndId.current,
disabled: isDragDisabled || !imageDTO,
data: draggableData,
});
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
id: dndId.current,
disabled: isDropDisabled,
data: droppableData,
});
const setDndRef = useCombinedRefs(setDroppableRef, setDraggableRef);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction,
isDisabled: isUploadDisabled,
@ -139,9 +111,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
userSelect: 'none',
cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer',
}}
{...attributes}
{...listeners}
ref={setDndRef}
>
{imageDTO && (
<Flex
@ -154,7 +123,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}}
>
<Image
onClick={onClick}
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError"
fallback={<IAILoadingImageFallback image={imageDTO} />}
@ -171,30 +139,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}}
/>
{withMetadataOverlay && <ImageMetadataOverlay image={imageDTO} />}
{onClickReset && withResetIcon && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
</Flex>
)}
{!imageDTO && !isUploadDisabled && (
@ -225,11 +169,42 @@ const IAIDndImage = (props: IAIDndImageProps) => {
</>
)}
{!imageDTO && isUploadDisabled && noContentFallback}
<AnimatePresence>
{isValidDrop(droppableData, active) && !isDragging && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
<IAIDroppable
data={droppableData}
disabled={isDropDisabled}
dropLabel={dropLabel}
/>
{imageDTO && (
<IAIDraggable
data={draggableData}
disabled={isDragDisabled || !imageDTO}
onClick={onClick}
/>
)}
{onClickReset && withResetIcon && imageDTO && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
</Flex>
);
};

View File

@ -0,0 +1,40 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDraggableData,
useDraggable,
} from 'app/components/ImageDnd/typesafeDnd';
import { MouseEvent, memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
type IAIDraggableProps = {
disabled?: boolean;
data?: TypesafeDraggableData;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const IAIDraggable = (props: IAIDraggableProps) => {
const { data, disabled, onClick } = props;
const dndId = useRef(uuidv4());
const { attributes, listeners, setNodeRef } = useDraggable({
id: dndId.current,
disabled,
data,
});
return (
<Box
onClick={onClick}
ref={setNodeRef}
position="absolute"
w="full"
h="full"
top={0}
insetInlineStart={0}
{...attributes}
{...listeners}
/>
);
};
export default memo(IAIDraggable);

View File

@ -0,0 +1,47 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDroppableData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import { AnimatePresence } from 'framer-motion';
import { memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
type IAIDroppableProps = {
dropLabel?: string;
disabled?: boolean;
data?: TypesafeDroppableData;
};
const IAIDroppable = (props: IAIDroppableProps) => {
const { dropLabel, data, disabled } = props;
const dndId = useRef(uuidv4());
const { isOver, setNodeRef, active } = useDroppable({
id: dndId.current,
disabled,
data,
});
return (
<Box
ref={setNodeRef}
position="absolute"
top={0}
insetInlineStart={0}
w="full"
h="full"
pointerEvents="none"
>
<AnimatePresence>
{isValidDrop(data, active) && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
</Box>
);
};
export default memo(IAIDroppable);

View File

@ -0,0 +1,42 @@
import { Box, Flex, Icon } from '@chakra-ui/react';
import { FaExclamation } from 'react-icons/fa';
const IAIErrorLoadingImageFallback = () => {
return (
<Box
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
bg: 'base.100',
color: 'base.500',
_dark: {
color: 'base.700',
bg: 'base.850',
},
}}
>
<Icon as={FaExclamation} boxSize={16} opacity={0.7} />
</Flex>
</Box>
);
};
export default IAIErrorLoadingImageFallback;

View File

@ -0,0 +1,30 @@
import { Box, Skeleton } from '@chakra-ui/react';
const IAIFillSkeleton = () => {
return (
<Skeleton
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
}}
/>
</Skeleton>
);
};
export default IAIFillSkeleton;

View File

@ -1,18 +1,20 @@
import { Box, Icon, Skeleton } 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 IAIErrorLoadingImageFallback from 'common/components/IAIErrorLoadingImageFallback';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import {
batchImageRangeEndSelected,
batchImageSelected,
batchImageSelectionToggled,
imageRemovedFromBatch,
} from 'features/batch/store/batchSlice';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { FaExclamationCircle } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const makeSelector = (image_name: string) =>
@ -20,6 +22,7 @@ const makeSelector = (image_name: string) =>
[stateSelector],
(state) => ({
selectionCount: state.batch.selection.length,
selection: state.batch.selection,
isSelected: state.batch.selection.includes(image_name),
}),
defaultSelectorOptions
@ -30,43 +33,41 @@ type BatchImageProps = {
};
const BatchImage = (props: BatchImageProps) => {
const dispatch = useAppDispatch();
const { imageName } = props;
const {
currentData: imageDTO,
isFetching,
isLoading,
isError,
isSuccess,
} = useGetImageDTOQuery(props.imageName);
const dispatch = useAppDispatch();
} = useGetImageDTOQuery(imageName);
const selector = useMemo(() => makeSelector(imageName), [imageName]);
const selector = useMemo(
() => makeSelector(props.imageName),
[props.imageName]
);
const { isSelected, selectionCount } = useAppSelector(selector);
const { isSelected, selectionCount, selection } = useAppSelector(selector);
const handleClickRemove = useCallback(() => {
dispatch(imageRemovedFromBatch(props.imageName));
}, [dispatch, props.imageName]);
dispatch(imageRemovedFromBatch(imageName));
}, [dispatch, imageName]);
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (e.shiftKey) {
dispatch(batchImageRangeEndSelected(props.imageName));
dispatch(batchImageRangeEndSelected(imageName));
} else if (e.ctrlKey || e.metaKey) {
dispatch(batchImageSelectionToggled(props.imageName));
dispatch(batchImageSelectionToggled(imageName));
} else {
dispatch(batchImageSelected(props.imageName));
dispatch(batchImageSelected(imageName));
}
},
[dispatch, props.imageName]
[dispatch, imageName]
);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (selectionCount > 1) {
return {
id: 'batch',
payloadType: 'BATCH_SELECTION',
payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
};
}
@ -77,38 +78,49 @@ const BatchImage = (props: BatchImageProps) => {
payload: { imageDTO },
};
}
}, [imageDTO, selectionCount]);
}, [imageDTO, selection, selectionCount]);
if (isError) {
return <Icon as={FaExclamationCircle} />;
if (isLoading) {
return <IAIFillSkeleton />;
}
if (isFetching) {
return (
<Skeleton>
<Box w="full" h="full" aspectRatio="1/1" />
</Skeleton>
);
if (isError || !imageDTO) {
return <IAIErrorLoadingImageFallback />;
}
return (
<Box sx={{ position: 'relative', aspectRatio: '1/1' }}>
<IAIDndImage
imageDTO={imageDTO}
draggableData={draggableData}
isDropDisabled={true}
isUploadDisabled={true}
imageSx={{
w: 'full',
h: 'full',
}}
onClick={handleClick}
isSelected={isSelected}
onClickReset={handleClickRemove}
resetTooltip="Remove from batch"
withResetIcon
thumbnail
/>
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ImageContextMenu imageDTO={imageDTO}>
{(ref) => (
<Box
position="relative"
key={imageName}
userSelect="none"
ref={ref}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
}}
>
<IAIDndImage
onClick={handleClick}
imageDTO={imageDTO}
draggableData={draggableData}
isSelected={isSelected}
minSize={0}
onClickReset={handleClickRemove}
isDropDisabled={true}
imageSx={{ w: 'full', h: 'full' }}
isUploadDisabled={true}
resetTooltip="Remove from batch"
withResetIcon
thumbnail
/>
</Box>
)}
</ImageContextMenu>
</Box>
);
};

View File

@ -1,11 +1,7 @@
import { Box } from '@chakra-ui/react';
import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable';
import BatchImageGrid from './BatchImageGrid';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import {
AddToBatchDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
const droppableData: AddToBatchDropData = {
id: 'batch',
@ -13,17 +9,10 @@ const droppableData: AddToBatchDropData = {
};
const BatchImageContainer = () => {
const { isOver, setNodeRef, active } = useDroppable({
id: 'batch-manager',
data: droppableData,
});
return (
<Box ref={setNodeRef} position="relative" w="full" h="full">
<Box position="relative" w="full" h="full">
<BatchImageGrid />
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} label="Add to Batch" />
)}
<IAIDroppable data={droppableData} dropLabel="Add to Batch" />
</Box>
);
};

View File

@ -1,4 +1,4 @@
import { PayloadAction, createAction, createSlice } from '@reduxjs/toolkit';
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { uniq } from 'lodash-es';
import { imageDeleted } from 'services/api/thunks/image';
@ -26,10 +26,10 @@ const batch = createSlice({
state.isEnabled = action.payload;
},
imageAddedToBatch: (state, action: PayloadAction<string>) => {
state.imageNames = uniq(state.imageNames.concat(action.payload));
state.imageNames.push(action.payload);
},
imagesAddedToBatch: (state, action: PayloadAction<string[]>) => {
state.imageNames = uniq(state.imageNames.concat(action.payload));
state.imageNames = state.imageNames.concat(action.payload);
},
imageRemovedFromBatch: (state, action: PayloadAction<string>) => {
state.imageNames = state.imageNames.filter(
@ -50,10 +50,13 @@ const batch = createSlice({
batchImageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1];
const lastClickedIndex = state.imageNames.findIndex(
const { imageNames } = state;
const lastClickedIndex = imageNames.findIndex(
(n) => n === lastSelectedImage
);
const currentClickedIndex = state.imageNames.findIndex(
const currentClickedIndex = imageNames.findIndex(
(n) => n === rangeEndImageName
);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
@ -61,7 +64,8 @@ const batch = createSlice({
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = state.imageNames.slice(start, end + 1);
const imagesToSelect = imageNames.slice(start, end + 1);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
},
@ -136,7 +140,3 @@ export const {
} = batch.actions;
export default batch.reducer;
export const selectionAddedToBatch = createAction(
'batch/selectionAddedToBatch'
);

View File

@ -0,0 +1,87 @@
import { Box } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
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 BatchImage from 'features/batch/components/BatchImage';
import { VirtuosoGrid } from 'react-virtuoso';
import ItemContainer from './ItemContainer';
import ListContainer from './ListContainer';
const selector = createSelector(
[stateSelector],
(state) => {
return {
imageNames: state.batch.imageNames,
};
},
defaultSelectorOptions
);
const BatchGrid = () => {
const { t } = useTranslation();
const rootRef = useRef(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 { imageNames } = useAppSelector(selector);
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
if (imageNames.length) {
return (
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={imageNames}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, imageName) => (
<BatchImage key={imageName} imageName={imageName} />
)}
/>
</Box>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
export default memo(BatchGrid);

View File

@ -0,0 +1,102 @@
import { Box, Spinner } from '@chakra-ui/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaExclamation, FaImage } from 'react-icons/fa';
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 BatchImage from 'features/batch/components/BatchImage';
import { VirtuosoGrid } from 'react-virtuoso';
import { useGetAllBoardImagesForBoardQuery } from 'services/api/endpoints/boardImages';
import ItemContainer from './ItemContainer';
import ListContainer from './ListContainer';
const selector = createSelector(
[stateSelector],
(state) => {
return {
imageNames: state.batch.imageNames,
};
},
defaultSelectorOptions
);
type BoardGridProps = {
board_id: string;
};
const BoardGrid = (props: BoardGridProps) => {
const { board_id } = props;
const { data, isLoading, isError, isSuccess } =
useGetAllBoardImagesForBoardQuery({
board_id,
});
const { t } = useTranslation();
const rootRef = useRef(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' },
},
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <FaExclamation />;
}
if (isSuccess && data.image_names) {
return (
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={data.image_names}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, imageName) => (
<BatchImage key={imageName} imageName={imageName} />
)}
/>
</Box>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
export default memo(BoardGrid);

View File

@ -1,23 +1,14 @@
import { Flex, useColorMode } from '@chakra-ui/react';
import { FaImages } from 'react-icons/fa';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { FaImages } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AnimatePresence } from 'framer-motion';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { mode } from 'theme/util/mode';
import {
MoveBoardDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import GenericBoard from './GenericBoard';
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const { colorMode } = useColorMode();
const handleAllImagesBoardClick = () => {
dispatch(boardIdSelected());
dispatch(boardIdSelected('all'));
};
const droppableData: MoveBoardDropData = {
@ -26,67 +17,14 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
context: { boardId: null },
};
const { isOver, setNodeRef, active } = useDroppable({
id: `board_droppable_all_images`,
data: droppableData,
});
return (
<Flex
sx={{
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
w: 'full',
h: 'full',
borderRadius: 'base',
}}
>
<Flex
ref={setNodeRef}
onClick={handleAllImagesBoardClick}
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,
}}
>
<IAINoContentFallback
boxSize={8}
icon={FaImages}
sx={{
border: '2px solid var(--invokeai-colors-base-200)',
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
}}
/>
<AnimatePresence>
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} />
)}
</AnimatePresence>
</Flex>
<Flex
sx={{
h: 'full',
alignItems: 'center',
color: isSelected
? mode('base.900', 'base.50')(colorMode)
: mode('base.700', 'base.200')(colorMode),
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
}}
>
All Images
</Flex>
</Flex>
<GenericBoard
droppableData={droppableData}
onClick={handleAllImagesBoardClick}
isSelected={isSelected}
icon={FaImages}
label="All Images"
/>
);
};

View File

@ -0,0 +1,42 @@
import { createSelector } from '@reduxjs/toolkit';
import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useCallback } from 'react';
import { FaLayerGroup } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import GenericBoard from './GenericBoard';
const selector = createSelector(stateSelector, (state) => {
return {
count: state.batch.imageNames.length,
};
});
const BatchBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const { count } = useAppSelector(selector);
const handleBatchBoardClick = useCallback(() => {
dispatch(boardIdSelected('batch'));
}, [dispatch]);
const droppableData: AddToBatchDropData = {
id: 'batch-board',
actionType: 'ADD_TO_BATCH',
};
return (
<GenericBoard
droppableData={droppableData}
onClick={handleBatchBoardClick}
isSelected={isSelected}
icon={FaLayerGroup}
label="Batch"
badgeCount={count}
/>
);
};
export default BatchBoard;

View File

@ -1,3 +1,4 @@
import { CloseIcon } from '@chakra-ui/icons';
import {
Collapse,
Flex,
@ -9,17 +10,17 @@ import {
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, useState } from 'react';
import HoverableBoard from './HoverableBoard';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import AddBoardButton from './AddBoardButton';
import AllImagesBoard from './AllImagesBoard';
import { CloseIcon } from '@chakra-ui/icons';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { stateSelector } from 'app/store/store';
import BatchBoard from './BatchBoard';
import GalleryBoard from './GalleryBoard';
const selector = createSelector(
[stateSelector],
@ -115,14 +116,19 @@ const BoardsList = (props: Props) => {
}}
>
{!searchMode && (
<GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={!selectedBoardId} />
</GridItem>
<>
<GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={selectedBoardId === 'all'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<BatchBoard isSelected={selectedBoardId === 'batch'} />
</GridItem>
</>
)}
{filteredBoards &&
filteredBoards.map((board) => (
<GridItem key={board.board_id} sx={{ p: 1.5 }}>
<HoverableBoard
<GalleryBoard
board={board}
isSelected={selectedBoardId === board.board_id}
/>

View File

@ -12,35 +12,31 @@ import {
} from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { memo, useCallback, useContext } from 'react';
import { FaFolder, FaTrash } from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { BoardDTO } from 'services/api/types';
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 { AnimatePresence } from 'framer-motion';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { boardAddedToBatch } from 'app/store/middleware/listenerMiddleware/listeners/addBoardToBatch';
import IAIDroppable from 'common/components/IAIDroppable';
import { mode } from 'theme/util/mode';
import {
MoveBoardDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
interface HoverableBoardProps {
interface GalleryBoardProps {
board: BoardDTO;
isSelected: boolean;
}
const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
const dispatch = useAppDispatch();
const { currentData: coverImage } = useGetImageDTOQuery(
@ -71,21 +67,23 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
deleteBoard(board_id);
}, [board_id, deleteBoard]);
const handleAddBoardToBatch = useCallback(() => {
dispatch(boardAddedToBatch({ board_id }));
}, [board_id, dispatch]);
const handleDeleteBoardAndImages = useCallback(() => {
console.log({ board });
onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]);
const droppableData: MoveBoardDropData = {
id: board_id,
actionType: 'MOVE_BOARD',
context: { boardId: board_id },
};
const { isOver, setNodeRef, active } = useDroppable({
id: `board_droppable_${board_id}`,
data: droppableData,
});
const droppableData: MoveBoardDropData = useMemo(
() => ({
id: board_id,
actionType: 'MOVE_BOARD',
context: { boardId: board_id },
}),
[board_id]
);
return (
<Box sx={{ touchAction: 'none', height: 'full' }}>
@ -94,16 +92,25 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
{board.image_count > 0 && (
<MenuItem
sx={{ color: 'error.300' }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
<>
<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: mode('error.700', 'error.300')(colorMode) }}
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoard}
>
@ -127,7 +134,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
}}
>
<Flex
ref={setNodeRef}
onClick={handleSelectBoard}
sx={{
position: 'relative',
@ -167,11 +173,7 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
>
<Badge variant="solid">{board.image_count}</Badge>
</Flex>
<AnimatePresence>
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} />
)}
</AnimatePresence>
<IAIDroppable data={droppableData} />
</Flex>
<Flex
@ -219,6 +221,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
);
});
HoverableBoard.displayName = 'HoverableBoard';
GalleryBoard.displayName = 'HoverableBoard';
export default HoverableBoard;
export default GalleryBoard;

View File

@ -0,0 +1,83 @@
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';
type GenericBoardProps = {
droppableData: TypesafeDroppableData;
onClick: () => void;
isSelected: boolean;
icon: As;
label: string;
badgeCount?: number;
};
const GenericBoard = (props: GenericBoardProps) => {
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props;
return (
<Flex
sx={{
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
w: 'full',
h: 'full',
borderRadius: 'base',
}}
>
<Flex
onClick={onClick}
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,
}}
>
<IAINoContentFallback
boxSize={8}
icon={icon}
sx={{
border: '2px solid var(--invokeai-colors-base-200)',
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
}}
/>
<Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
}}
>
{badgeCount !== undefined && (
<Badge variant="solid">{badgeCount}</Badge>
)}
</Flex>
<IAIDroppable data={droppableData} />
</Flex>
<Flex
sx={{
h: 'full',
alignItems: 'center',
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
color: isSelected ? 'base.900' : 'base.700',
_dark: { color: isSelected ? 'base.50' : 'base.200' },
}}
>
{label}
</Flex>
</Flex>
);
};
export default GenericBoard;

View File

@ -8,7 +8,7 @@ import {
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { isEqual } from 'lodash-es';
import { memo, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';

View File

@ -1,4 +1,4 @@
import { Box } from '@chakra-ui/react';
import { Box, Spinner } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
@ -7,9 +7,7 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
import { ImageDTO } from 'services/api/types';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import {
imageRangeEndSelected,
imageSelected,
@ -20,50 +18,38 @@ import ImageContextMenu from './ImageContextMenu';
export const makeSelector = (image_name: string) =>
createSelector(
[stateSelector],
({ gallery }) => {
const isSelected = gallery.selection.includes(image_name);
const selectionCount = gallery.selection.length;
return {
isSelected,
selectionCount,
};
},
({ gallery }) => ({
isSelected: gallery.selection.includes(image_name),
selectionCount: gallery.selection.length,
selection: gallery.selection,
}),
defaultSelectorOptions
);
interface HoverableImageProps {
imageDTO: ImageDTO;
imageName: string;
}
/**
* Gallery image component with delete/use all/use seed buttons on hover.
*/
const GalleryImage = (props: HoverableImageProps) => {
const { imageDTO } = props;
const { image_url, thumbnail_url, image_name } = imageDTO;
const localSelector = useMemo(() => makeSelector(image_name), [image_name]);
const { isSelected, selectionCount } = useAppSelector(localSelector);
const dispatch = useAppDispatch();
const { imageName } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const localSelector = useMemo(() => makeSelector(imageName), [imageName]);
const { t } = useTranslation();
const { isSelected, selectionCount, selection } =
useAppSelector(localSelector);
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
// multiselect disabled for now
// if (e.shiftKey) {
// dispatch(imageRangeEndSelected(props.imageDTO.image_name));
// } else if (e.ctrlKey || e.metaKey) {
// dispatch(imageSelectionToggled(props.imageDTO.image_name));
// } else {
// dispatch(imageSelected(props.imageDTO.image_name));
// }
dispatch(imageSelected(props.imageDTO.image_name));
if (e.shiftKey) {
dispatch(imageRangeEndSelected(imageName));
} else if (e.ctrlKey || e.metaKey) {
dispatch(imageSelectionToggled(imageName));
} else {
dispatch(imageSelected(imageName));
}
},
[dispatch, props.imageDTO.image_name]
[dispatch, imageName]
);
const handleDelete = useCallback(
@ -81,7 +67,8 @@ const GalleryImage = (props: HoverableImageProps) => {
if (selectionCount > 1) {
return {
id: 'gallery-image',
payloadType: 'GALLERY_SELECTION',
payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
};
}
@ -92,15 +79,19 @@ const GalleryImage = (props: HoverableImageProps) => {
payload: { imageDTO },
};
}
}, [imageDTO, selectionCount]);
}, [imageDTO, selection, selectionCount]);
if (!imageDTO) {
return <Spinner />;
}
return (
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ImageContextMenu image={imageDTO}>
<ImageContextMenu imageDTO={imageDTO}>
{(ref) => (
<Box
position="relative"
key={image_name}
key={imageName}
userSelect="none"
ref={ref}
sx={{
@ -117,13 +108,13 @@ const GalleryImage = (props: HoverableImageProps) => {
isSelected={isSelected}
minSize={0}
onClickReset={handleDelete}
resetIcon={<FaTrash />}
resetTooltip="Delete image"
imageSx={{ w: 'full', h: 'full' }}
// withResetIcon // removed bc it's too easy to accidentally delete images
isDropDisabled={true}
isUploadDisabled={true}
thumbnail={true}
// resetIcon={<FaTrash />}
// resetTooltip="Delete image"
// withResetIcon // removed bc it's too easy to accidentally delete images
/>
</Box>
)}

View File

@ -7,8 +7,9 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import {
imageAddedToBatch,
imageRemovedFromBatch,
imagesAddedToBatch,
selectionAddedToBatch,
} from 'features/batch/store/batchSlice';
import {
resizeAndScaleCanvas,
@ -21,34 +22,46 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaExpand, FaFolder, FaShare, FaTrash } from 'react-icons/fa';
import {
FaExpand,
FaFolder,
FaLayerGroup,
FaShare,
FaTrash,
} from 'react-icons/fa';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
import {
useAddManyBoardImagesMutation,
useDeleteBoardImageMutation,
useDeleteManyBoardImagesMutation,
} from 'services/api/endpoints/boardImages';
import { ImageDTO } from 'services/api/types';
import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
type Props = {
image: ImageDTO;
imageDTO: ImageDTO;
children: ContextMenuProps<HTMLDivElement>['children'];
};
const ImageContextMenu = ({ image, children }: Props) => {
const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selector = useMemo(
() =>
createSelector(
[stateSelector],
({ gallery, batch }) => {
const selectionCount = gallery.selection.length;
const isInBatch = batch.imageNames.includes(image.image_name);
const isBatch = gallery.selectedBoardId === 'batch';
return { selectionCount, isInBatch };
const selection = isBatch ? batch.selection : gallery.selection;
const isInBatch = batch.imageNames.includes(imageDTO.image_name);
return { selection, isInBatch };
},
defaultSelectorOptions
),
[image.image_name]
[imageDTO.image_name]
);
const { selectionCount, isInBatch } = useAppSelector(selector);
const { selection, isInBatch } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { t } = useTranslation();
@ -60,37 +73,39 @@ const ImageContextMenu = ({ image, children }: Props) => {
const { onClickAddToBoard } = useContext(AddImageToBoardContext);
const handleDelete = useCallback(() => {
if (!image) {
if (!imageDTO) {
return;
}
dispatch(imageToDeleteSelected(image));
}, [dispatch, image]);
dispatch(imageToDeleteSelected(imageDTO));
}, [dispatch, imageDTO]);
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
const [removeFromBoard] = useRemoveImageFromBoardMutation();
const [deleteBoardImage] = useDeleteBoardImageMutation();
const [deleteManyBoardImages] = useDeleteManyBoardImagesMutation();
const [addManyBoardImages] = useAddManyBoardImagesMutation();
// Recall parameters handlers
const handleRecallPrompt = useCallback(() => {
recallBothPrompts(
image.metadata?.positive_conditioning,
image.metadata?.negative_conditioning
imageDTO.metadata?.positive_conditioning,
imageDTO.metadata?.negative_conditioning
);
}, [
image.metadata?.negative_conditioning,
image.metadata?.positive_conditioning,
imageDTO.metadata?.negative_conditioning,
imageDTO.metadata?.positive_conditioning,
recallBothPrompts,
]);
const handleRecallSeed = useCallback(() => {
recallSeed(image.metadata?.seed);
}, [image, recallSeed]);
recallSeed(imageDTO.metadata?.seed);
}, [imageDTO, recallSeed]);
const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(image));
}, [dispatch, image]);
dispatch(initialImageSelected(imageDTO));
}, [dispatch, imageDTO]);
// const handleRecallInitialImage = useCallback(() => {
// recallInitialImage(image.metadata.invokeai?.node?.image);
@ -98,7 +113,7 @@ const ImageContextMenu = ({ image, children }: Props) => {
const handleSendToCanvas = () => {
dispatch(sentImageToCanvas());
dispatch(setInitialCanvasImage(image));
dispatch(setInitialCanvasImage(imageDTO));
dispatch(resizeAndScaleCanvas());
dispatch(setActiveTab('unifiedCanvas'));
@ -111,8 +126,8 @@ const ImageContextMenu = ({ image, children }: Props) => {
};
const handleUseAllParameters = useCallback(() => {
recallAllParameters(image);
}, [image, recallAllParameters]);
recallAllParameters(imageDTO);
}, [imageDTO, recallAllParameters]);
const handleLightBox = () => {
// dispatch(setCurrentImage(image));
@ -120,34 +135,50 @@ const ImageContextMenu = ({ image, children }: Props) => {
};
const handleAddToBoard = useCallback(() => {
onClickAddToBoard(image);
}, [image, onClickAddToBoard]);
onClickAddToBoard(imageDTO);
}, [imageDTO, onClickAddToBoard]);
const handleRemoveFromBoard = useCallback(() => {
if (!image.board_id) {
if (!imageDTO.board_id) {
return;
}
removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
}, [image.board_id, image.image_name, removeFromBoard]);
deleteBoardImage({ image_name: imageDTO.image_name });
}, [deleteBoardImage, imageDTO.board_id, imageDTO.image_name]);
const handleOpenInNewTab = () => {
window.open(image.image_url, '_blank');
};
const handleAddSelectionToBoard = useCallback(() => {
// addManyBoardImages({ board_id, image_names: selection });
}, []);
const handleRemoveSelectionFromBoard = useCallback(() => {
deleteManyBoardImages({ image_names: selection });
}, [deleteManyBoardImages, selection]);
const handleOpenInNewTab = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
}, [imageDTO.image_url]);
const handleAddSelectionToBatch = useCallback(() => {
dispatch(selectionAddedToBatch());
}, [dispatch]);
dispatch(imagesAddedToBatch(selection));
}, [dispatch, selection]);
const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([image.image_name]));
}, [dispatch, image.image_name]);
dispatch(imageAddedToBatch(imageDTO.image_name));
}, [dispatch, imageDTO]);
const handleRemoveFromBatch = useCallback(() => {
dispatch(imageRemovedFromBatch(imageDTO.image_name));
}, [dispatch, imageDTO]);
if (!imageDTO) {
return null;
}
return (
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
{selectionCount === 1 ? (
{selection.length === 1 ? (
<>
<MenuItem
icon={<ExternalLinkIcon />}
@ -164,7 +195,7 @@ const ImageContextMenu = ({ image, children }: Props) => {
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallPrompt}
isDisabled={
image?.metadata?.positive_conditioning === undefined
imageDTO?.metadata?.positive_conditioning === undefined
}
>
{t('parameters.usePrompt')}
@ -173,24 +204,17 @@ const ImageContextMenu = ({ image, children }: Props) => {
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallSeed}
isDisabled={image?.metadata?.seed === undefined}
isDisabled={imageDTO?.metadata?.seed === undefined}
>
{t('parameters.useSeed')}
</MenuItem>
{/* <MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallInitialImage}
isDisabled={image?.metadata?.type !== 'img2img'}
>
{t('parameters.useInitImg')}
</MenuItem> */}
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseAllParameters}
isDisabled={
// what should these be
!['t2l', 'l2l', 'inpaint'].includes(
String(image?.metadata?.type)
String(imageDTO?.metadata?.type)
)
}
>
@ -212,17 +236,18 @@ const ImageContextMenu = ({ image, children }: Props) => {
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
{/* <MenuItem
icon={<FaFolder />}
isDisabled={isInBatch}
onClickCapture={handleAddToBatch}
<MenuItem
icon={<FaLayerGroup />}
onClickCapture={
isInBatch ? handleRemoveFromBatch : handleAddToBatch
}
>
Add to Batch
</MenuItem> */}
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{image.board_id ? 'Change Board' : 'Add to Board'}
{isInBatch ? 'Remove from Batch' : 'Add to Batch'}
</MenuItem>
{image.board_id && (
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{imageDTO.board_id ? 'Change Board' : 'Add to Board'}
</MenuItem>
{imageDTO.board_id && (
<MenuItem
icon={<FaFolder />}
onClickCapture={handleRemoveFromBoard}
@ -241,18 +266,23 @@ const ImageContextMenu = ({ image, children }: Props) => {
) : (
<>
<MenuItem
isDisabled={true}
icon={<FaFolder />}
onClickCapture={handleAddToBoard}
onClickCapture={handleAddSelectionToBoard}
>
Move Selection to Board
</MenuItem>
{/* <MenuItem
icon={<FaFolderPlus />}
<MenuItem
icon={<FaFolder />}
onClickCapture={handleRemoveSelectionFromBoard}
>
Reset Board for Selection
</MenuItem>
<MenuItem
icon={<FaLayerGroup />}
onClickCapture={handleAddSelectionToBatch}
>
Add Selection to Batch
</MenuItem> */}
</MenuItem>
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}

View File

@ -19,7 +19,7 @@ import {
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { ChangeEvent, memo, useCallback, useRef } from 'react';
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';
@ -29,14 +29,10 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
imageCategoriesChanged,
shouldAutoSwitchChanged,
} from 'features/gallery/store/gallerySlice';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode';
import BatchGrid from './BatchGrid';
import BoardsList from './Boards/BoardsList';
import ImageGalleryGrid from './ImageGalleryGrid';
@ -66,6 +62,7 @@ const ImageGalleryContent = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null);
const { colorMode } = useColorMode();
@ -83,6 +80,16 @@ const ImageGalleryContent = () => {
}),
});
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) => {
@ -95,12 +102,10 @@ const ImageGalleryContent = () => {
};
const handleClickImagesCategory = useCallback(() => {
dispatch(imageCategoriesChanged(IMAGE_CATEGORIES));
dispatch(setGalleryView('images'));
}, [dispatch]);
const handleClickAssetsCategory = useCallback(() => {
dispatch(imageCategoriesChanged(ASSETS_CATEGORIES));
dispatch(setGalleryView('assets'));
}, [dispatch]);
@ -163,7 +168,7 @@ const ImageGalleryContent = () => {
fontWeight: 600,
}}
>
{selectedBoard ? selectedBoard.board_name : 'All Images'}
{boardTitle}
</Text>
<ChevronUpIcon
sx={{
@ -216,8 +221,8 @@ const ImageGalleryContent = () => {
<BoardsList isOpen={isBoardListOpen} />
</Box>
</Box>
<Flex direction="column" gap={2} h="full" w="full">
<ImageGalleryGrid />
<Flex ref={galleryGridRef} direction="column" gap={2} h="full" w="full">
{selectedBoardId === 'batch' ? <BatchGrid /> : <ImageGalleryGrid />}
</Flex>
</VStack>
);

View File

@ -1,74 +1,38 @@
import {
Box,
Flex,
FlexProps,
Grid,
Skeleton,
Spinner,
forwardRef,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Box } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import { IMAGE_LIMIT } from 'features/gallery/store/gallerySlice';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import {
PropsWithChildren,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from '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 { RootState, stateSelector } from 'app/store/store';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { selectFilteredImages } from 'features/gallery/store/gallerySlice';
import { VirtuosoGrid } from 'react-virtuoso';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { ImageDTO } from 'services/api/types';
import { useLoadMoreImages } from '../hooks/useLoadMoreImages';
import ItemContainer from './ItemContainer';
import ListContainer from './ListContainer';
const selector = createSelector(
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const {
categories,
total: allImagesTotal,
isLoading,
isFetching,
selectedBoardId,
} = state.gallery;
let images = filteredImages as (ImageDTO | 'loading')[];
if (!isLoading && isFetching) {
// loading, not not the initial load
images = images.concat(Array(IMAGE_LIMIT).fill('loading'));
}
[stateSelector],
(state) => {
const { galleryImageMinimumWidth } = state.gallery;
return {
images,
allImagesTotal,
isLoading,
isFetching,
categories,
selectedBoardId,
galleryImageMinimumWidth,
};
},
defaultSelectorOptions
);
const ImageGalleryGrid = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const rootRef = useRef(null);
const rootRef = useRef<HTMLDivElement>(null);
const emptyGalleryRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
@ -83,46 +47,27 @@ const ImageGalleryGrid = () => {
},
});
const { galleryImageMinimumWidth } = useAppSelector(selector);
const {
images,
isLoading,
isFetching,
allImagesTotal,
categories,
imageNames,
galleryView,
loadMoreImages,
selectedBoardId,
} = useAppSelector(selector);
const { selectedBoard } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => ({
selectedBoard: data?.find((b) => b.board_id === selectedBoardId),
}),
});
const filteredImagesTotal = useMemo(
() => selectedBoard?.image_count ?? allImagesTotal,
[allImagesTotal, selectedBoard?.image_count]
);
const areMoreAvailable = useMemo(() => {
return images.length < filteredImagesTotal;
}, [images.length, filteredImagesTotal]);
status,
areMoreAvailable,
} = useLoadMoreImages();
const handleLoadMoreImages = useCallback(() => {
dispatch(
receivedPageOfImages({
categories,
board_id: selectedBoardId,
is_intermediate: false,
})
);
}, [categories, dispatch, selectedBoardId]);
loadMoreImages({});
}, [loadMoreImages]);
const handleEndReached = useMemo(() => {
if (areMoreAvailable && !isLoading) {
if (areMoreAvailable && status !== 'pending') {
return handleLoadMoreImages;
}
return undefined;
}, [areMoreAvailable, handleLoadMoreImages, isLoading]);
}, [areMoreAvailable, handleLoadMoreImages, status]);
useEffect(() => {
const { current: root } = rootRef;
@ -137,53 +82,68 @@ const ImageGalleryGrid = () => {
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
if (isLoading) {
useEffect(() => {
// TODO: this doesn't actually prevent 2 intial image loads...
if (status !== undefined) {
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);
// load up that many images
loadMoreImages({
offset: 0,
limit: imagesToLoad,
});
}, [
galleryImageMinimumWidth,
galleryView,
loadMoreImages,
selectedBoardId,
status,
]);
if (status === 'fulfilled' && imageNames.length === 0) {
return (
<Flex
sx={{
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spinner
size="xl"
sx={{ color: 'base.300', _dark: { color: 'base.700' } }}
<Box ref={emptyGalleryRef} sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
</Flex>
</Box>
);
}
if (images.length) {
if (status !== 'rejected') {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={images}
data={imageNames}
endReached={handleEndReached}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, item) =>
typeof item === 'string' ? (
<Skeleton sx={{ w: 'full', h: 'full', aspectRatio: '1/1' }} />
) : (
<GalleryImage
key={`${item.image_name}-${item.thumbnail_url}`}
imageDTO={item}
/>
)
}
itemContent={(index, imageName) => (
<GalleryImage key={imageName} imageName={imageName} />
)}
/>
</Box>
<IAIButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isLoading={isFetching}
isLoading={status === 'pending'}
loadingText="Loading"
flexShrink={0}
>
@ -194,40 +154,6 @@ const ImageGalleryGrid = () => {
</>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default memo(ImageGalleryGrid);

View File

@ -0,0 +1,11 @@
import { Box, FlexProps, forwardRef } from '@chakra-ui/react';
import { PropsWithChildren } from 'react';
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
export default ItemContainer;

View File

@ -0,0 +1,26 @@
import { FlexProps, Grid, forwardRef } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { PropsWithChildren } from 'react';
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default ListContainer;

View File

@ -4,7 +4,6 @@ import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
imageSelected,
selectFilteredImages,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es';
@ -13,6 +12,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { selectFilteredImages } from '../store/gallerySelectors';
const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = {
height: '100%',

View File

@ -0,0 +1,64 @@
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 { useCallback } from 'react';
import { ImagesLoadedArg, imagesLoaded } from 'services/api/thunks/image';
const selector = createSelector(
[stateSelector],
(state) => {
const { selectedBoardId, galleryView } = state.gallery;
const imageNames =
state.gallery.imageNamesByIdAndView[selectedBoardId]?.[galleryView]
.imageNames ?? [];
const total =
state.gallery.imageNamesByIdAndView[selectedBoardId]?.[galleryView]
.total ?? 0;
const status =
state.gallery.statusByIdAndView[selectedBoardId]?.[galleryView] ??
undefined;
return {
imageNames,
status,
total,
selectedBoardId,
galleryView,
};
},
defaultSelectorOptions
);
export const useLoadMoreImages = () => {
const dispatch = useAppDispatch();
const { selectedBoardId, imageNames, galleryView, total, status } =
useAppSelector(selector);
const loadMoreImages = useCallback(
(arg: Partial<ImagesLoadedArg>) => {
dispatch(
imagesLoaded({
board_id: selectedBoardId,
offset: imageNames.length,
view: galleryView,
...arg,
})
);
},
[dispatch, galleryView, imageNames.length, selectedBoardId]
);
return {
loadMoreImages,
selectedBoardId,
imageNames,
galleryView,
areMoreAvailable: imageNames.length < total,
total,
status,
};
};

View File

@ -15,4 +15,6 @@ export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
'galleryView',
'total',
'isInitialized',
'imageNamesByIdAndView',
'statusByIdAndView',
];

View File

@ -1,3 +1,61 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { keyBy } from 'lodash-es';
import { galleryImagesAdapter, initialGalleryState } from './gallerySlice';
export const gallerySelector = (state: RootState) => state.gallery;
export const selectFilteredImagesLocal = createSelector(
(state: typeof initialGalleryState) => state,
(galleryState) => {
const allImages = galleryImagesAdapter
.getSelectors()
.selectAll(galleryState);
const { categories, selectedBoardId } = galleryState;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
return filteredImages;
}
);
export const selectFilteredImages = createSelector(
(state: RootState) => state,
(state) => {
return selectFilteredImagesLocal(state.gallery);
},
defaultSelectorOptions
);
export const selectFilteredImagesAsObject = createSelector(
selectFilteredImages,
(filteredImages) => keyBy(filteredImages, 'image_name')
);
export const selectFilteredImagesIds = createSelector(
selectFilteredImages,
(filteredImages) => filteredImages.map((i) => i.image_name)
);
export const selectLastSelectedImage = createSelector(
(state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions
);
export const selectSelectedImages = createSelector(
(state: RootState) => state,
(state) =>
galleryImagesAdapter
.getSelectors()
.selectAll(state.gallery)
.filter((i) => state.gallery.selection.includes(i.image_name)),
defaultSelectorOptions
);

View File

@ -1,21 +1,14 @@
import type { PayloadAction, Update } from '@reduxjs/toolkit';
import {
createEntityAdapter,
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { dateComparator } from 'common/util/dateComparator';
import { keyBy, uniq } from 'lodash-es';
import { filter, forEach, uniq } from 'lodash-es';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { boardsApi } from 'services/api/endpoints/boards';
import {
imageUrlsReceived,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { imageDeleted, imagesLoaded } from 'services/api/thunks/image';
import { ImageCategory, ImageDTO } from 'services/api/types';
export const imagesAdapter = createEntityAdapter<ImageDTO>({
export const galleryImagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
@ -31,23 +24,99 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20;
type AdditionaGalleryState = {
type RequestState = 'pending' | 'fulfilled' | 'rejected';
type GalleryView = 'images' | 'assets';
// dirty hack to get autocompletion while still accepting any string
type BoardPath =
| 'all.images'
| 'all.assets'
| 'none.images'
| 'none.assets'
| 'batch.images'
| 'batch.assets'
| `${string}.${GalleryView}`;
const systemBoards = [
'all.images',
'all.assets',
'none.images',
'none.assets',
'batch.images',
'batch.assets',
];
type Boards = Record<
BoardPath,
{
path: BoardPath;
id: 'all' | 'none' | 'batch' | (string & Record<never, never>);
view: GalleryView;
imageNames: string[];
total: number;
status: RequestState | undefined;
}
>;
type AdditionalGalleryState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
isFetching: boolean;
categories: ImageCategory[];
selectedBoardId?: string;
selection: string[];
shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number;
galleryView: 'images' | 'assets';
isInitialized: boolean;
galleryView: GalleryView;
selectedBoardId: 'all' | 'none' | 'batch' | (string & Record<never, never>);
boards: Boards;
};
const initialBoardState = { imageNames: [], total: 0, status: undefined };
const initialBoards: Boards = {
'all.images': {
path: 'all.images',
id: 'all',
view: 'images',
...initialBoardState,
},
'all.assets': {
path: 'all.assets',
id: 'all',
view: 'assets',
...initialBoardState,
},
'none.images': {
path: 'none.images',
id: 'none',
view: 'images',
...initialBoardState,
},
'none.assets': {
path: 'none.assets',
id: 'none',
view: 'assets',
...initialBoardState,
},
'batch.images': {
path: 'batch.images',
id: 'batch',
view: 'images',
...initialBoardState,
},
'batch.assets': {
path: 'batch.assets',
id: 'batch',
view: 'assets',
...initialBoardState,
},
};
export const initialGalleryState =
imagesAdapter.getInitialState<AdditionaGalleryState>({
galleryImagesAdapter.getInitialState<AdditionalGalleryState>({
offset: 0,
limit: 0,
total: 0,
@ -59,57 +128,45 @@ export const initialGalleryState =
galleryImageMinimumWidth: 96,
galleryView: 'images',
isInitialized: false,
selectedBoardId: 'all',
boards: initialBoards,
});
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.categories = IMAGE_CATEGORIES;
}
},
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
imagesAdapter.updateOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload);
galleryImagesAdapter.removeOne(state, action.payload);
},
imagesRemoved: (state, action: PayloadAction<string[]>) => {
imagesAdapter.removeMany(state, action.payload);
},
imageCategoriesChanged: (state, action: PayloadAction<ImageCategory[]>) => {
state.categories = action.payload;
galleryImagesAdapter.removeMany(state, action.payload);
},
imageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1];
const filteredImages = selectFilteredImagesLocal(state);
// get image names for the current board and view
const imageNames =
state.boards[`${state.selectedBoardId}.${state.galleryView}`]
.imageNames;
const lastClickedIndex = filteredImages.findIndex(
(n) => n.image_name === lastSelectedImage
// get the index of the last selected image
const lastClickedIndex = imageNames.findIndex(
(n) => n === lastSelectedImage
);
const currentClickedIndex = filteredImages.findIndex(
(n) => n.image_name === rangeEndImageName
// get the index of the just-clicked image
const currentClickedIndex = imageNames.findIndex(
(n) => n === rangeEndImageName
);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
// We have a valid range, selected it!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = filteredImages
.slice(start, end + 1)
.map((i) => i.image_name);
const imagesToSelect = imageNames.slice(start, end + 1);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
@ -122,9 +179,10 @@ export const gallerySlice = createSlice({
state.selection = state.selection.filter(
(imageName) => imageName !== action.payload
);
} else {
state.selection = uniq(state.selection.concat(action.payload));
return;
}
state.selection = uniq(state.selection.concat(action.payload));
},
imageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload
@ -137,59 +195,210 @@ export const gallerySlice = createSlice({
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload;
},
setGalleryView: (state, action: PayloadAction<'images' | 'assets'>) => {
setGalleryView: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
boardIdSelected: (state, action: PayloadAction<string | undefined>) => {
state.selectedBoardId = action.payload;
boardIdSelected: (state, action: PayloadAction<BoardPath>) => {
const boardId = action.payload;
if (state.selectedBoardId === boardId) {
// selected same board, no-op
return;
}
state.selectedBoardId = boardId;
// handle selecting an unitialized board
const boardImagesId: BoardPath = `${boardId}.images`;
const boardAssetsId: BoardPath = `${boardId}.assets`;
if (!state.boards[boardImagesId]) {
state.boards[boardImagesId] = {
path: boardImagesId,
id: boardId,
view: 'images',
...initialBoardState,
};
}
if (!state.boards[boardAssetsId]) {
state.boards[boardAssetsId] = {
path: boardAssetsId,
id: boardId,
view: 'assets',
...initialBoardState,
};
}
// set the first image as selected
const firstImageName =
state.boards[`${boardId}.${state.galleryView}`].imageNames[0];
state.selection = firstImageName ? [firstImageName] : [];
},
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(receivedPageOfImages.pending, (state) => {
state.isFetching = true;
/**
* Image deleted
*/
builder.addCase(imageDeleted.pending, (state, action) => {
// optimistic update, but no undo :/
const { image_name } = action.meta.arg;
// remove image from all boards
forEach(state.boards, (board) => {
board.imageNames = board.imageNames.filter((n) => n !== image_name);
});
// and selection
state.selection = state.selection.filter((n) => n !== image_name);
});
builder.addCase(receivedPageOfImages.rejected, (state) => {
state.isFetching = false;
/**
* Images loaded into gallery - PENDING
*/
builder.addCase(imagesLoaded.pending, (state, action) => {
const { board_id, view } = action.meta.arg;
state.boards[`${board_id}.${view}`].status = 'pending';
});
builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
state.isFetching = false;
const { board_id, categories, image_origin, is_intermediate } =
action.meta.arg;
/**
* Images loaded into gallery - FULFILLED
*/
builder.addCase(imagesLoaded.fulfilled, (state, action) => {
const { items, total } = action.payload;
const { board_id, view } = action.meta.arg;
const board = state.boards[`${board_id}.${view}`];
const { items, offset, limit, total } = action.payload;
board.status = 'fulfilled';
imagesAdapter.upsertMany(state, items);
board.imageNames = uniq(
board.imageNames.concat(items.map((i) => i.image_name))
);
board.total = total;
if (state.selection.length === 0 && items.length) {
state.selection = [items[0].image_name];
}
});
/**
* Images loaded into gallery - REJECTED
*/
builder.addCase(imagesLoaded.rejected, (state, action) => {
const { board_id, view } = action.meta.arg;
state.boards[`${board_id}.${view}`].status = 'rejected';
});
/**
* Image added to board
*/
builder.addMatcher(
boardImagesApi.endpoints.addBoardImage.matchFulfilled,
(state, action) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
// update user board stores
const userBoards = selectUserBoards(state);
userBoards.forEach((board) => {
// only update the current view
if (board.view !== state.galleryView) {
return;
}
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;
if (board_id === board.id) {
// add image to the board
board.imageNames = uniq(board.imageNames.concat(image_name));
} else {
// remove image from other boards
board.imageNames = board.imageNames.filter((n) => n !== image_name);
}
});
}
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 },
});
});
);
/**
* Many images added to board
*/
builder.addMatcher(
boardImagesApi.endpoints.addManyBoardImages.matchFulfilled,
(state, action) => {
const { board_id, image_names } = action.meta.arg.originalArgs;
// update local board stores
forEach(state.boards, (board, board_id) => {
// only update the current view
if (board_id === board.id) {
// add images to the board
board.imageNames = uniq(board.imageNames.concat(image_names));
} else {
// remove images from other boards
board.imageNames = board.imageNames.filter((n) =>
image_names.includes(n)
);
}
});
}
);
/**
* Board deleted (not images)
*/
builder.addMatcher(
boardsApi.endpoints.deleteBoard.matchFulfilled,
(state, action) => {
if (action.meta.arg.originalArgs === state.selectedBoardId) {
state.selectedBoardId = undefined;
const deletedBoardId = action.meta.arg.originalArgs;
if (deletedBoardId === state.selectedBoardId) {
state.selectedBoardId = 'all';
}
// remove board from local store
delete state.boards[`${deletedBoardId}.images`];
delete state.boards[`${deletedBoardId}.assets`];
}
);
/**
* Board deleted (with images)
*/
builder.addMatcher(
boardsApi.endpoints.deleteBoardAndImages.matchFulfilled,
(state, action) => {
const { deleted_images } = action.payload;
const deletedBoardId = action.meta.arg.originalArgs;
// remove images from all boards
forEach(state.boards, (board) => {
// remove images from all boards
board.imageNames = board.imageNames.filter((n) =>
deleted_images.includes(n)
);
});
delete state.boards[`${deletedBoardId}.images`];
delete state.boards[`${deletedBoardId}.assets`];
}
);
/**
* Image removed from board; i.e. Board reset for image
*/
builder.addMatcher(
boardImagesApi.endpoints.deleteBoardImage.matchFulfilled,
(state, action) => {
const { image_name } = action.meta.arg.originalArgs;
// remove from all user boards (skip all, none, batch)
const userBoards = selectUserBoards(state);
userBoards.forEach((board) => {
board.imageNames = board.imageNames.filter((n) => n !== image_name);
});
}
);
/**
* Many images removed from board; i.e. Board reset for many images
*/
builder.addMatcher(
boardImagesApi.endpoints.deleteManyBoardImages.matchFulfilled,
(state, action) => {
const { image_names } = action.meta.arg.originalArgs;
// remove images from all boards
forEach(state.imageNamesByIdAndView, (board) => {
// only update the current view
const view = board[state.galleryView];
view.imageNames = view.imageNames.filter((n) =>
image_names.includes(n)
);
});
}
);
},
@ -201,14 +410,10 @@ export const {
selectEntities: selectImagesEntities,
selectIds: selectImagesIds,
selectTotal: selectImagesTotal,
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery);
} = galleryImagesAdapter.getSelectors<RootState>((state) => state.gallery);
export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imagesRemoved,
imageCategoriesChanged,
imageRangeEndSelected,
imageSelectionToggled,
imageSelected,
@ -221,44 +426,12 @@ export const {
export default gallerySlice.reducer;
export const selectFilteredImagesLocal = createSelector(
(state: typeof initialGalleryState) => state,
(galleryState) => {
const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
const { categories, selectedBoardId } = galleryState;
const selectUserBoards = (state: typeof initialGalleryState) =>
filter(state.boards, (board, path) => !systemBoards.includes(path));
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
const selectCurrentBoard = (state: typeof initialGalleryState) =>
state.boards[`${state.selectedBoardId}.${state.galleryView}`];
return filteredImages;
}
);
const isImagesView = (board: BoardPath) => board.split('.')[1] === 'images';
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
);
const isAssetsView = (board: BoardPath) => board.split('.')[1] === 'assets';

View File

@ -121,8 +121,9 @@ const nodesSlice = createSlice({
) => {
state.invocationTemplates = action.payload;
},
nodeEditorReset: () => {
return { ...initialNodesState };
nodeEditorReset: (state) => {
state.nodes = [];
state.edges = [];
},
setEditorInstance: (state, action) => {
state.editorInstance = action.payload;

View File

@ -1,22 +1,22 @@
import { Flex, Spacer, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback, useMemo } from 'react';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaLayerGroup, FaUndo, FaUpload } from 'react-icons/fa';
import useImageUploader from 'common/hooks/useImageUploader';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import IAIButton from 'common/components/IAIButton';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import useImageUploader from 'common/hooks/useImageUploader';
import BatchImageContainer from 'features/batch/components/BatchImageContainer';
import {
asInitialImageToggled,
batchReset,
} from 'features/batch/store/batchSlice';
import BatchImageContainer from 'features/batch/components/BatchImageContainer';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback, useMemo } from 'react';
import { FaLayerGroup, FaUndo, FaUpload } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/thunks/image';
import InitialImage from './InitialImage';
@ -114,7 +114,7 @@ const InitialImageDisplay = () => {
Initial Image
</Text>
<Spacer />
{/* <IAIButton
<IAIButton
tooltip={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'}
aria-label={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'}
leftIcon={<FaLayerGroup />}
@ -122,7 +122,7 @@ const InitialImageDisplay = () => {
onClick={handleClickUseBatch}
>
{useBatchAsInitialImage ? 'Batch' : 'Single'}
</IAIButton> */}
</IAIButton>
<IAIIconButton
tooltip={
useBatchAsInitialImage ? 'Upload to Batch' : 'Upload Initial Image'
@ -146,8 +146,7 @@ const InitialImageDisplay = () => {
isDisabled={isResetButtonDisabled}
/>
</Flex>
<InitialImage />
{/* {useBatchAsInitialImage ? <BatchImageContainer /> : <InitialImage />} */}
{useBatchAsInitialImage ? <BatchImageContainer /> : <InitialImage />}
<input {...getUploadInputProps()} />
</Flex>
);

View File

@ -24,7 +24,7 @@ import { isEqual } from 'lodash-es';
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaCube, FaFont, FaImage } from 'react-icons/fa';
import { FaCube, FaFont, FaImage, FaLayerGroup } from 'react-icons/fa';
import { MdDeviceHub, MdGridOn } from 'react-icons/md';
import { Panel, PanelGroup } from 'react-resizable-panels';
import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize';
@ -32,6 +32,7 @@ import {
activeTabIndexSelector,
activeTabNameSelector,
} from '../store/uiSelectors';
import BatchTab from './tabs/Batch/BatchTab';
import ImageTab from './tabs/ImageToImage/ImageToImageTab';
import ModelManagerTab from './tabs/ModelManager/ModelManagerTab';
import NodesTab from './tabs/Nodes/NodesTab';
@ -78,11 +79,12 @@ const tabs: InvokeTabInfo[] = [
icon: <Icon as={FaCube} sx={{ boxSize: 6, pointerEvents: 'none' }} />,
content: <ModelManagerTab />,
},
// {
// id: 'batch',
// icon: <Icon as={FaLayerGroup} sx={{ boxSize: 6, pointerEvents: 'none' }} />,
// content: <BatchTab />,
// },
{
id: 'batch',
translationKey: 'modelManager.modelManager',
icon: <Icon as={FaLayerGroup} sx={{ boxSize: 6, pointerEvents: 'none' }} />,
content: <BatchTab />,
},
];
const enabledTabsSelector = createSelector(

View File

@ -1,44 +1,57 @@
import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types';
import { api } from '..';
import { paths } from '../schema';
import { PatchCollection } from '@reduxjs/toolkit/dist/query/core/buildThunks';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { components, paths } from '../schema';
import { imagesApi } from './images';
type ListBoardImagesArg =
paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] &
paths['/api/v1/board_images/{board_id}']['get']['parameters']['query'];
type AddImageToBoardArg =
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json'];
paths['/api/v1/board_images/{board_id}']['post']['requestBody']['content']['application/json'];
type AddManyImagesToBoardArg =
paths['/api/v1/board_images/{board_id}/images']['patch']['requestBody']['content']['application/json'];
type RemoveImageFromBoardArg =
paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json'];
type RemoveManyBoardImagesArg =
paths['/api/v1/board_images/images']['post']['requestBody']['content']['application/json'];
type GetAllBoardImagesForBoardResult =
components['schemas']['GetAllBoardImagesForBoardResult'];
export const boardImagesApi = api.injectEndpoints({
endpoints: (build) => ({
/**
* Board Images Queries
*/
listBoardImages: build.query<
OffsetPaginatedResults_ImageDTO_,
ListBoardImagesArg
getAllBoardImagesForBoard: build.query<
GetAllBoardImagesForBoardResult,
{ board_id: string }
>({
query: ({ board_id, offset, limit }) => ({
query: ({ board_id }) => ({
url: `board_images/${board_id}`,
method: 'DELETE',
body: { offset, limit },
method: 'GET',
}),
providesTags: (result, error, arg) => [
{
type: 'Board',
id: arg.board_id,
},
],
}),
/**
* Board Images Mutations
*/
addImageToBoard: build.mutation<void, AddImageToBoardArg>({
addBoardImage: build.mutation<
void,
{ board_id: string; image_name: string }
>({
query: ({ board_id, image_name }) => ({
url: `board_images/`,
url: `board_images/${board_id}`,
method: 'POST',
body: { board_id, image_name },
body: image_name,
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg.board_id },
@ -60,19 +73,55 @@ export const boardImagesApi = api.injectEndpoints({
},
}),
removeImageFromBoard: build.mutation<void, RemoveImageFromBoardArg>({
query: ({ board_id, image_name }) => ({
url: `board_images/`,
method: 'DELETE',
body: { board_id, image_name },
addManyBoardImages: build.mutation<
string[],
{ board_id: string; image_names: string[] }
>({
query: ({ board_id, image_names }) => ({
url: `board_images/${board_id}/images`,
method: 'PATCH',
body: image_names,
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg.board_id },
],
invalidatesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [
{ type: 'Board', id: arg.board_id },
{ type: 'Board', id: LIST_TAG },
];
return tags;
},
async onQueryStarted(
{ image_name, ...patch },
{ image_names, board_id },
{ dispatch, queryFulfilled }
) {
const patches: PatchCollection[] = [];
image_names.forEach((n) => {
const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', n, (draft) => {
Object.assign(draft, { board_id });
})
);
patches.push(patchResult);
});
try {
await queryFulfilled;
} catch {
patches.forEach((p) => p.undo());
}
},
}),
deleteBoardImage: build.mutation<void, { image_name: string }>({
query: ({ image_name }) => ({
url: `board_images/`,
method: 'DELETE',
body: image_name,
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: LIST_TAG },
],
async onQueryStarted({ image_name }, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => {
Object.assign(draft, { board_id: null });
@ -85,11 +134,42 @@ export const boardImagesApi = api.injectEndpoints({
}
},
}),
deleteManyBoardImages: build.mutation<void, { image_names: string[] }>({
query: ({ image_names }) => ({
url: `board_images/images`,
method: 'POST',
body: image_names,
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: LIST_TAG },
],
async onQueryStarted({ image_names }, { dispatch, queryFulfilled }) {
const patches: PatchCollection[] = [];
image_names.forEach((n) => {
const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', n, (draft) => {
Object.assign(draft, { board_id: null });
})
);
patches.push(patchResult);
});
try {
await queryFulfilled;
} catch {
patches.forEach((p) => p.undo());
}
},
}),
}),
});
export const {
useAddImageToBoardMutation,
useRemoveImageFromBoardMutation,
useListBoardImagesQuery,
useGetAllBoardImagesForBoardQuery,
useAddBoardImageMutation,
useAddManyBoardImagesMutation,
useDeleteBoardImageMutation,
useDeleteManyBoardImagesMutation,
} = boardImagesApi;

View File

@ -1,6 +1,6 @@
import { BoardDTO, OffsetPaginatedResults_BoardDTO_ } from 'services/api/types';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema';
import { components, paths } from '../schema';
type ListBoardsArg = NonNullable<
paths['/api/v1/boards/']['get']['parameters']['query']
@ -20,7 +20,7 @@ export const boardsApi = api.injectEndpoints({
query: (arg) => ({ url: 'boards/', params: arg }),
providesTags: (result, error, arg) => {
// any list of boards
const tags: ApiFullTagDescription[] = [{ id: 'Board', type: LIST_TAG }];
const tags: ApiFullTagDescription[] = [{ type: 'Board', id: LIST_TAG }];
if (result) {
// and individual tags for each board
@ -43,7 +43,7 @@ export const boardsApi = api.injectEndpoints({
}),
providesTags: (result, error, arg) => {
// any list of boards
const tags: ApiFullTagDescription[] = [{ id: 'Board', type: LIST_TAG }];
const tags: ApiFullTagDescription[] = [{ type: 'Board', id: LIST_TAG }];
if (result) {
// and individual tags for each board
@ -69,7 +69,7 @@ export const boardsApi = api.injectEndpoints({
method: 'POST',
params: { board_name },
}),
invalidatesTags: [{ id: 'Board', type: LIST_TAG }],
invalidatesTags: [{ type: 'Board', id: LIST_TAG }],
}),
updateBoard: build.mutation<BoardDTO, UpdateBoardArg>({
@ -86,9 +86,19 @@ export const boardsApi = api.injectEndpoints({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }),
invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }],
}),
deleteBoardAndImages: build.mutation<void, string>({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE', params: { include_images: true } }),
invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }, { type: 'Image', id: LIST_TAG }],
deleteBoardAndImages: build.mutation<
components['schemas']['DeleteManyImagesResult'],
string
>({
query: (board_id) => ({
url: `boards/${board_id}`,
method: 'DELETE',
params: { include_images: true },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg },
{ type: 'Image', id: LIST_TAG },
],
}),
}),
});
@ -99,5 +109,5 @@ export const {
useCreateBoardMutation,
useUpdateBoardMutation,
useDeleteBoardMutation,
useDeleteBoardAndImagesMutation
useDeleteBoardAndImagesMutation,
} = boardsApi;

View File

@ -1,4 +1,4 @@
import { ApiFullTagDescription, api } from '..';
import { api } from '..';
import { ImageDTO } from '../types';
export const imagesApi = api.injectEndpoints({
@ -7,14 +7,8 @@ export const imagesApi = api.injectEndpoints({
* Image Queries
*/
getImageDTO: build.query<ImageDTO, string>({
query: (image_name) => ({ url: `images/${image_name}/metadata` }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [{ type: 'Image', id: arg }];
if (result?.board_id) {
tags.push({ type: 'Board', id: result.board_id });
}
return tags;
},
query: (image_name) => ({ url: `images/${image_name}` }),
providesTags: (result, error, arg) => [{ type: 'Image', id: arg }],
keepUnusedDataFor: 86400, // 24 hours
}),
}),

View File

@ -73,7 +73,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'main' } }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [
{ id: 'MainModel', type: LIST_TAG },
{ type: 'MainModel', id: LIST_TAG },
];
if (result) {
@ -105,7 +105,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'lora' } }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [
{ id: 'LoRAModel', type: LIST_TAG },
{ type: 'LoRAModel', id: LIST_TAG },
];
if (result) {
@ -140,7 +140,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'controlnet' } }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [
{ id: 'ControlNetModel', type: LIST_TAG },
{ type: 'ControlNetModel', id: LIST_TAG },
];
if (result) {
@ -172,7 +172,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'vae' } }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [
{ id: 'VaeModel', type: LIST_TAG },
{ type: 'VaeModel', id: LIST_TAG },
];
if (result) {
@ -207,7 +207,7 @@ export const modelsApi = api.injectEndpoints({
query: () => ({ url: 'models/', params: { model_type: 'embedding' } }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [
{ id: 'TextualInversionModel', type: LIST_TAG },
{ type: 'TextualInversionModel', id: LIST_TAG },
];
if (result) {

View File

@ -107,12 +107,7 @@ export type paths = {
*/
put: operations["merge_models"];
};
"/api/v1/images/": {
/**
* List Images With Metadata
* @description Gets a list of images
*/
get: operations["list_images_with_metadata"];
"/api/v1/images/upload": {
/**
* Upload Image
* @description Uploads an image
@ -121,10 +116,10 @@ export type paths = {
};
"/api/v1/images/{image_name}": {
/**
* Get Image Full
* @description Gets a full-resolution image file
* Get Image Dto
* @description Gets an image's DTO
*/
get: operations["get_image_full"];
get: operations["get_image"];
/**
* Delete Image
* @description Deletes an image
@ -136,12 +131,12 @@ export type paths = {
*/
patch: operations["update_image"];
};
"/api/v1/images/{image_name}/metadata": {
"/api/v1/images/{image_name}/full_size": {
/**
* Get Image Metadata
* @description Gets an image's metadata
* Get Image Full Size
* @description Gets a full-resolution image file
*/
get: operations["get_image_metadata"];
get: operations["get_image_full_size"];
};
"/api/v1/images/{image_name}/thumbnail": {
/**
@ -157,6 +152,25 @@ export type paths = {
*/
get: operations["get_image_urls"];
};
"/api/v1/images/": {
/**
* Get Many Images
* @description Gets a list of images
*/
get: operations["get_many_images"];
/**
* Get Images By Names
* @description Gets a list of images
*/
post: operations["get_images_by_names"];
};
"/api/v1/images/delete": {
/**
* Delete Many Images
* @description Deletes many images
*/
post: operations["delete_many_images"];
};
"/api/v1/boards/": {
/**
* List Boards
@ -186,24 +200,38 @@ export type paths = {
*/
patch: operations["update_board"];
};
"/api/v1/board_images/": {
"/api/v1/board_images/{board_id}": {
/**
* Get All Board Images For Board
* @description Gets all image names for a board
*/
get: operations["get_all_board_images_for_board"];
/**
* Create Board Image
* @description Creates a board_image
*/
post: operations["create_board_image"];
};
"/api/v1/board_images/": {
/**
* Remove Board Image
* @description Deletes a board_image
*/
delete: operations["remove_board_image"];
};
"/api/v1/board_images/{board_id}": {
"/api/v1/board_images/{board_id}/images": {
/**
* List Board Images
* @description Gets a list of images for a board
* Create Multiple Board Images
* @description Add many images to a board
*/
get: operations["list_board_images"];
patch: operations["create_multiple_board_images"];
};
"/api/v1/board_images/images": {
/**
* Delete Multiple Board Images
* @description Remove many images from their boards, if they have one
*/
post: operations["delete_multiple_board_images"];
};
"/api/v1/app/version": {
/** Get Version */
@ -318,19 +346,6 @@ export type components = {
*/
image_count: number;
};
/** Body_create_board_image */
Body_create_board_image: {
/**
* Board Id
* @description The id of the board to add to
*/
board_id: string;
/**
* Image Name
* @description The name of the image to add
*/
image_name: string;
};
/** Body_import_model */
Body_import_model: {
/**
@ -373,19 +388,6 @@ export type components = {
*/
force?: boolean;
};
/** Body_remove_board_image */
Body_remove_board_image: {
/**
* Board Id
* @description The id of the board
*/
board_id: string;
/**
* Image Name
* @description The name of the image to remove
*/
image_name: string;
};
/** Body_upload_image */
Body_upload_image: {
/**
@ -869,6 +871,17 @@ export type components = {
*/
mask?: components["schemas"]["ImageField"];
};
/**
* DeleteManyImagesResult
* @description The result of a delete many image operation.
*/
DeleteManyImagesResult: {
/**
* Deleted Images
* @description The names of the images that were successfully deleted
*/
deleted_images: (string)[];
};
/**
* DivideInvocation
* @description Divides two numbers
@ -1046,6 +1059,33 @@ export type components = {
*/
param?: number;
};
/**
* GetAllBoardImagesForBoardResult
* @description The result of a get all image names for board operation.
*/
GetAllBoardImagesForBoardResult: {
/**
* Board Id
* @description The id of the board with which the images are associated
*/
board_id: string;
/**
* Image Names
* @description The names of the images that are associated with the board
*/
image_names: (string)[];
};
/**
* GetImagesByNamesResult
* @description The result of a get all image names for board operation.
*/
GetImagesByNamesResult: {
/**
* Image Dtos
* @description The names of the images that are associated with the board
*/
image_dtos: (components["schemas"]["ImageDTO"])[];
};
/** Graph */
Graph: {
/**
@ -1058,7 +1098,7 @@ export type components = {
* @description The nodes in this graph
*/
nodes?: {
[key: string]: (components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined;
[key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined;
};
/**
* Edges
@ -1101,7 +1141,7 @@ export type components = {
* @description The results of node executions
*/
results: {
[key: string]: (components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["VaeLoaderOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined;
[key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["VaeLoaderOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined;
};
/**
* Errors
@ -4425,18 +4465,18 @@ export type components = {
*/
image?: components["schemas"]["ImageField"];
};
/**
* StableDiffusion2ModelFormat
* @description An enumeration.
* @enum {string}
*/
StableDiffusion2ModelFormat: "checkpoint" | "diffusers";
/**
* StableDiffusion1ModelFormat
* @description An enumeration.
* @enum {string}
*/
StableDiffusion1ModelFormat: "checkpoint" | "diffusers";
/**
* StableDiffusion2ModelFormat
* @description An enumeration.
* @enum {string}
*/
StableDiffusion2ModelFormat: "checkpoint" | "diffusers";
};
responses: never;
parameters: never;
@ -4547,7 +4587,7 @@ export type operations = {
};
requestBody: {
content: {
"application/json": components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
"application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
};
};
responses: {
@ -4584,7 +4624,7 @@ export type operations = {
};
requestBody: {
content: {
"application/json": components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
"application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["UpscaleInvocation"] | components["schemas"]["RestoreFaceInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
};
};
responses: {
@ -4976,42 +5016,6 @@ export type operations = {
};
};
};
/**
* List Images With Metadata
* @description Gets a list of images
*/
list_images_with_metadata: {
parameters: {
query?: {
/** @description The origin of images to list */
image_origin?: components["schemas"]["ResourceOrigin"];
/** @description The categories of image to include */
categories?: (components["schemas"]["ImageCategory"])[];
/** @description Whether to list intermediate images */
is_intermediate?: boolean;
/** @description The board id to filter by */
board_id?: string;
/** @description The page offset */
offset?: number;
/** @description The number of images per page */
limit?: number;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Upload Image
* @description Uploads an image
@ -5050,25 +5054,23 @@ export type operations = {
};
};
/**
* Get Image Full
* @description Gets a full-resolution image file
* Get Image Dto
* @description Gets an image's DTO
*/
get_image_full: {
get_image: {
parameters: {
path: {
/** @description The name of full-resolution image file to get */
/** @description The name of image to get */
image_name: string;
};
};
responses: {
/** @description Return the full-resolution image */
/** @description Successful Response */
200: {
content: {
"image/png": unknown;
"application/json": components["schemas"]["ImageDTO"];
};
};
/** @description Image not found */
404: never;
/** @description Validation Error */
422: {
content: {
@ -5135,23 +5137,25 @@ export type operations = {
};
};
/**
* Get Image Metadata
* @description Gets an image's metadata
* Get Image Full Size
* @description Gets a full-resolution image file
*/
get_image_metadata: {
get_image_full_size: {
parameters: {
path: {
/** @description The name of image to get */
/** @description The name of full-resolution image file to get */
image_name: string;
};
};
responses: {
/** @description Successful Response */
/** @description Return the full-resolution image */
200: {
content: {
"application/json": components["schemas"]["ImageDTO"];
"image/png": unknown;
};
};
/** @description Image not found */
404: never;
/** @description Validation Error */
422: {
content: {
@ -5214,6 +5218,92 @@ export type operations = {
};
};
};
/**
* Get Many Images
* @description Gets a list of images
*/
get_many_images: {
parameters: {
query?: {
/** @description The origin of images to list */
image_origin?: components["schemas"]["ResourceOrigin"];
/** @description The categories of image to include */
categories?: (components["schemas"]["ImageCategory"])[];
/** @description Whether to list intermediate images */
is_intermediate?: boolean;
/** @description The board id to filter by, provide 'none' for images without a board */
board_id?: string;
/** @description The page offset */
offset?: number;
/** @description The number of images per page */
limit?: number;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Get Images By Names
* @description Gets a list of images
*/
get_images_by_names: {
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["GetImagesByNamesResult"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Delete Many Images
* @description Deletes many images
*/
delete_many_images: {
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["DeleteManyImagesResult"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* List Boards
* @description Gets a list of boards
@ -5315,7 +5405,7 @@ export type operations = {
/** @description Successful Response */
200: {
content: {
"application/json": unknown;
"application/json": components["schemas"]["DeleteManyImagesResult"];
};
};
/** @description Validation Error */
@ -5357,14 +5447,46 @@ export type operations = {
};
};
};
/**
* Get All Board Images For Board
* @description Gets all image names for a board
*/
get_all_board_images_for_board: {
parameters: {
path: {
/** @description The id of the board */
board_id: string;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["GetAllBoardImagesForBoardResult"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Create Board Image
* @description Creates a board_image
*/
create_board_image: {
parameters: {
path: {
/** @description The id of the board to add to */
board_id: string;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["Body_create_board_image"];
"application/json": string;
};
};
responses: {
@ -5389,7 +5511,7 @@ export type operations = {
remove_board_image: {
requestBody: {
content: {
"application/json": components["schemas"]["Body_remove_board_image"];
"application/json": string;
};
};
responses: {
@ -5408,27 +5530,51 @@ export type operations = {
};
};
/**
* List Board Images
* @description Gets a list of images for a board
* Create Multiple Board Images
* @description Add many images to a board
*/
list_board_images: {
create_multiple_board_images: {
parameters: {
query?: {
/** @description The page offset */
offset?: number;
/** @description The number of boards per page */
limit?: number;
};
path: {
/** @description The id of the board */
board_id: string;
};
};
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: {
/** @description Successful Response */
200: {
/** @description The images were added to the board successfully */
201: {
content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/**
* Delete Multiple Board Images
* @description Remove many images from their boards, if they have one
*/
delete_multiple_board_images: {
requestBody: {
content: {
"application/json": (string)[];
};
};
responses: {
/** @description The images were removed from their boards successfully */
201: {
content: {
"application/json": unknown;
};
};
/** @description Validation Error */

View File

@ -0,0 +1,41 @@
import { createAppAsyncThunk } from 'app/store/storeUtils';
import { $client } from '../client';
import { components } from '../schema';
type Arg = { board_id: string };
type GetAllBoardImagesForBoardResult =
components['schemas']['GetAllBoardImagesForBoardResult'];
type GetImageUrlsThunkConfig = {
rejectValue: {
arg: Arg;
error: unknown;
};
};
/**
* Thunk to get image URLs
*/
export const boardImageNamesReceived = createAppAsyncThunk<
GetAllBoardImagesForBoardResult,
Arg,
GetImageUrlsThunkConfig
>('thunkApi/boardImageNamesReceived', async (arg, { rejectWithValue }) => {
const { get } = $client.get();
const { data, error, response } = await get(
'/api/v1/board_images/{board_id}',
{
params: {
path: {
board_id: arg.board_id,
},
},
}
);
if (error) {
return rejectWithValue({ arg, error });
}
return data;
});

View File

@ -1,9 +1,10 @@
import queryString from 'query-string';
import { createAppAsyncThunk } from 'app/store/storeUtils';
import { selectImagesAll } from 'features/gallery/store/gallerySlice';
import { size } from 'lodash-es';
import { paths } from 'services/api/schema';
import queryString from 'query-string';
import { $client } from 'services/api/client';
import { paths } from 'services/api/schema';
import { ImageCategory, OffsetPaginatedResults_ImageDTO_ } from '../types';
type GetImageUrlsArg =
paths['/api/v1/images/{image_name}/urls']['get']['parameters']['path'];
@ -24,7 +25,7 @@ export const imageUrlsReceived = createAppAsyncThunk<
GetImageUrlsResponse,
GetImageUrlsArg,
GetImageUrlsThunkConfig
>('api/imageUrlsReceived', async (arg, { rejectWithValue }) => {
>('thunkApi/imageUrlsReceived', async (arg, { rejectWithValue }) => {
const { image_name } = arg;
const { get } = $client.get();
const { data, error, response } = await get(
@ -45,34 +46,31 @@ export const imageUrlsReceived = createAppAsyncThunk<
return data;
});
type GetImageMetadataArg =
paths['/api/v1/images/{image_name}/metadata']['get']['parameters']['path'];
type GetImageDTOArg =
paths['/api/v1/images/{image_name}']['get']['parameters']['path'];
type GetImageMetadataResponse =
paths['/api/v1/images/{image_name}/metadata']['get']['responses']['200']['content']['application/json'];
type GetImageDTOResponse =
paths['/api/v1/images/{image_name}']['get']['responses']['200']['content']['application/json'];
type GetImageMetadataThunkConfig = {
type GetImageDTOThunkConfig = {
rejectValue: {
arg: GetImageMetadataArg;
arg: GetImageDTOArg;
error: unknown;
};
};
export const imageMetadataReceived = createAppAsyncThunk<
GetImageMetadataResponse,
GetImageMetadataArg,
GetImageMetadataThunkConfig
>('api/imageMetadataReceived', async (arg, { rejectWithValue }) => {
export const imageDTOReceived = createAppAsyncThunk<
GetImageDTOResponse,
GetImageDTOArg,
GetImageDTOThunkConfig
>('thunkApi/imageDTOReceived', async (arg, { rejectWithValue }) => {
const { image_name } = arg;
const { get } = $client.get();
const { data, error, response } = await get(
'/api/v1/images/{image_name}/metadata',
{
params: {
path: { image_name },
},
}
);
const { data, error, response } = await get('/api/v1/images/{image_name}', {
params: {
path: { image_name },
},
});
if (error) {
return rejectWithValue({ arg, error });
@ -127,13 +125,13 @@ export type PostUploadAction =
| AddToBatchAction;
type UploadImageArg =
paths['/api/v1/images/']['post']['parameters']['query'] & {
paths['/api/v1/images/upload']['post']['parameters']['query'] & {
file: File;
postUploadAction?: PostUploadAction;
};
type UploadImageResponse =
paths['/api/v1/images/']['post']['responses']['201']['content']['application/json'];
paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'];
type UploadImageThunkConfig = {
rejectValue: {
@ -148,7 +146,7 @@ export const imageUploaded = createAppAsyncThunk<
UploadImageResponse,
UploadImageArg,
UploadImageThunkConfig
>('api/imageUploaded', async (arg, { rejectWithValue }) => {
>('thunkApi/imageUploaded', async (arg, { rejectWithValue }) => {
const {
postUploadAction,
file,
@ -157,7 +155,7 @@ export const imageUploaded = createAppAsyncThunk<
session_id,
} = arg;
const { post } = $client.get();
const { data, error, response } = await post('/api/v1/images/', {
const { data, error, response } = await post('/api/v1/images/upload', {
params: {
query: {
image_category,
@ -199,7 +197,7 @@ export const imageDeleted = createAppAsyncThunk<
DeleteImageResponse,
DeleteImageArg,
DeleteImageThunkConfig
>('api/imageDeleted', async (arg, { rejectWithValue }) => {
>('thunkApi/imageDeleted', async (arg, { rejectWithValue }) => {
const { image_name } = arg;
const { del } = $client.get();
const { data, error, response } = await del('/api/v1/images/{image_name}', {
@ -235,7 +233,7 @@ export const imageUpdated = createAppAsyncThunk<
UpdateImageResponse,
UpdateImageArg,
UpdateImageThunkConfig
>('api/imageUpdated', async (arg, { rejectWithValue }) => {
>('thunkApi/imageUpdated', async (arg, { rejectWithValue }) => {
const { image_name, image_category, is_intermediate, session_id } = arg;
const { patch } = $client.get();
const { data, error, response } = await patch('/api/v1/images/{image_name}', {
@ -277,6 +275,7 @@ type ListImagesThunkConfig = {
error: unknown;
};
};
/**
* `ImagesService.listImagesWithMetadata()` thunk
*/
@ -284,46 +283,156 @@ export const receivedPageOfImages = createAppAsyncThunk<
ListImagesResponse,
ListImagesArg,
ListImagesThunkConfig
>('api/receivedPageOfImages', async (arg, { getState, rejectWithValue }) => {
const { get } = $client.get();
>(
'thunkApi/receivedPageOfImages',
async (arg, { getState, rejectWithValue }) => {
const { get } = $client.get();
const state = getState();
const { categories, selectedBoardId } = state.gallery;
const state = getState();
const { categories, selectedBoardId } = state.gallery;
const images = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
const images = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
let query: ListImagesArg = {};
let query: ListImagesArg = {};
if (size(arg)) {
query = {
...DEFAULT_IMAGES_LISTED_ARG,
offset: images.length,
...arg,
};
} else {
query = {
...DEFAULT_IMAGES_LISTED_ARG,
categories,
offset: images.length,
};
if (size(arg)) {
query = {
...DEFAULT_IMAGES_LISTED_ARG,
offset: images.length,
...arg,
};
} else {
query = {
...DEFAULT_IMAGES_LISTED_ARG,
categories,
offset: images.length,
};
}
const { data, error, response } = await get('/api/v1/images/', {
params: {
query,
},
querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }),
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
}
);
const { data, error, response } = await get('/api/v1/images/', {
params: {
query,
},
querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }),
});
export type ImagesLoadedArg = {
board_id: 'all' | 'none' | (string & Record<never, never>);
view: 'images' | 'assets';
offset: number;
limit?: number;
};
if (error) {
return rejectWithValue({ arg, error });
type ImagesLoadedThunkConfig = {
rejectValue: {
arg: ImagesLoadedArg;
error: unknown;
};
};
const getCategories = (view: 'images' | 'assets'): ImageCategory[] => {
if (view === 'images') {
return ['general'];
}
return ['control', 'mask', 'user', 'other'];
};
return data;
});
const getBoardId = (
board_id: 'all' | 'none' | (string & Record<never, never>)
) => {
if (board_id === 'all') {
return undefined;
}
if (board_id === 'none') {
return 'none';
}
return board_id;
};
/**
* `ImagesService.listImagesWithMetadata()` thunk
*/
export const imagesLoaded = createAppAsyncThunk<
OffsetPaginatedResults_ImageDTO_,
ImagesLoadedArg,
ImagesLoadedThunkConfig
>(
'thunkApi/imagesLoaded',
async (arg, { getState, rejectWithValue, requestId }) => {
const { get } = $client.get();
// TODO: do not make request if request in progress
const query = {
categories: getCategories(arg.view),
board_id: getBoardId(arg.board_id),
offset: arg.offset,
limit: arg.limit ?? IMAGES_PER_PAGE,
};
const { data, error, response } = await get('/api/v1/images/', {
params: {
query,
},
querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }),
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
}
);
type GetImagesByNamesArg = NonNullable<
paths['/api/v1/images/']['post']['requestBody']['content']['application/json']
>;
type GetImagesByNamesResponse =
paths['/api/v1/images/']['post']['responses']['200']['content']['application/json'];
type GetImagesByNamesThunkConfig = {
rejectValue: {
arg: GetImagesByNamesArg;
error: unknown;
};
};
/**
* `ImagesService.GetImagesByNamesWithMetadata()` thunk
*/
export const receivedListOfImages = createAppAsyncThunk<
GetImagesByNamesResponse,
GetImagesByNamesArg,
GetImagesByNamesThunkConfig
>(
'thunkApi/receivedListOfImages',
async (arg, { getState, rejectWithValue }) => {
const { post } = $client.get();
const { data, error, response } = await post('/api/v1/images/', {
body: arg,
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
}
);

View File

@ -5,8 +5,13 @@ const subtext = defineStyle((props) => ({
color: mode('colors.base.500', 'colors.base.400')(props),
}));
const destructive = defineStyle((props) => ({
color: mode('colors.error.600', 'colors.error.300')(props),
}));
export const textTheme = defineStyleConfig({
variants: {
subtext,
destructive,
},
});

View File

@ -1175,14 +1175,14 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061"
integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==
"@eslint-community/eslint-utils@^4.2.0":
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.3.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
dependencies:
eslint-visitor-keys "^3.3.0"
"@eslint-community/regexpp@^4.4.0":
"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.5.0":
version "4.5.1"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.5.1.tgz#cdd35dce4fa1a89a4fd42b1599eb35b3af408884"
integrity sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==
@ -1982,7 +1982,7 @@
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3"
integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==
"@types/json-schema@*", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.9":
"@types/json-schema@*", "@types/json-schema@^7.0.11", "@types/json-schema@^7.0.6":
version "7.0.12"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb"
integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==
@ -2086,49 +2086,53 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.2.tgz#ede1d1b1e451548d44919dc226253e32a6952c4b"
integrity sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==
"@typescript-eslint/eslint-plugin@^5.60.0":
version "5.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.60.0.tgz#2f4bea6a3718bed2ba52905358d0f45cd3620d31"
integrity sha512-78B+anHLF1TI8Jn/cD0Q00TBYdMgjdOn980JfAVa9yw5sop8nyTfVOQAv6LWywkOGLclDBtv5z3oxN4w7jxyNg==
"@typescript-eslint/eslint-plugin@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.0.0.tgz#19ff4f1cab8d6f8c2c1825150f7a840bc5d9bdc4"
integrity sha512-xuv6ghKGoiq856Bww/yVYnXGsKa588kY3M0XK7uUW/3fJNNULKRfZfSBkMTSpqGG/8ZCXCadfh8G/z/B4aqS/A==
dependencies:
"@eslint-community/regexpp" "^4.4.0"
"@typescript-eslint/scope-manager" "5.60.0"
"@typescript-eslint/type-utils" "5.60.0"
"@typescript-eslint/utils" "5.60.0"
"@eslint-community/regexpp" "^4.5.0"
"@typescript-eslint/scope-manager" "6.0.0"
"@typescript-eslint/type-utils" "6.0.0"
"@typescript-eslint/utils" "6.0.0"
"@typescript-eslint/visitor-keys" "6.0.0"
debug "^4.3.4"
grapheme-splitter "^1.0.4"
ignore "^5.2.0"
graphemer "^1.4.0"
ignore "^5.2.4"
natural-compare "^1.4.0"
natural-compare-lite "^1.4.0"
semver "^7.3.7"
tsutils "^3.21.0"
semver "^7.5.0"
ts-api-utils "^1.0.1"
"@typescript-eslint/parser@^5.60.0":
version "5.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.60.0.tgz#08f4daf5fc6548784513524f4f2f359cebb4068a"
integrity sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==
"@typescript-eslint/parser@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.0.0.tgz#46b2600fd1f67e62fc00a28093a75f41bf7effc4"
integrity sha512-TNaufYSPrr1U8n+3xN+Yp9g31vQDJqhXzzPSHfQDLcaO4tU+mCfODPxCwf4H530zo7aUBE3QIdxCXamEnG04Tg==
dependencies:
"@typescript-eslint/scope-manager" "5.60.0"
"@typescript-eslint/types" "5.60.0"
"@typescript-eslint/typescript-estree" "5.60.0"
"@typescript-eslint/scope-manager" "6.0.0"
"@typescript-eslint/types" "6.0.0"
"@typescript-eslint/typescript-estree" "6.0.0"
"@typescript-eslint/visitor-keys" "6.0.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@5.60.0":
version "5.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.60.0.tgz#ae511967b4bd84f1d5e179bb2c82857334941c1c"
integrity sha512-hakuzcxPwXi2ihf9WQu1BbRj1e/Pd8ZZwVTG9kfbxAMZstKz8/9OoexIwnmLzShtsdap5U/CoQGRCWlSuPbYxQ==
"@typescript-eslint/scope-manager@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.0.0.tgz#8ede47a37cb2b7ed82d329000437abd1113b5e11"
integrity sha512-o4q0KHlgCZTqjuaZ25nw5W57NeykZT9LiMEG4do/ovwvOcPnDO1BI5BQdCsUkjxFyrCL0cSzLjvIMfR9uo7cWg==
dependencies:
"@typescript-eslint/types" "5.60.0"
"@typescript-eslint/visitor-keys" "5.60.0"
"@typescript-eslint/types" "6.0.0"
"@typescript-eslint/visitor-keys" "6.0.0"
"@typescript-eslint/type-utils@5.60.0":
version "5.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.60.0.tgz#69b09087eb12d7513d5b07747e7d47f5533aa228"
integrity sha512-X7NsRQddORMYRFH7FWo6sA9Y/zbJ8s1x1RIAtnlj6YprbToTiQnM6vxcMu7iYhdunmoC0rUWlca13D5DVHkK2g==
"@typescript-eslint/type-utils@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.0.0.tgz#0478d8a94f05e51da2877cc0500f1b3c27ac7e18"
integrity sha512-ah6LJvLgkoZ/pyJ9GAdFkzeuMZ8goV6BH7eC9FPmojrnX9yNCIsfjB+zYcnex28YO3RFvBkV6rMV6WpIqkPvoQ==
dependencies:
"@typescript-eslint/typescript-estree" "5.60.0"
"@typescript-eslint/utils" "5.60.0"
"@typescript-eslint/typescript-estree" "6.0.0"
"@typescript-eslint/utils" "6.0.0"
debug "^4.3.4"
tsutils "^3.21.0"
ts-api-utils "^1.0.1"
"@typescript-eslint/types@4.33.0":
version "4.33.0"
@ -2140,18 +2144,23 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.60.0.tgz#3179962b28b4790de70e2344465ec97582ce2558"
integrity sha512-ascOuoCpNZBccFVNJRSC6rPq4EmJ2NkuoKnd6LDNyAQmdDnziAtxbCGWCbefG1CNzmDvd05zO36AmB7H8RzKPA==
"@typescript-eslint/typescript-estree@5.60.0", "@typescript-eslint/typescript-estree@^5.55.0":
version "5.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.0.tgz#4ddf1a81d32a850de66642d9b3ad1e3254fb1600"
integrity sha512-R43thAuwarC99SnvrBmh26tc7F6sPa2B3evkXp/8q954kYL6Ro56AwASYWtEEi+4j09GbiNAHqYwNNZuNlARGQ==
"@typescript-eslint/types@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.0.0.tgz#19795f515f8decbec749c448b0b5fc76d82445a1"
integrity sha512-Zk9KDggyZM6tj0AJWYYKgF0yQyrcnievdhG0g5FqyU3Y2DRxJn4yWY21sJC0QKBckbsdKKjYDV2yVrrEvuTgxg==
"@typescript-eslint/typescript-estree@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.0.0.tgz#1e09aab7320e404fb9f83027ea568ac24e372f81"
integrity sha512-2zq4O7P6YCQADfmJ5OTDQTP3ktajnXIRrYAtHM9ofto/CJZV3QfJ89GEaM2BNGeSr1KgmBuLhEkz5FBkS2RQhQ==
dependencies:
"@typescript-eslint/types" "5.60.0"
"@typescript-eslint/visitor-keys" "5.60.0"
"@typescript-eslint/types" "6.0.0"
"@typescript-eslint/visitor-keys" "6.0.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
semver "^7.3.7"
tsutils "^3.21.0"
semver "^7.5.0"
ts-api-utils "^1.0.1"
"@typescript-eslint/typescript-estree@^4.33.0":
version "4.33.0"
@ -2166,19 +2175,32 @@
semver "^7.3.5"
tsutils "^3.21.0"
"@typescript-eslint/utils@5.60.0":
"@typescript-eslint/typescript-estree@^5.55.0":
version "5.60.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.60.0.tgz#4667c5aece82f9d4f24a667602f0f300864b554c"
integrity sha512-ba51uMqDtfLQ5+xHtwlO84vkdjrqNzOnqrnwbMHMRY8Tqeme8C2Q8Fc7LajfGR+e3/4LoYiWXUM6BpIIbHJ4hQ==
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.0.tgz#4ddf1a81d32a850de66642d9b3ad1e3254fb1600"
integrity sha512-R43thAuwarC99SnvrBmh26tc7F6sPa2B3evkXp/8q954kYL6Ro56AwASYWtEEi+4j09GbiNAHqYwNNZuNlARGQ==
dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@types/json-schema" "^7.0.9"
"@types/semver" "^7.3.12"
"@typescript-eslint/scope-manager" "5.60.0"
"@typescript-eslint/types" "5.60.0"
"@typescript-eslint/typescript-estree" "5.60.0"
eslint-scope "^5.1.1"
"@typescript-eslint/visitor-keys" "5.60.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
semver "^7.3.7"
tsutils "^3.21.0"
"@typescript-eslint/utils@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.0.0.tgz#27a16d0d8f2719274a39417b9782f7daa3802db0"
integrity sha512-SOr6l4NB6HE4H/ktz0JVVWNXqCJTOo/mHnvIte1ZhBQ0Cvd04x5uKZa3zT6tiodL06zf5xxdK8COiDvPnQ27JQ==
dependencies:
"@eslint-community/eslint-utils" "^4.3.0"
"@types/json-schema" "^7.0.11"
"@types/semver" "^7.3.12"
"@typescript-eslint/scope-manager" "6.0.0"
"@typescript-eslint/types" "6.0.0"
"@typescript-eslint/typescript-estree" "6.0.0"
eslint-scope "^5.1.1"
semver "^7.5.0"
"@typescript-eslint/visitor-keys@4.33.0":
version "4.33.0"
@ -2196,6 +2218,14 @@
"@typescript-eslint/types" "5.60.0"
eslint-visitor-keys "^3.3.0"
"@typescript-eslint/visitor-keys@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.0.0.tgz#0b49026049fbd096d2c00c5e784866bc69532a31"
integrity sha512-cvJ63l8c0yXdeT5POHpL0Q1cZoRcmRKFCtSjNGJxPkcP571EfZMcNbzWAc7oK3D1dRzm/V5EwtkANTZxqvuuUA==
dependencies:
"@typescript-eslint/types" "6.0.0"
eslint-visitor-keys "^3.4.1"
"@vitejs/plugin-react-swc@^3.3.2":
version "3.3.2"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.3.2.tgz#34a82c1728066f48a86dfecb2f15df60f89207fb"
@ -3426,7 +3456,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994"
integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==
eslint@^8.43.0:
eslint@^8.44.0:
version "8.44.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.44.0.tgz#51246e3889b259bbcd1d7d736a0c10add4f0e500"
integrity sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==
@ -4069,7 +4099,7 @@ ieee754@^1.1.13:
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ignore@^5.2.0:
ignore@^5.2.0, ignore@^5.2.4:
version "5.2.4"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
@ -5795,6 +5825,13 @@ semver@^7.3.5, semver@^7.3.7:
dependencies:
lru-cache "^6.0.0"
semver@^7.5.0:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
semver@~7.3.0:
version "7.3.8"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
@ -6235,6 +6272,11 @@ tree-kill@^1.2.2:
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
ts-api-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.1.tgz#8144e811d44c749cd65b2da305a032510774452d"
integrity sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==
ts-easing@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec"
@ -6338,6 +6380,11 @@ typed-array-length@^1.0.4:
for-each "^0.3.3"
is-typed-array "^1.1.9"
typescript-eslint@^0.0.1-alpha.0:
version "0.0.1-alpha.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-0.0.1-alpha.0.tgz#285d68a4e96588295cd436278801bcb6a6b916c1"
integrity sha512-1hNKM37dAWML/2ltRXupOq2uqcdRQyDFphl+341NTPXFLLLiDhErXx8VtaSLh3xP7SyHZdcCgpt9boYYVb3fQg==
typescript@^3.9.10, typescript@^3.9.7:
version "3.9.10"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
@ -6348,6 +6395,11 @@ typescript@^4.0.0, typescript@^4.9.5:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
typescript@~5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b"