mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005402b0a7 | ||
|
|
a6baeba357 | ||
|
|
ccdd58838a | ||
|
|
e4170df91d | ||
|
|
190a7eef58 | ||
|
|
18a93a5164 | ||
|
|
a1cf3af732 | ||
|
|
4f191fe4b3 | ||
|
|
097d0da09f | ||
|
|
184bcfaf06 | ||
|
|
994236e9a8 | ||
|
|
74004aea04 | ||
|
|
ab27832d0c | ||
|
|
ef3d260657 | ||
|
|
4c462c2423 | ||
|
|
f797061390 | ||
|
|
83cca78e7c | ||
|
|
89cd24d3e2 | ||
|
|
ae3e9f0007 | ||
|
|
7a5d0a8973 | ||
|
|
515e270908 | ||
|
|
69cd265124 | ||
|
|
88164ed268 | ||
|
|
8566ede81a | ||
|
|
1690d10197 | ||
|
|
5ec022323a | ||
|
|
90815551d6 | ||
|
|
dac23c54c9 | ||
|
|
da116eb09b | ||
|
|
5f8e21e809 | ||
|
|
8d53cbbcdd | ||
|
|
baac5f06d6 | ||
|
|
cf781a3b99 | ||
|
|
1e47e6fe0a | ||
|
|
cb15841eaf | ||
|
|
527d89d07b | ||
|
|
d36f02a20f | ||
|
|
432c65795a | ||
|
|
c734924ea5 | ||
|
|
b61e6d5760 | ||
|
|
06289da0c9 | ||
|
|
d97eb84c4e | ||
|
|
15f212b9f0 | ||
|
|
5e573119ab | ||
|
|
20effc5da6 | ||
|
|
c2266da827 | ||
|
|
9d8e182227 | ||
|
|
49420d3449 | ||
|
|
7e04454106 | ||
|
|
78fa526312 | ||
|
|
8d0541b06e | ||
|
|
f5b093b980 | ||
|
|
906b2f852c | ||
|
|
83d41c8bd1 | ||
|
|
afb318fd76 | ||
|
|
00410d1376 | ||
|
|
1662063152 | ||
|
|
376e5836b8 | ||
|
|
1a79109998 | ||
|
|
d0ff7256c9 | ||
|
|
87ddaa602d | ||
|
|
32f268af20 | ||
|
|
006c90127b | ||
|
|
ff21055cfb | ||
|
|
92d16ffa96 | ||
|
|
e8a64ac766 | ||
|
|
fd42db0b1b | ||
|
|
5ad3f611b6 | ||
|
|
6046ac2f75 | ||
|
|
933a45e0fe | ||
|
|
177f879bb3 | ||
|
|
fe09ff3501 | ||
|
|
5313aac7f8 | ||
|
|
8ece17f39f | ||
|
|
d18c4cb6e5 | ||
|
|
1a8bf3cac8 | ||
|
|
194e42ac99 | ||
|
|
25e002e4d6 | ||
|
|
2206bc543e | ||
|
|
7547c17758 | ||
|
|
3d58da6d18 | ||
|
|
2b06f252be | ||
|
|
265f74e642 | ||
|
|
c9ee9d9c27 | ||
|
|
72ab5268c7 | ||
|
|
237ae28373 | ||
|
|
f6e7a1bde7 | ||
|
|
4fb40fcf86 | ||
|
|
2a10c00117 | ||
|
|
46e6334e27 | ||
|
|
e0fbb9b916 | ||
|
|
607b292a16 | ||
|
|
a05b8cf536 | ||
|
|
2145dd217e | ||
|
|
7d58b37d68 | ||
|
|
c85f3f88db | ||
|
|
b7751a85c2 | ||
|
|
921dcd81b8 | ||
|
|
01acac8c4e | ||
|
|
3f7a9b7d82 | ||
|
|
21f8263ddf | ||
|
|
c2db93669c | ||
|
|
37c0961597 | ||
|
|
b13be4891a | ||
|
|
5cad4ed06d | ||
|
|
c8c8a79c07 | ||
|
|
22ff5f71cc | ||
|
|
b2ee934e8c | ||
|
|
19f8bb4795 | ||
|
|
85e17aa36b | ||
|
|
88680a75c9 | ||
|
|
b038c79451 | ||
|
|
088eea9a0e | ||
|
|
c575eb14ca | ||
|
|
ffb3dc2bcd | ||
|
|
b1b1d7c2a6 | ||
|
|
89b34cb225 | ||
|
|
aa4b0d6705 | ||
|
|
b97ad8518c | ||
|
|
eda3cc2306 | ||
|
|
ec564725b1 | ||
|
|
c2fc3c6328 | ||
|
|
fdd2051257 | ||
|
|
4d7213baf7 | ||
|
|
533018b14e | ||
|
|
d2b486cd8e | ||
|
|
e05b3cdd8a | ||
|
|
6c98b4b38d | ||
|
|
a10f5efd16 | ||
|
|
6354d363c5 | ||
|
|
161559a1fa | ||
|
|
c0c1649436 | ||
|
|
10105a7e4e | ||
|
|
ffb6e87d50 | ||
|
|
d667bb1741 | ||
|
|
52f8cf6840 | ||
|
|
1a3b3dad8a | ||
|
|
4957dd8fa2 | ||
|
|
30e22a0728 | ||
|
|
2b297d4d37 | ||
|
|
5cbac72167 | ||
|
|
3b3672e4ae | ||
|
|
e39ce1fee2 | ||
|
|
6e0e394095 | ||
|
|
3ec39c9b3f | ||
|
|
b19df9ddcf | ||
|
|
0c34a49c58 | ||
|
|
a0721835d6 | ||
|
|
6a534d5b4f | ||
|
|
0f680e16b6 | ||
|
|
83e82e25a6 | ||
|
|
b85736ccf3 | ||
|
|
2fb1d93038 | ||
|
|
05543b6073 | ||
|
|
95986e4aa0 | ||
|
|
f7bf459721 | ||
|
|
b266ce78f5 | ||
|
|
fcaf216a3d | ||
|
|
12fdc934ee | ||
|
|
dee7b74c59 | ||
|
|
1411395b06 | ||
|
|
e3a56c8b81 | ||
|
|
eb41fd1ca6 | ||
|
|
6a78739076 | ||
|
|
0794eb43e7 |
@@ -1,21 +1,12 @@
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult
|
||||
|
||||
board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
|
||||
|
||||
|
||||
class AddImagesToBoardResult(BaseModel):
|
||||
board_id: str = Field(description="The id of the board the images were added to")
|
||||
added_image_names: list[str] = Field(description="The image names that were added to the board")
|
||||
|
||||
|
||||
class RemoveImagesFromBoardResult(BaseModel):
|
||||
removed_image_names: list[str] = Field(description="The image names that were removed from their board")
|
||||
|
||||
|
||||
@board_images_router.post(
|
||||
"/",
|
||||
operation_id="add_image_to_board",
|
||||
@@ -23,17 +14,26 @@ class RemoveImagesFromBoardResult(BaseModel):
|
||||
201: {"description": "The image was added to a board successfully"},
|
||||
},
|
||||
status_code=201,
|
||||
response_model=AddImagesToBoardResult,
|
||||
)
|
||||
async def add_image_to_board(
|
||||
board_id: str = Body(description="The id of the board to add to"),
|
||||
image_name: str = Body(description="The name of the image to add"),
|
||||
):
|
||||
) -> AddImagesToBoardResult:
|
||||
"""Creates a board_image"""
|
||||
try:
|
||||
result = ApiDependencies.invoker.services.board_images.add_image_to_board(
|
||||
board_id=board_id, image_name=image_name
|
||||
added_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
|
||||
ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name)
|
||||
added_images.add(image_name)
|
||||
affected_boards.add(board_id)
|
||||
affected_boards.add(old_board_id)
|
||||
|
||||
return AddImagesToBoardResult(
|
||||
added_images=list(added_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
return result
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to add image to board")
|
||||
|
||||
@@ -45,14 +45,25 @@ async def add_image_to_board(
|
||||
201: {"description": "The image was removed from the board successfully"},
|
||||
},
|
||||
status_code=201,
|
||||
response_model=RemoveImagesFromBoardResult,
|
||||
)
|
||||
async def remove_image_from_board(
|
||||
image_name: str = Body(description="The name of the image to remove", embed=True),
|
||||
):
|
||||
) -> RemoveImagesFromBoardResult:
|
||||
"""Removes an image from its board, if it had one"""
|
||||
try:
|
||||
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
|
||||
return result
|
||||
removed_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
|
||||
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
|
||||
removed_images.add(image_name)
|
||||
affected_boards.add("none")
|
||||
affected_boards.add(old_board_id)
|
||||
return RemoveImagesFromBoardResult(
|
||||
removed_images=list(removed_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to remove image from board")
|
||||
|
||||
@@ -72,16 +83,25 @@ async def add_images_to_board(
|
||||
) -> AddImagesToBoardResult:
|
||||
"""Adds a list of images to a board"""
|
||||
try:
|
||||
added_image_names: list[str] = []
|
||||
added_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
|
||||
ApiDependencies.invoker.services.board_images.add_image_to_board(
|
||||
board_id=board_id, image_name=image_name
|
||||
board_id=board_id,
|
||||
image_name=image_name,
|
||||
)
|
||||
added_image_names.append(image_name)
|
||||
added_images.add(image_name)
|
||||
affected_boards.add(board_id)
|
||||
affected_boards.add(old_board_id)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return AddImagesToBoardResult(board_id=board_id, added_image_names=added_image_names)
|
||||
return AddImagesToBoardResult(
|
||||
added_images=list(added_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to add images to board")
|
||||
|
||||
@@ -100,13 +120,20 @@ async def remove_images_from_board(
|
||||
) -> RemoveImagesFromBoardResult:
|
||||
"""Removes a list of images from their board, if they had one"""
|
||||
try:
|
||||
removed_image_names: list[str] = []
|
||||
removed_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
|
||||
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
|
||||
removed_image_names.append(image_name)
|
||||
removed_images.add(image_name)
|
||||
affected_boards.add("none")
|
||||
affected_boards.add(old_board_id)
|
||||
except Exception:
|
||||
pass
|
||||
return RemoveImagesFromBoardResult(removed_image_names=removed_image_names)
|
||||
return RemoveImagesFromBoardResult(
|
||||
removed_images=list(removed_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to remove images from board")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import io
|
||||
import json
|
||||
import traceback
|
||||
from typing import ClassVar, Optional
|
||||
from typing import ClassVar, Literal, Optional
|
||||
|
||||
from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -14,10 +14,17 @@ from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_i
|
||||
from invokeai.app.invocations.fields import MetadataField
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageCollectionCounts,
|
||||
ImageRecordChanges,
|
||||
ResourceOrigin,
|
||||
)
|
||||
from invokeai.app.services.images.images_common import ImageDTO, ImageUrlsDTO
|
||||
from invokeai.app.services.images.images_common import (
|
||||
DeleteImagesResult,
|
||||
ImageDTO,
|
||||
ImageUrlsDTO,
|
||||
StarredImagesResult,
|
||||
UnstarredImagesResult,
|
||||
)
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.util.controlnet_utils import heuristic_resize_fast
|
||||
@@ -153,18 +160,30 @@ async def create_image_upload_entry(
|
||||
raise HTTPException(status_code=501, detail="Not implemented")
|
||||
|
||||
|
||||
@images_router.delete("/i/{image_name}", operation_id="delete_image")
|
||||
@images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult)
|
||||
async def delete_image(
|
||||
image_name: str = Path(description="The name of the image to delete"),
|
||||
) -> None:
|
||||
) -> DeleteImagesResult:
|
||||
"""Deletes an image"""
|
||||
|
||||
deleted_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
|
||||
try:
|
||||
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
|
||||
board_id = image_dto.board_id or "none"
|
||||
ApiDependencies.invoker.services.images.delete(image_name)
|
||||
deleted_images.add(image_name)
|
||||
affected_boards.add(board_id)
|
||||
except Exception:
|
||||
# TODO: Does this need any exception handling at all?
|
||||
pass
|
||||
|
||||
return DeleteImagesResult(
|
||||
deleted_images=list(deleted_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
|
||||
|
||||
@images_router.delete("/intermediates", operation_id="clear_intermediates")
|
||||
async def clear_intermediates() -> int:
|
||||
@@ -376,31 +395,32 @@ async def list_image_dtos(
|
||||
return image_dtos
|
||||
|
||||
|
||||
class DeleteImagesFromListResult(BaseModel):
|
||||
deleted_images: list[str]
|
||||
|
||||
|
||||
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesFromListResult)
|
||||
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult)
|
||||
async def delete_images_from_list(
|
||||
image_names: list[str] = Body(description="The list of names of images to delete", embed=True),
|
||||
) -> DeleteImagesFromListResult:
|
||||
) -> DeleteImagesResult:
|
||||
try:
|
||||
deleted_images: list[str] = []
|
||||
deleted_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
|
||||
board_id = image_dto.board_id or "none"
|
||||
ApiDependencies.invoker.services.images.delete(image_name)
|
||||
deleted_images.append(image_name)
|
||||
deleted_images.add(image_name)
|
||||
affected_boards.add(board_id)
|
||||
except Exception:
|
||||
pass
|
||||
return DeleteImagesFromListResult(deleted_images=deleted_images)
|
||||
return DeleteImagesResult(
|
||||
deleted_images=list(deleted_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete images")
|
||||
|
||||
|
||||
@images_router.delete(
|
||||
"/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesFromListResult
|
||||
)
|
||||
async def delete_uncategorized_images() -> DeleteImagesFromListResult:
|
||||
@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult)
|
||||
async def delete_uncategorized_images() -> DeleteImagesResult:
|
||||
"""Deletes all images that are uncategorized"""
|
||||
|
||||
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
@@ -408,14 +428,19 @@ async def delete_uncategorized_images() -> DeleteImagesFromListResult:
|
||||
)
|
||||
|
||||
try:
|
||||
deleted_images: list[str] = []
|
||||
deleted_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
ApiDependencies.invoker.services.images.delete(image_name)
|
||||
deleted_images.append(image_name)
|
||||
deleted_images.add(image_name)
|
||||
affected_boards.add("none")
|
||||
except Exception:
|
||||
pass
|
||||
return DeleteImagesFromListResult(deleted_images=deleted_images)
|
||||
return DeleteImagesResult(
|
||||
deleted_images=list(deleted_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete images")
|
||||
|
||||
@@ -424,36 +449,50 @@ class ImagesUpdatedFromListResult(BaseModel):
|
||||
updated_image_names: list[str] = Field(description="The image names that were updated")
|
||||
|
||||
|
||||
@images_router.post("/star", operation_id="star_images_in_list", response_model=ImagesUpdatedFromListResult)
|
||||
@images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult)
|
||||
async def star_images_in_list(
|
||||
image_names: list[str] = Body(description="The list of names of images to star", embed=True),
|
||||
) -> ImagesUpdatedFromListResult:
|
||||
) -> StarredImagesResult:
|
||||
try:
|
||||
updated_image_names: list[str] = []
|
||||
starred_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=True))
|
||||
updated_image_names.append(image_name)
|
||||
updated_image_dto = ApiDependencies.invoker.services.images.update(
|
||||
image_name, changes=ImageRecordChanges(starred=True)
|
||||
)
|
||||
starred_images.add(image_name)
|
||||
affected_boards.add(updated_image_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
|
||||
return StarredImagesResult(
|
||||
starred_images=list(starred_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to star images")
|
||||
|
||||
|
||||
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=ImagesUpdatedFromListResult)
|
||||
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult)
|
||||
async def unstar_images_in_list(
|
||||
image_names: list[str] = Body(description="The list of names of images to unstar", embed=True),
|
||||
) -> ImagesUpdatedFromListResult:
|
||||
) -> UnstarredImagesResult:
|
||||
try:
|
||||
updated_image_names: list[str] = []
|
||||
unstarred_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=False))
|
||||
updated_image_names.append(image_name)
|
||||
updated_image_dto = ApiDependencies.invoker.services.images.update(
|
||||
image_name, changes=ImageRecordChanges(starred=False)
|
||||
)
|
||||
unstarred_images.add(image_name)
|
||||
affected_boards.add(updated_image_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
|
||||
return UnstarredImagesResult(
|
||||
unstarred_images=list(unstarred_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to unstar images")
|
||||
|
||||
@@ -524,3 +563,92 @@ async def get_bulk_download_item(
|
||||
return response
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
@images_router.get(
|
||||
"/collections/counts", operation_id="get_image_collection_counts", response_model=ImageCollectionCounts
|
||||
)
|
||||
async def get_image_collection_counts(
|
||||
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to count."),
|
||||
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: Optional[bool] = Query(default=None, description="Whether to include intermediate images."),
|
||||
board_id: Optional[str] = Query(
|
||||
default=None,
|
||||
description="The board id to filter by. Use 'none' to find images without a board.",
|
||||
),
|
||||
search_term: Optional[str] = Query(default=None, description="The term to search for"),
|
||||
) -> ImageCollectionCounts:
|
||||
"""Gets counts for starred and unstarred image collections"""
|
||||
|
||||
try:
|
||||
return ApiDependencies.invoker.services.images.get_collection_counts(
|
||||
image_origin=image_origin,
|
||||
categories=categories,
|
||||
is_intermediate=is_intermediate,
|
||||
board_id=board_id,
|
||||
search_term=search_term,
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to get collection counts")
|
||||
|
||||
|
||||
@images_router.get("/collections/{collection}", operation_id="get_image_collection")
|
||||
async def get_image_collection(
|
||||
collection: Literal["starred", "unstarred"] = Path(..., description="The collection to retrieve from"),
|
||||
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
|
||||
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
|
||||
board_id: Optional[str] = Query(
|
||||
default=None,
|
||||
description="The board id to filter by. Use 'none' to find images without a board.",
|
||||
),
|
||||
offset: int = Query(default=0, description="The offset within the collection"),
|
||||
limit: int = Query(default=50, description="The number of images to return"),
|
||||
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
|
||||
search_term: Optional[str] = Query(default=None, description="The term to search for"),
|
||||
) -> OffsetPaginatedResults[ImageDTO]:
|
||||
"""Gets images from a specific collection (starred or unstarred)"""
|
||||
|
||||
try:
|
||||
image_dtos = ApiDependencies.invoker.services.images.get_collection_images(
|
||||
collection=collection,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
order_dir=order_dir,
|
||||
image_origin=image_origin,
|
||||
categories=categories,
|
||||
is_intermediate=is_intermediate,
|
||||
board_id=board_id,
|
||||
search_term=search_term,
|
||||
)
|
||||
return image_dtos
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to get collection images")
|
||||
|
||||
|
||||
@images_router.get("/names", operation_id="get_image_names")
|
||||
async def get_image_names(
|
||||
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
|
||||
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
|
||||
board_id: Optional[str] = Query(
|
||||
default=None,
|
||||
description="The board id to filter by. Use 'none' to find images without a board.",
|
||||
),
|
||||
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
|
||||
search_term: Optional[str] = Query(default=None, description="The term to search for"),
|
||||
) -> list[str]:
|
||||
"""Gets ordered list of all image names (starred first, then unstarred)"""
|
||||
|
||||
try:
|
||||
image_names = ApiDependencies.invoker.services.images.get_image_names(
|
||||
order_dir=order_dir,
|
||||
image_origin=image_origin,
|
||||
categories=categories,
|
||||
is_intermediate=is_intermediate,
|
||||
board_id=board_id,
|
||||
search_term=search_term,
|
||||
)
|
||||
return image_names
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to get image names")
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from invokeai.app.invocations.fields import MetadataField
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageCollectionCounts,
|
||||
ImageRecord,
|
||||
ImageRecordChanges,
|
||||
ResourceOrigin,
|
||||
@@ -97,3 +98,44 @@ class ImageRecordStorageBase(ABC):
|
||||
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
|
||||
"""Gets the most recent image for a board."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_collection_counts(
|
||||
self,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> ImageCollectionCounts:
|
||||
"""Gets counts for starred and unstarred image collections."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_collection_images(
|
||||
self,
|
||||
collection: Literal["starred", "unstarred"],
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[ImageRecord]:
|
||||
"""Gets images from a specific collection (starred or unstarred)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_image_names(
|
||||
self,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
"""Gets ordered list of all image names (starred first, then unstarred)."""
|
||||
pass
|
||||
|
||||
@@ -3,7 +3,7 @@ import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import Field, StrictBool, StrictStr
|
||||
from pydantic import BaseModel, Field, StrictBool, StrictStr
|
||||
|
||||
from invokeai.app.util.metaenum import MetaEnum
|
||||
from invokeai.app.util.misc import get_iso_timestamp
|
||||
@@ -207,3 +207,8 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord:
|
||||
starred=starred,
|
||||
has_workflow=has_workflow,
|
||||
)
|
||||
|
||||
|
||||
class ImageCollectionCounts(BaseModel):
|
||||
starred_count: int = Field(description="The number of starred images in the collection.")
|
||||
unstarred_count: int = Field(description="The number of unstarred images in the collection.")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union, cast
|
||||
from typing import Literal, Optional, Union, cast
|
||||
|
||||
from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator
|
||||
from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
IMAGE_DTO_COLS,
|
||||
ImageCategory,
|
||||
ImageCollectionCounts,
|
||||
ImageRecord,
|
||||
ImageRecordChanges,
|
||||
ImageRecordDeleteException,
|
||||
@@ -386,3 +387,253 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
return None
|
||||
|
||||
return deserialize_image_record(dict(result))
|
||||
|
||||
def get_collection_counts(
|
||||
self,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> ImageCollectionCounts:
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
# Build the base query conditions (same as get_many)
|
||||
base_query = """--sql
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
query_conditions = ""
|
||||
query_params: list[Union[int, str, bool]] = []
|
||||
|
||||
if image_origin is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.image_origin = ?
|
||||
"""
|
||||
query_params.append(image_origin.value)
|
||||
|
||||
if categories is not None:
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
query_conditions += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
for c in category_strings:
|
||||
query_params.append(c)
|
||||
|
||||
if is_intermediate is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
query_params.append(is_intermediate)
|
||||
|
||||
if board_id == "none":
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id IS NULL
|
||||
"""
|
||||
elif board_id is not None:
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
query_params.append(board_id)
|
||||
|
||||
if search_term:
|
||||
query_conditions += """--sql
|
||||
AND (
|
||||
images.metadata LIKE ?
|
||||
OR images.created_at LIKE ?
|
||||
)
|
||||
"""
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
|
||||
# Get starred count
|
||||
starred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = TRUE;"
|
||||
cursor.execute(starred_query, query_params)
|
||||
starred_count = cast(int, cursor.fetchone()[0])
|
||||
|
||||
# Get unstarred count
|
||||
unstarred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = FALSE;"
|
||||
cursor.execute(unstarred_query, query_params)
|
||||
unstarred_count = cast(int, cursor.fetchone()[0])
|
||||
|
||||
return ImageCollectionCounts(starred_count=starred_count, unstarred_count=unstarred_count)
|
||||
|
||||
def get_collection_images(
|
||||
self,
|
||||
collection: Literal["starred", "unstarred"],
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[ImageRecord]:
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
# Base queries
|
||||
count_query = """--sql
|
||||
SELECT COUNT(*)
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
images_query = f"""--sql
|
||||
SELECT {IMAGE_DTO_COLS}
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
query_conditions = ""
|
||||
query_params: list[Union[int, str, bool]] = []
|
||||
|
||||
# Add starred/unstarred filter
|
||||
is_starred = collection == "starred"
|
||||
query_conditions += """--sql
|
||||
AND images.starred = ?
|
||||
"""
|
||||
query_params.append(is_starred)
|
||||
|
||||
if image_origin is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.image_origin = ?
|
||||
"""
|
||||
query_params.append(image_origin.value)
|
||||
|
||||
if categories is not None:
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
query_conditions += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
for c in category_strings:
|
||||
query_params.append(c)
|
||||
|
||||
if is_intermediate is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
query_params.append(is_intermediate)
|
||||
|
||||
if board_id == "none":
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id IS NULL
|
||||
"""
|
||||
elif board_id is not None:
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
query_params.append(board_id)
|
||||
|
||||
if search_term:
|
||||
query_conditions += """--sql
|
||||
AND (
|
||||
images.metadata LIKE ?
|
||||
OR images.created_at LIKE ?
|
||||
)
|
||||
"""
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
|
||||
# Add ordering and pagination
|
||||
query_pagination = f"""--sql
|
||||
ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ?
|
||||
"""
|
||||
|
||||
# Execute images query
|
||||
images_query += query_conditions + query_pagination + ";"
|
||||
images_params = query_params.copy()
|
||||
images_params.extend([limit, offset])
|
||||
|
||||
cursor.execute(images_query, images_params)
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
images = [deserialize_image_record(dict(r)) for r in result]
|
||||
|
||||
# Execute count query
|
||||
count_query += query_conditions + ";"
|
||||
cursor.execute(count_query, query_params)
|
||||
count = cast(int, cursor.fetchone()[0])
|
||||
|
||||
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
|
||||
|
||||
def get_image_names(
|
||||
self,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
# Base query to get image names in order (starred first, then unstarred)
|
||||
query = """--sql
|
||||
SELECT images.image_name
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
query_conditions = ""
|
||||
query_params: list[Union[int, str, bool]] = []
|
||||
|
||||
if image_origin is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.image_origin = ?
|
||||
"""
|
||||
query_params.append(image_origin.value)
|
||||
|
||||
if categories is not None:
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
query_conditions += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
for c in category_strings:
|
||||
query_params.append(c)
|
||||
|
||||
if is_intermediate is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
query_params.append(is_intermediate)
|
||||
|
||||
if board_id == "none":
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id IS NULL
|
||||
"""
|
||||
elif board_id is not None:
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
query_params.append(board_id)
|
||||
|
||||
if search_term:
|
||||
query_conditions += """--sql
|
||||
AND (
|
||||
images.metadata LIKE ?
|
||||
OR images.created_at LIKE ?
|
||||
)
|
||||
"""
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
|
||||
# Order by starred first, then by created_at
|
||||
query += (
|
||||
query_conditions
|
||||
+ f"""--sql
|
||||
ORDER BY images.starred DESC, images.created_at {order_dir.value}
|
||||
"""
|
||||
)
|
||||
|
||||
cursor.execute(query, query_params)
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
|
||||
return [row[0] for row in result]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, Literal, Optional
|
||||
|
||||
from PIL.Image import Image as PILImageType
|
||||
|
||||
from invokeai.app.invocations.fields import MetadataField
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageCollectionCounts,
|
||||
ImageRecord,
|
||||
ImageRecordChanges,
|
||||
ResourceOrigin,
|
||||
@@ -125,7 +126,7 @@ class ImageServiceABC(ABC):
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[ImageDTO]:
|
||||
"""Gets a paginated list of image DTOs."""
|
||||
"""Gets a paginated list of image DTOs with starred images first when starred_first=True."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -147,3 +148,44 @@ class ImageServiceABC(ABC):
|
||||
def delete_images_on_board(self, board_id: str):
|
||||
"""Deletes all images on a board."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_collection_counts(
|
||||
self,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> ImageCollectionCounts:
|
||||
"""Gets counts for starred and unstarred image collections."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_collection_images(
|
||||
self,
|
||||
collection: Literal["starred", "unstarred"],
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[ImageDTO]:
|
||||
"""Gets images from a specific collection (starred or unstarred)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_image_names(
|
||||
self,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
"""Gets ordered list of all image names (starred first, then unstarred)."""
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageRecord
|
||||
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
|
||||
@@ -39,3 +39,27 @@ def image_record_to_dto(
|
||||
thumbnail_url=thumbnail_url,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
|
||||
class ResultWithAffectedBoards(BaseModel):
|
||||
affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation")
|
||||
|
||||
|
||||
class DeleteImagesResult(ResultWithAffectedBoards):
|
||||
deleted_images: list[str] = Field(description="The names of the images that were deleted")
|
||||
|
||||
|
||||
class StarredImagesResult(ResultWithAffectedBoards):
|
||||
starred_images: list[str] = Field(description="The names of the images that were starred")
|
||||
|
||||
|
||||
class UnstarredImagesResult(ResultWithAffectedBoards):
|
||||
unstarred_images: list[str] = Field(description="The names of the images that were unstarred")
|
||||
|
||||
|
||||
class AddImagesToBoardResult(ResultWithAffectedBoards):
|
||||
added_images: list[str] = Field(description="The image names that were added to the board")
|
||||
|
||||
|
||||
class RemoveImagesFromBoardResult(ResultWithAffectedBoards):
|
||||
removed_images: list[str] = Field(description="The image names that were removed from their board")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from PIL.Image import Image as PILImageType
|
||||
|
||||
@@ -10,6 +10,7 @@ from invokeai.app.services.image_files.image_files_common import (
|
||||
)
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageCollectionCounts,
|
||||
ImageRecord,
|
||||
ImageRecordChanges,
|
||||
ImageRecordDeleteException,
|
||||
@@ -309,3 +310,90 @@ class ImageService(ImageServiceABC):
|
||||
except Exception as e:
|
||||
self.__invoker.services.logger.error("Problem getting intermediates count")
|
||||
raise e
|
||||
|
||||
def get_collection_counts(
|
||||
self,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> ImageCollectionCounts:
|
||||
try:
|
||||
return self.__invoker.services.image_records.get_collection_counts(
|
||||
image_origin=image_origin,
|
||||
categories=categories,
|
||||
is_intermediate=is_intermediate,
|
||||
board_id=board_id,
|
||||
search_term=search_term,
|
||||
)
|
||||
except Exception as e:
|
||||
self.__invoker.services.logger.error("Problem getting collection counts")
|
||||
raise e
|
||||
|
||||
def get_collection_images(
|
||||
self,
|
||||
collection: Literal["starred", "unstarred"],
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[ImageDTO]:
|
||||
try:
|
||||
results = self.__invoker.services.image_records.get_collection_images(
|
||||
collection=collection,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
order_dir=order_dir,
|
||||
image_origin=image_origin,
|
||||
categories=categories,
|
||||
is_intermediate=is_intermediate,
|
||||
board_id=board_id,
|
||||
search_term=search_term,
|
||||
)
|
||||
|
||||
image_dtos = [
|
||||
image_record_to_dto(
|
||||
image_record=r,
|
||||
image_url=self.__invoker.services.urls.get_image_url(r.image_name),
|
||||
thumbnail_url=self.__invoker.services.urls.get_image_url(r.image_name, True),
|
||||
board_id=self.__invoker.services.board_image_records.get_board_for_image(r.image_name),
|
||||
)
|
||||
for r in results.items
|
||||
]
|
||||
|
||||
return OffsetPaginatedResults[ImageDTO](
|
||||
items=image_dtos,
|
||||
offset=results.offset,
|
||||
limit=results.limit,
|
||||
total=results.total,
|
||||
)
|
||||
except Exception as e:
|
||||
self.__invoker.services.logger.error("Problem getting collection images")
|
||||
raise e
|
||||
|
||||
def get_image_names(
|
||||
self,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
image_origin: Optional[ResourceOrigin] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
try:
|
||||
return self.__invoker.services.image_records.get_image_names(
|
||||
order_dir=order_dir,
|
||||
image_origin=image_origin,
|
||||
categories=categories,
|
||||
is_intermediate=is_intermediate,
|
||||
board_id=board_id,
|
||||
search_term=search_term,
|
||||
)
|
||||
except Exception as e:
|
||||
self.__invoker.services.logger.error("Problem getting image names")
|
||||
raise e
|
||||
|
||||
@@ -12,11 +12,13 @@ module.exports = {
|
||||
// TODO: ENABLE THIS RULE BEFORE v6.0.0
|
||||
// 'i18next/no-literal-string': 'error',
|
||||
// https://eslint.org/docs/latest/rules/no-console
|
||||
'no-console': 'error',
|
||||
'no-console': 'warn',
|
||||
// https://eslint.org/docs/latest/rules/no-promise-executor-return
|
||||
'no-promise-executor-return': 'error',
|
||||
// https://eslint.org/docs/latest/rules/require-await
|
||||
'require-await': 'error',
|
||||
// TODO: ENABLE THIS RULE BEFORE v6.0.0
|
||||
'react/display-name': 'off',
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -7,11 +7,13 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { memo } from 'react';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const GlobalImageHotkeys = memo(() => {
|
||||
useAssertSingleton('GlobalImageHotkeys');
|
||||
const imageDTO = useAppSelector(selectLastSelectedImage);
|
||||
const imageName = useAppSelector(selectLastSelectedImage);
|
||||
const imageDTO = useImageDTO(imageName);
|
||||
|
||||
if (!imageDTO) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '@fontsource-variable/inter';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import '@xyflow/react/dist/base.css';
|
||||
import 'common/components/OverlayScrollbars/overlayscrollbars.css';
|
||||
|
||||
import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -15,8 +15,6 @@ import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMi
|
||||
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
|
||||
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
|
||||
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
|
||||
import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred';
|
||||
import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred';
|
||||
import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded';
|
||||
import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
|
||||
import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
|
||||
@@ -47,10 +45,6 @@ addImageUploadedFulfilledListener(startAppListening);
|
||||
// Image deleted
|
||||
addDeleteBoardAndImagesFulfilledListener(startAppListening);
|
||||
|
||||
// Image starred
|
||||
addImagesStarredListener(startAppListening);
|
||||
addImagesUnstarredListener(startAppListening);
|
||||
|
||||
// Gallery
|
||||
addGalleryImageClickedListener(startAppListening);
|
||||
addGalleryOffsetChangedListener(startAppListening);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
|
||||
matcher: matchAnyBoardDeleted,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
const state = getState();
|
||||
const deletedBoardId = action.meta.arg.originalArgs;
|
||||
const deletedBoardId = action.meta.arg.originalArgs.board_id;
|
||||
const { autoAddBoardId, selectedBoardId } = state.gallery;
|
||||
|
||||
// If the deleted board was currently selected, we should reset the selected board to uncategorized
|
||||
|
||||
@@ -30,9 +30,9 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
|
||||
const selectedImage = boardImagesData.items.find(
|
||||
(item) => item.image_name === action.payload.selectedImageName
|
||||
);
|
||||
dispatch(imageSelected(selectedImage || null));
|
||||
dispatch(imageSelected(selectedImage?.image_name ?? null));
|
||||
} else if (boardImagesData) {
|
||||
dispatch(imageSelected(boardImagesData.items[0] || null));
|
||||
dispatch(imageSelected(boardImagesData.items[0]?.image_name ?? null));
|
||||
} else {
|
||||
// board has no images - deselect
|
||||
dispatch(imageSelected(null));
|
||||
|
||||
@@ -9,7 +9,7 @@ export const addEnsureImageIsSelectedListener = (startAppListening: AppStartList
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
const selection = getState().gallery.selection;
|
||||
if (selection.length === 0) {
|
||||
dispatch(imageSelected(action.payload.items[0] ?? null));
|
||||
dispatch(imageSelected(action.payload.items[0]?.image_name ?? null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { ImageCategory, SQLiteDirection } from 'services/api/types';
|
||||
|
||||
// Type for image collection query arguments
|
||||
type ImageCollectionQueryArgs = {
|
||||
board_id?: string;
|
||||
categories?: ImageCategory[];
|
||||
search_term?: string;
|
||||
order_dir?: SQLiteDirection;
|
||||
is_intermediate: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get cached image names list for selection operations
|
||||
* Returns an ordered array of image names (starred first, then unstarred)
|
||||
*/
|
||||
const getCachedImageNames = (state: RootState, queryArgs: ImageCollectionQueryArgs): string[] => {
|
||||
const queryResult = imagesApi.endpoints.getImageNames.select(queryArgs)(state);
|
||||
return queryResult.data || [];
|
||||
};
|
||||
|
||||
export const galleryImageClicked = createAction<{
|
||||
imageDTO: ImageDTO;
|
||||
imageName: string;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
@@ -28,45 +48,51 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
|
||||
startAppListening({
|
||||
actionCreator: galleryImageClicked,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
|
||||
const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
|
||||
const state = getState();
|
||||
const queryArgs = selectListImagesQueryArgs(state);
|
||||
const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state);
|
||||
const queryArgs = selectImageCollectionQueryArgs(state);
|
||||
|
||||
if (!queryResult.data) {
|
||||
// Should never happen if we have clicked a gallery image
|
||||
// Get cached image names for selection operations
|
||||
const imageNames = getCachedImageNames(state, queryArgs);
|
||||
|
||||
// If we don't have the image names cached, we can't perform selection operations
|
||||
// This can happen if the user clicks on an image before the names are loaded
|
||||
if (imageNames.length === 0) {
|
||||
// For basic click without modifiers, we can still set selection
|
||||
if (!shiftKey && !ctrlKey && !metaKey && !altKey) {
|
||||
dispatch(selectionChanged([imageName]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const imageDTOs = queryResult.data.items;
|
||||
const selection = state.gallery.selection;
|
||||
|
||||
if (altKey) {
|
||||
if (state.gallery.imageToCompare?.image_name === imageDTO.image_name) {
|
||||
if (state.gallery.imageToCompare === imageName) {
|
||||
dispatch(imageToCompareChanged(null));
|
||||
} else {
|
||||
dispatch(imageToCompareChanged(imageDTO));
|
||||
dispatch(imageToCompareChanged(imageName));
|
||||
}
|
||||
} else if (shiftKey) {
|
||||
const rangeEndImageName = imageDTO.image_name;
|
||||
const lastSelectedImage = selection[selection.length - 1]?.image_name;
|
||||
const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage);
|
||||
const currentClickedIndex = imageDTOs.findIndex((n) => n.image_name === rangeEndImageName);
|
||||
const rangeEndImageName = imageName;
|
||||
const lastSelectedImage = selection.at(-1);
|
||||
const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage);
|
||||
const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName);
|
||||
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
|
||||
// We have a valid range!
|
||||
const start = Math.min(lastClickedIndex, currentClickedIndex);
|
||||
const end = Math.max(lastClickedIndex, currentClickedIndex);
|
||||
const imagesToSelect = imageDTOs.slice(start, end + 1);
|
||||
dispatch(selectionChanged(selection.concat(imagesToSelect)));
|
||||
const imagesToSelect = imageNames.slice(start, end + 1);
|
||||
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect))));
|
||||
}
|
||||
} else if (ctrlKey || metaKey) {
|
||||
if (selection.some((i) => i.image_name === imageDTO.image_name) && selection.length > 1) {
|
||||
dispatch(selectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name)));
|
||||
if (selection.some((n) => n === imageName) && selection.length > 1) {
|
||||
dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName))));
|
||||
} else {
|
||||
dispatch(selectionChanged(selection.concat(imageDTO)));
|
||||
dispatch(selectionChanged(uniq(selection.concat(imageName))));
|
||||
}
|
||||
} else {
|
||||
dispatch(selectionChanged([imageDTO]));
|
||||
dispatch(selectionChanged([imageName]));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,14 +84,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe
|
||||
if (offset < prevOffset) {
|
||||
// We've gone backwards
|
||||
const lastImage = imageDTOs[imageDTOs.length - 1];
|
||||
if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) {
|
||||
dispatch(selectionChanged(lastImage ? [lastImage] : []));
|
||||
if (!selection.some((selectedImage) => selectedImage === lastImage?.image_name)) {
|
||||
dispatch(selectionChanged(lastImage ? [lastImage.image_name] : []));
|
||||
}
|
||||
} else {
|
||||
// We've gone forwards
|
||||
const firstImage = imageDTOs[0];
|
||||
if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) {
|
||||
dispatch(selectionChanged(firstImage ? [firstImage] : []));
|
||||
if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) {
|
||||
dispatch(selectionChanged(firstImage ? [firstImage.image_name] : []));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -102,14 +102,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe
|
||||
if (offset < prevOffset) {
|
||||
// We've gone backwards
|
||||
const lastImage = imageDTOs[imageDTOs.length - 1];
|
||||
if (lastImage && imageToCompare?.image_name !== lastImage.image_name) {
|
||||
dispatch(imageToCompareChanged(lastImage));
|
||||
if (lastImage && imageToCompare !== lastImage.image_name) {
|
||||
dispatch(imageToCompareChanged(lastImage.image_name));
|
||||
}
|
||||
} else {
|
||||
// We've gone forwards
|
||||
const firstImage = imageDTOs[0];
|
||||
if (firstImage && imageToCompare?.image_name !== firstImage.image_name) {
|
||||
dispatch(imageToCompareChanged(firstImage));
|
||||
if (firstImage && imageToCompare !== firstImage.image_name) {
|
||||
dispatch(imageToCompareChanged(firstImage.image_name));
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -8,16 +8,16 @@ export const addImageAddedToBoardFulfilledListener = (startAppListening: AppStar
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
|
||||
effect: (action) => {
|
||||
const { board_id, imageDTO } = action.meta.arg.originalArgs;
|
||||
log.debug({ board_id, imageDTO }, 'Image added to board');
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
log.debug({ board_id, image_name }, 'Image added to board');
|
||||
},
|
||||
});
|
||||
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
|
||||
effect: (action) => {
|
||||
const { board_id, imageDTO } = action.meta.arg.originalArgs;
|
||||
log.debug({ board_id, imageDTO }, 'Problem adding image to board');
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
log.debug({ board_id, image_name }, 'Problem adding image to board');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const addImagesStarredListener = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.starImages.matchFulfilled,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
const { updated_image_names: starredImages } = action.payload;
|
||||
|
||||
const state = getState();
|
||||
|
||||
const { selection } = state.gallery;
|
||||
const updatedSelection: ImageDTO[] = [];
|
||||
|
||||
selection.forEach((selectedImageDTO) => {
|
||||
if (starredImages.includes(selectedImageDTO.image_name)) {
|
||||
updatedSelection.push({
|
||||
...selectedImageDTO,
|
||||
starred: true,
|
||||
});
|
||||
} else {
|
||||
updatedSelection.push(selectedImageDTO);
|
||||
}
|
||||
});
|
||||
dispatch(selectionChanged(updatedSelection));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const addImagesUnstarredListener = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.unstarImages.matchFulfilled,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
const { updated_image_names: unstarredImages } = action.payload;
|
||||
|
||||
const state = getState();
|
||||
|
||||
const { selection } = state.gallery;
|
||||
const updatedSelection: ImageDTO[] = [];
|
||||
|
||||
selection.forEach((selectedImageDTO) => {
|
||||
if (unstarredImages.includes(selectedImageDTO.image_name)) {
|
||||
updatedSelection.push({
|
||||
...selectedImageDTO,
|
||||
starred: false,
|
||||
});
|
||||
} else {
|
||||
updatedSelection.push(selectedImageDTO);
|
||||
}
|
||||
});
|
||||
dispatch(selectionChanged(updatedSelection));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
.os-scrollbar {
|
||||
/* The size of the scrollbar */
|
||||
--os-size: 9px;
|
||||
--os-size: 8px;
|
||||
/* The axis-perpedicular padding of the scrollbar (horizontal: padding-y, vertical: padding-x) */
|
||||
/* --os-padding-perpendicular: 0; */
|
||||
/* The axis padding of the scrollbar (horizontal: padding-x, vertical: padding-y) */
|
||||
@@ -8,11 +8,11 @@
|
||||
/* The border radius of the scrollbar track */
|
||||
/* --os-track-border-radius: 0; */
|
||||
/* The background of the scrollbar track */
|
||||
/* --os-track-bg: rgba(0, 0, 0, 0.3); */
|
||||
--os-track-bg: rgba(0, 0, 0, 0.5);
|
||||
/* The :hover background of the scrollbar track */
|
||||
/* --os-track-bg-hover: rgba(0, 0, 0, 0.3); */
|
||||
--os-track-bg-hover: rgba(0, 0, 0, 0.5);
|
||||
/* The :active background of the scrollbar track */
|
||||
/* --os-track-bg-active: rgba(0, 0, 0, 0.3); */
|
||||
--os-track-bg-active: rgba(0, 0, 0, 0.6);
|
||||
/* The border of the scrollbar track */
|
||||
/* --os-track-border: none; */
|
||||
/* The :hover background of the scrollbar track */
|
||||
@@ -22,11 +22,11 @@
|
||||
/* The border radius of the scrollbar handle */
|
||||
/* --os-handle-border-radius: 2px; */
|
||||
/* The background of the scrollbar handle */
|
||||
/* --os-handle-bg: var(--invokeai-colors-accentAlpha-500); */
|
||||
--os-handle-bg: var(--invoke-colors-base-400);
|
||||
/* The :hover background of the scrollbar handle */
|
||||
/* --os-handle-bg-hover: var(--invokeai-colors-accentAlpha-700); */
|
||||
--os-handle-bg-hover: var(--invoke-colors-base-300);
|
||||
/* The :active background of the scrollbar handle */
|
||||
/* --os-handle-bg-active: var(--invokeai-colors-accentAlpha-800); */
|
||||
--os-handle-bg-active: var(--invoke-colors-base-250);
|
||||
/* The border of the scrollbar handle */
|
||||
/* --os-handle-border: none; */
|
||||
/* The :hover border of the scrollbar handle */
|
||||
@@ -34,23 +34,23 @@
|
||||
/* The :active border of the scrollbar handle */
|
||||
/* --os-handle-border-active: none; */
|
||||
/* The min size of the scrollbar handle */
|
||||
--os-handle-min-size: 50px;
|
||||
--os-handle-min-size: 32px;
|
||||
/* The max size of the scrollbar handle */
|
||||
/* --os-handle-max-size: none; */
|
||||
/* The axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
|
||||
/* --os-handle-perpendicular-size: 100%; */
|
||||
/* The :hover axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
|
||||
/* --os-handle-perpendicular-size-hover: 100%; */
|
||||
--os-handle-perpendicular-size-hover: 100%;
|
||||
/* The :active axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
|
||||
/* --os-handle-perpendicular-size-active: 100%; */
|
||||
/* Increases the interactive area of the scrollbar handle. */
|
||||
/* --os-handle-interactive-area-offset: 0; */
|
||||
--os-handle-interactive-area-offset: -1px;
|
||||
}
|
||||
|
||||
.os-scrollbar-handle {
|
||||
cursor: grab;
|
||||
/* cursor: grab; */
|
||||
}
|
||||
|
||||
.os-scrollbar-handle:active {
|
||||
cursor: grabbing;
|
||||
/* cursor: grabbing; */
|
||||
}
|
||||
|
||||
28
invokeai/frontend/web/src/common/hooks/useSelectorAsAtom.ts
Normal file
28
invokeai/frontend/web/src/common/hooks/useSelectorAsAtom.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Selector } from '@reduxjs/toolkit';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import type { Atom, WritableAtom } from 'nanostores';
|
||||
import { atom } from 'nanostores';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
export const useSelectorAsAtom = <T extends Selector<RootState, any>>(selector: T): Atom<ReturnType<T>> => {
|
||||
const store = useAppStore();
|
||||
const $atom = useState<WritableAtom<ReturnType<T>>>(() => atom<ReturnType<T>>(selector(store.getState())))[0];
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
const prev = $atom.get();
|
||||
const next = selector(store.getState());
|
||||
if (prev !== next) {
|
||||
$atom.set(next);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [$atom, selector, store]);
|
||||
|
||||
return $atom;
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 's
|
||||
|
||||
const selectImagesToChange = createMemoizedSelector(
|
||||
selectChangeBoardModalSlice,
|
||||
(changeBoardModal) => changeBoardModal.imagesToChange
|
||||
(changeBoardModal) => changeBoardModal.image_names
|
||||
);
|
||||
|
||||
const selectIsModalOpen = createSelector(
|
||||
@@ -57,10 +57,10 @@ const ChangeBoardModal = () => {
|
||||
}
|
||||
|
||||
if (selectedBoard === 'none') {
|
||||
removeImagesFromBoard({ imageDTOs: imagesToChange });
|
||||
removeImagesFromBoard({ image_names: imagesToChange });
|
||||
} else {
|
||||
addImagesToBoard({
|
||||
imageDTOs: imagesToChange,
|
||||
image_names: imagesToChange,
|
||||
board_id: selectedBoard,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import type { ChangeBoardModalState } from './types';
|
||||
|
||||
export const initialState: ChangeBoardModalState = {
|
||||
isModalOpen: false,
|
||||
imagesToChange: [],
|
||||
image_names: [],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { initialState } from './initialState';
|
||||
|
||||
@@ -12,11 +11,11 @@ export const changeBoardModalSlice = createSlice({
|
||||
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isModalOpen = action.payload;
|
||||
},
|
||||
imagesToChangeSelected: (state, action: PayloadAction<ImageDTO[]>) => {
|
||||
state.imagesToChange = action.payload;
|
||||
imagesToChangeSelected: (state, action: PayloadAction<string[]>) => {
|
||||
state.image_names = action.payload;
|
||||
},
|
||||
changeBoardReset: (state) => {
|
||||
state.imagesToChange = [];
|
||||
state.image_names = [];
|
||||
state.isModalOpen = false;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export type ChangeBoardModalState = {
|
||||
isModalOpen: boolean;
|
||||
imagesToChange: ImageDTO[];
|
||||
image_names: string[];
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { QueueItemProgressImage } from 'features/controlLayers/components/Simple
|
||||
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
|
||||
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
@@ -46,12 +47,28 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
|
||||
ctx.$selectedItemId.set(item.item_id);
|
||||
}, [ctx.$selectedItemId, item.item_id]);
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
const autoSwitch = ctx.$autoSwitch.get();
|
||||
if (autoSwitch !== 'off') {
|
||||
ctx.$autoSwitch.set('off');
|
||||
toast({
|
||||
title: 'Auto-Switch Disabled',
|
||||
});
|
||||
}
|
||||
}, [ctx.$autoSwitch]);
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
ctx.onImageLoad(item.item_id);
|
||||
}, [ctx, item.item_id]);
|
||||
|
||||
return (
|
||||
<Flex id={getQueueItemElementId(item.item_id)} sx={sx} data-selected={isSelected} onClick={onClick}>
|
||||
<Flex
|
||||
id={getQueueItemElementId(item.item_id)}
|
||||
sx={sx}
|
||||
data-selected={isSelected}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
|
||||
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail />}
|
||||
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { buildZodTypeGuard } from 'common/util/zodUtils';
|
||||
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import type { ProgressImage } from 'features/nodes/types/common';
|
||||
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
|
||||
@@ -13,6 +14,11 @@ import { queueApi } from 'services/api/endpoints/queue';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import { $socket } from 'services/events/stores';
|
||||
import { assert, objectEntries } from 'tsafe';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
|
||||
export const isAutoSwitchMode = buildZodTypeGuard(zAutoSwitchMode);
|
||||
export type AutoSwitchMode = z.infer<typeof zAutoSwitchMode>;
|
||||
|
||||
export type ProgressData = {
|
||||
itemId: number;
|
||||
@@ -91,7 +97,7 @@ type CanvasSessionContextValue = {
|
||||
$selectedItem: Atom<S['SessionQueueItem'] | null>;
|
||||
$selectedItemIndex: Atom<number | null>;
|
||||
$selectedItemOutputImageDTO: Atom<ImageDTO | null>;
|
||||
$autoSwitch: WritableAtom<boolean>;
|
||||
$autoSwitch: WritableAtom<AutoSwitchMode>;
|
||||
selectNext: () => void;
|
||||
selectPrev: () => void;
|
||||
selectFirst: () => void;
|
||||
@@ -116,8 +122,17 @@ export const CanvasSessionContextProvider = memo(
|
||||
const store = useAppStore();
|
||||
|
||||
const socket = useStore($socket);
|
||||
|
||||
/**
|
||||
* Track the last completed item. Used to implement autoswitch.
|
||||
*/
|
||||
const $lastCompletedItemId = useState(() => atom<number | null>(null))[0];
|
||||
|
||||
/**
|
||||
* Track the last started item. Used to implement autoswitch.
|
||||
*/
|
||||
const $lastStartedItemId = useState(() => atom<number | null>(null))[0];
|
||||
|
||||
/**
|
||||
* Manually-synced atom containing queue items for the current session. This is populated from the RTK Query cache
|
||||
* and kept in sync with it via a redux subscription.
|
||||
@@ -127,7 +142,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
/**
|
||||
* Whether auto-switch is enabled.
|
||||
*/
|
||||
const $autoSwitch = useState(() => atom(true))[0];
|
||||
const $autoSwitch = useState(() => atom<AutoSwitchMode>('switch_on_start'))[0];
|
||||
|
||||
/**
|
||||
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
|
||||
@@ -277,12 +292,12 @@ export const CanvasSessionContextProvider = memo(
|
||||
imageLoaded: true,
|
||||
});
|
||||
}
|
||||
if ($lastCompletedItemId.get() === itemId) {
|
||||
if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') {
|
||||
$selectedItemId.set(itemId);
|
||||
$lastCompletedItemId.set(null);
|
||||
}
|
||||
},
|
||||
[$lastCompletedItemId, $progressData, $selectedItemId]
|
||||
[$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId]
|
||||
);
|
||||
|
||||
// Set up socket listeners
|
||||
@@ -305,6 +320,9 @@ export const CanvasSessionContextProvider = memo(
|
||||
if (data.status === 'completed') {
|
||||
$lastCompletedItemId.set(data.item_id);
|
||||
}
|
||||
if (data.status === 'in_progress') {
|
||||
$lastStartedItemId.set(data.item_id);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('invocation_progress', onProgress);
|
||||
@@ -314,7 +332,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
socket.off('invocation_progress', onProgress);
|
||||
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
|
||||
};
|
||||
}, [$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId, session.id, socket]);
|
||||
}, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
|
||||
|
||||
// Set up state subscriptions and effects
|
||||
useEffect(() => {
|
||||
@@ -333,29 +351,39 @@ export const CanvasSessionContextProvider = memo(
|
||||
});
|
||||
|
||||
// Handle cases that could result in a nonexistent queue item being selected.
|
||||
const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => {
|
||||
// If there are no items, cannot have a selected item.
|
||||
if (items.length === 0) {
|
||||
$selectedItemId.set(null);
|
||||
return;
|
||||
}
|
||||
// If there is no selected item but there are items, select the first one.
|
||||
if (selectedItemId === null && items.length > 0) {
|
||||
$selectedItemId.set(items[0]?.item_id ?? null);
|
||||
return;
|
||||
}
|
||||
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
|
||||
// the above case, selecting the first item if there are any.
|
||||
if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
|
||||
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
|
||||
if (prevIndex >= items.length) {
|
||||
prevIndex = items.length - 1;
|
||||
const unsubEnsureSelectedItemIdExists = effect(
|
||||
[$items, $selectedItemId, $lastStartedItemId],
|
||||
(items, selectedItemId, lastStartedItemId) => {
|
||||
// If there are no items, cannot have a selected item.
|
||||
if (items.length === 0) {
|
||||
$selectedItemId.set(null);
|
||||
return;
|
||||
}
|
||||
// If there is no selected item but there are items, select the first one.
|
||||
if (selectedItemId === null && items.length > 0) {
|
||||
$selectedItemId.set(items[0]?.item_id ?? null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
$autoSwitch.get() === 'switch_on_start' &&
|
||||
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
|
||||
) {
|
||||
$selectedItemId.set(lastStartedItemId);
|
||||
$lastStartedItemId.set(null);
|
||||
}
|
||||
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
|
||||
// the above case, selecting the first item if there are any.
|
||||
if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
|
||||
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
|
||||
if (prevIndex >= items.length) {
|
||||
prevIndex = items.length - 1;
|
||||
}
|
||||
const nextItem = items[prevIndex];
|
||||
$selectedItemId.set(nextItem?.item_id ?? null);
|
||||
return;
|
||||
}
|
||||
const nextItem = items[prevIndex];
|
||||
$selectedItemId.set(nextItem?.item_id ?? null);
|
||||
return;
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// Clean up the progress data when a queue item is discarded.
|
||||
const unsubCleanUpProgressData = $items.subscribe(async (items) => {
|
||||
@@ -438,7 +466,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
if (lastLoadedItemId === null) {
|
||||
return;
|
||||
}
|
||||
if ($autoSwitch.get()) {
|
||||
if ($autoSwitch.get() === 'switch_on_finish') {
|
||||
$selectedItemId.set(lastLoadedItemId);
|
||||
}
|
||||
$lastLoadedItemId.set(null);
|
||||
@@ -461,7 +489,17 @@ export const CanvasSessionContextProvider = memo(
|
||||
$progressData.set({});
|
||||
$selectedItemId.set(null);
|
||||
};
|
||||
}, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]);
|
||||
}, [
|
||||
$autoSwitch,
|
||||
$items,
|
||||
$lastLoadedItemId,
|
||||
$lastStartedItemId,
|
||||
$progressData,
|
||||
$selectedItemId,
|
||||
selectQueueItems,
|
||||
session.id,
|
||||
store,
|
||||
]);
|
||||
|
||||
const value = useMemo<CanvasSessionContextValue>(
|
||||
() => ({
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { isImageField } from 'features/nodes/types/common';
|
||||
import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils';
|
||||
import { round } from 'lodash-es';
|
||||
import type { S } from 'services/api/types';
|
||||
import { formatProgressMessage } from 'services/events/stores';
|
||||
import { objectEntries } from 'tsafe';
|
||||
|
||||
export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) => {
|
||||
if (!data) {
|
||||
return 'Generating';
|
||||
}
|
||||
|
||||
let message = data.message;
|
||||
if (data.percentage) {
|
||||
message += ` (${round(data.percentage * 100)}%)`;
|
||||
}
|
||||
return message;
|
||||
return formatProgressMessage(data);
|
||||
};
|
||||
|
||||
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { isAutoSwitchMode, useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
export const StagingAreaToolbarMenuAutoSwitch = memo(() => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
@@ -9,18 +10,22 @@ export const StagingAreaToolbarMenuAutoSwitch = memo(() => {
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string | string[]) => {
|
||||
ctx.$autoSwitch.set(val === 'on');
|
||||
assert(isAutoSwitchMode(val));
|
||||
ctx.$autoSwitch.set(val);
|
||||
},
|
||||
[ctx.$autoSwitch]
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuOptionGroup value={autoSwitch ? 'on' : 'off'} onChange={onChange} title="Auto Switch" type="radio">
|
||||
<MenuOptionGroup value={autoSwitch} onChange={onChange} title="Auto-Switch" type="radio">
|
||||
<MenuItemOption value="off" closeOnSelect={false}>
|
||||
Off
|
||||
</MenuItemOption>
|
||||
<MenuItemOption value="on" closeOnSelect={false}>
|
||||
On
|
||||
<MenuItemOption value="switch_on_start" closeOnSelect={false}>
|
||||
Switch on Start
|
||||
</MenuItemOption>
|
||||
<MenuItemOption value="switch_on_finish" closeOnSelect={false}>
|
||||
Switch on Finish
|
||||
</MenuItemOption>
|
||||
</MenuOptionGroup>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export const CanvasToolbarSaveToGalleryButton = memo(() => {
|
||||
onClick={shift ? saveBboxToGallery : saveCanvasToGallery}
|
||||
icon={<PiFloppyDiskBold />}
|
||||
aria-label={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
|
||||
colorScheme="invokeBlue"
|
||||
tooltip={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Mutex } from 'async-mutex';
|
||||
import type { ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import type { ProgressData, ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
|
||||
@@ -135,8 +135,8 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging));
|
||||
};
|
||||
|
||||
connectToSession = ($selectedItemId: Atom<number | null>, $progressData: ProgressDataMap) =>
|
||||
effect([$selectedItemId, $progressData], (selectedItemId, progressData) => {
|
||||
connectToSession = ($selectedItemId: Atom<number | null>, $progressData: ProgressDataMap) => {
|
||||
const cb = (selectedItemId: number | null, progressData: Record<number, ProgressData | undefined>) => {
|
||||
if (!selectedItemId) {
|
||||
this.$imageSrc.set(null);
|
||||
return;
|
||||
@@ -153,7 +153,14 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
} else {
|
||||
this.$imageSrc.set(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Run the effect & forcibly render once to initialize
|
||||
cb($selectedItemId.get(), $progressData.get());
|
||||
this.render();
|
||||
|
||||
return effect([$selectedItemId, $progressData], cb);
|
||||
};
|
||||
|
||||
private _getImageFromSrc = (
|
||||
{ type, data }: ImageNameSrc | DataURLSrc,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const DeleteImageModal = memo(() => {
|
||||
|
||||
return (
|
||||
<ConfirmationAlertDialog
|
||||
title={`${t('gallery.deleteImage', { count: state.imageDTOs.length })}2`}
|
||||
title={`${t('gallery.deleteImage', { count: state.image_names.length })}2`}
|
||||
isOpen={state.isOpen}
|
||||
onClose={api.close}
|
||||
cancelButtonText={t('common.cancel')}
|
||||
|
||||
@@ -19,17 +19,16 @@ import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from '
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { selectUpscaleSlice, type UpscaleState } from 'features/parameters/store/upscaleSlice';
|
||||
import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemSlice';
|
||||
import { forEach, intersectionBy, some } from 'lodash-es';
|
||||
import { forEach, intersection, some } from 'lodash-es';
|
||||
import { atom } from 'nanostores';
|
||||
import { useMemo } from 'react';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { Param0 } from 'tsafe';
|
||||
|
||||
// Implements an awaitable modal dialog for deleting images
|
||||
|
||||
type DeleteImagesModalState = {
|
||||
imageDTOs: ImageDTO[];
|
||||
image_names: string[];
|
||||
usagePerImage: ImageUsage[];
|
||||
usageSummary: ImageUsage;
|
||||
isOpen: boolean;
|
||||
@@ -38,7 +37,7 @@ type DeleteImagesModalState = {
|
||||
};
|
||||
|
||||
const getInitialState = (): DeleteImagesModalState => ({
|
||||
imageDTOs: [],
|
||||
image_names: [],
|
||||
usagePerImage: [],
|
||||
usageSummary: {
|
||||
isControlLayerImage: false,
|
||||
@@ -54,21 +53,21 @@ const getInitialState = (): DeleteImagesModalState => ({
|
||||
|
||||
const $deleteModalState = atom<DeleteImagesModalState>(getInitialState());
|
||||
|
||||
const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
|
||||
const deleteImagesWithDialog = async (image_names: string[]): Promise<void> => {
|
||||
const { getState, dispatch } = getStore();
|
||||
const imageUsage = getImageUsageFromImageDTOs(imageDTOs, getState());
|
||||
const imageUsage = getImageUsageFromImageNames(image_names, getState());
|
||||
const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState());
|
||||
|
||||
if (!shouldConfirmOnDelete && !isAnyImageInUse(imageUsage)) {
|
||||
// If we don't need to confirm and the images are not in use, delete them directly
|
||||
await handleDeletions(imageDTOs, dispatch, getState);
|
||||
await handleDeletions(image_names, dispatch, getState);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
$deleteModalState.set({
|
||||
usagePerImage: imageUsage,
|
||||
usageSummary: getImageUsageSummary(imageUsage),
|
||||
imageDTOs,
|
||||
image_names,
|
||||
isOpen: true,
|
||||
resolve,
|
||||
reject,
|
||||
@@ -76,12 +75,12 @@ const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, getState: AppGetState) => {
|
||||
const handleDeletions = async (image_names: string[], dispatch: AppDispatch, getState: AppGetState) => {
|
||||
try {
|
||||
const state = getState();
|
||||
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
|
||||
await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap();
|
||||
|
||||
if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) {
|
||||
if (intersection(state.gallery.selection, image_names).length > 0) {
|
||||
// Some selected images were deleted, need to select the next image
|
||||
const queryArgs = selectListImagesQueryArgs(state);
|
||||
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
|
||||
@@ -93,11 +92,11 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get
|
||||
}
|
||||
|
||||
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
|
||||
for (const imageDTO of imageDTOs) {
|
||||
deleteNodesImages(state, dispatch, imageDTO);
|
||||
deleteControlLayerImages(state, dispatch, imageDTO);
|
||||
deleteReferenceImages(state, dispatch, imageDTO);
|
||||
deleteRasterLayerImages(state, dispatch, imageDTO);
|
||||
for (const image_name of image_names) {
|
||||
deleteNodesImages(state, dispatch, image_name);
|
||||
deleteControlLayerImages(state, dispatch, image_name);
|
||||
deleteReferenceImages(state, dispatch, image_name);
|
||||
deleteRasterLayerImages(state, dispatch, image_name);
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
@@ -106,7 +105,7 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get
|
||||
|
||||
const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => {
|
||||
const state = $deleteModalState.get();
|
||||
await handleDeletions(state.imageDTOs, dispatch, getState);
|
||||
await handleDeletions(state.image_names, dispatch, getState);
|
||||
state.resolve?.();
|
||||
closeSilently();
|
||||
};
|
||||
@@ -142,8 +141,8 @@ export const useDeleteImageModalApi = () => {
|
||||
return api;
|
||||
};
|
||||
|
||||
const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): ImageUsage[] => {
|
||||
if (imageDTOs.length === 0) {
|
||||
const getImageUsageFromImageNames = (image_names: string[], state: RootState): ImageUsage[] => {
|
||||
if (image_names.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -152,7 +151,7 @@ const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): Im
|
||||
const upscale = selectUpscaleSlice(state);
|
||||
const refImages = selectRefImagesSlice(state);
|
||||
|
||||
return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
|
||||
return image_names.map((image_name) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
|
||||
};
|
||||
|
||||
const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({
|
||||
@@ -178,7 +177,7 @@ const isAnyImageInUse = (imageUsage: ImageUsage[]): boolean =>
|
||||
);
|
||||
|
||||
// Some utils to delete images from different parts of the app
|
||||
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
|
||||
const actions: Param0<typeof dispatch>[] = [];
|
||||
state.nodes.present.nodes.forEach((node) => {
|
||||
if (!isInvocationNode(node)) {
|
||||
@@ -186,7 +185,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
|
||||
}
|
||||
|
||||
forEach(node.data.inputs, (input) => {
|
||||
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
|
||||
if (isImageFieldInputInstance(input) && input.value?.image_name === image_name) {
|
||||
actions.push(
|
||||
fieldImageValueChanged({
|
||||
nodeId: node.data.id,
|
||||
@@ -201,7 +200,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
|
||||
fieldImageCollectionValueChanged({
|
||||
nodeId: node.data.id,
|
||||
fieldName: input.name,
|
||||
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
|
||||
value: input.value?.filter((value) => value?.image_name !== image_name),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -211,11 +210,11 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
|
||||
actions.forEach(dispatch);
|
||||
};
|
||||
|
||||
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
|
||||
selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => {
|
||||
let shouldDelete = false;
|
||||
for (const obj of objects) {
|
||||
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
|
||||
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) {
|
||||
shouldDelete = true;
|
||||
break;
|
||||
}
|
||||
@@ -226,19 +225,19 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image
|
||||
});
|
||||
};
|
||||
|
||||
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
|
||||
selectReferenceImageEntities(state).forEach((entity) => {
|
||||
if (entity.config.image?.image_name === imageDTO.image_name) {
|
||||
if (entity.config.image?.image_name === image_name) {
|
||||
dispatch(refImageImageChanged({ id: entity.id, imageDTO: null }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
|
||||
selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => {
|
||||
let shouldDelete = false;
|
||||
for (const obj of objects) {
|
||||
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
|
||||
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) {
|
||||
shouldDelete = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } f
|
||||
import { memo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { Param0 } from 'tsafe';
|
||||
|
||||
const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[] }) => {
|
||||
const DndDragPreviewMultipleImage = memo(({ image_names }: { image_names: string[] }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Flex
|
||||
@@ -21,7 +20,7 @@ const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[]
|
||||
bg="base.900"
|
||||
borderRadius="base"
|
||||
>
|
||||
<Heading>{imageDTOs.length}</Heading>
|
||||
<Heading>{image_names.length}</Heading>
|
||||
<Heading size="sm">{t('parameters.images')}</Heading>
|
||||
</Flex>
|
||||
);
|
||||
@@ -32,11 +31,11 @@ DndDragPreviewMultipleImage.displayName = 'DndDragPreviewMultipleImage';
|
||||
export type DndDragPreviewMultipleImageState = {
|
||||
type: 'multiple-image';
|
||||
container: HTMLElement;
|
||||
imageDTOs: ImageDTO[];
|
||||
image_names: string[];
|
||||
};
|
||||
|
||||
export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageState) =>
|
||||
createPortal(<DndDragPreviewMultipleImage imageDTOs={arg.imageDTOs} />, arg.container);
|
||||
createPortal(<DndDragPreviewMultipleImage image_names={arg.image_names} />, arg.container);
|
||||
|
||||
type SetMultipleDragPreviewArg = {
|
||||
multipleImageDndData: MultipleImageDndSourceData;
|
||||
@@ -52,7 +51,7 @@ export const setMultipleImageDragPreview = ({
|
||||
const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
|
||||
setCustomNativeDragPreview({
|
||||
render({ container }) {
|
||||
setDragPreviewState({ type: 'multiple-image', container, imageDTOs: multipleImageDndData.payload.imageDTOs });
|
||||
setDragPreviewState({ type: 'multiple-image', container, image_names: multipleImageDndData.payload.image_names });
|
||||
return () => setDragPreviewState(null);
|
||||
},
|
||||
nativeSetDragImage,
|
||||
|
||||
@@ -87,7 +87,7 @@ const _multipleImage = buildTypeAndKey('multiple-image');
|
||||
export type MultipleImageDndSourceData = DndData<
|
||||
typeof _multipleImage.type,
|
||||
typeof _multipleImage.key,
|
||||
{ imageDTOs: ImageDTO[]; boardId: BoardId }
|
||||
{ image_names: string[]; board_id: BoardId }
|
||||
>;
|
||||
export const multipleImageDndSource: DndSource<MultipleImageDndSourceData> = {
|
||||
..._multipleImage,
|
||||
@@ -305,7 +305,7 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget<
|
||||
if (singleImageDndSource.typeGuard(sourceData)) {
|
||||
newValue.push({ image_name: sourceData.payload.imageDTO.image_name });
|
||||
} else {
|
||||
newValue.push(...sourceData.payload.imageDTOs.map(({ image_name }) => ({ image_name })));
|
||||
newValue.push(...sourceData.payload.image_names.map((image_name) => ({ image_name })));
|
||||
}
|
||||
|
||||
dispatch(fieldImageCollectionValueChanged({ ...fieldIdentifier, value: newValue }));
|
||||
@@ -330,17 +330,17 @@ export const setComparisonImageDndTarget: DndTarget<SetComparisonImageDndTargetD
|
||||
}
|
||||
const { firstImage, secondImage } = selectComparisonImages(getState());
|
||||
// Do not allow the same images to be selected for comparison
|
||||
if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) {
|
||||
if (sourceData.payload.imageDTO.image_name === firstImage) {
|
||||
return false;
|
||||
}
|
||||
if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) {
|
||||
if (sourceData.payload.imageDTO.image_name === secondImage) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
handler: ({ sourceData, dispatch }) => {
|
||||
const { imageDTO } = sourceData.payload;
|
||||
setComparisonImage({ imageDTO, dispatch });
|
||||
setComparisonImage({ image_name: imageDTO.image_name, dispatch });
|
||||
},
|
||||
};
|
||||
//#endregion
|
||||
@@ -450,7 +450,7 @@ export const addImageToBoardDndTarget: DndTarget<
|
||||
return currentBoard !== destinationBoard;
|
||||
}
|
||||
if (multipleImageDndSource.typeGuard(sourceData)) {
|
||||
const currentBoard = sourceData.payload.boardId;
|
||||
const currentBoard = sourceData.payload.board_id;
|
||||
const destinationBoard = targetData.payload.boardId;
|
||||
return currentBoard !== destinationBoard;
|
||||
}
|
||||
@@ -460,13 +460,13 @@ export const addImageToBoardDndTarget: DndTarget<
|
||||
if (singleImageDndSource.typeGuard(sourceData)) {
|
||||
const { imageDTO } = sourceData.payload;
|
||||
const { boardId } = targetData.payload;
|
||||
addImagesToBoard({ imageDTOs: [imageDTO], boardId, dispatch });
|
||||
addImagesToBoard({ image_names: [imageDTO.image_name], boardId, dispatch });
|
||||
}
|
||||
|
||||
if (multipleImageDndSource.typeGuard(sourceData)) {
|
||||
const { imageDTOs } = sourceData.payload;
|
||||
const { image_names } = sourceData.payload;
|
||||
const { boardId } = targetData.payload;
|
||||
addImagesToBoard({ imageDTOs, boardId, dispatch });
|
||||
addImagesToBoard({ image_names, boardId, dispatch });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -494,7 +494,7 @@ export const removeImageFromBoardDndTarget: DndTarget<
|
||||
}
|
||||
|
||||
if (multipleImageDndSource.typeGuard(sourceData)) {
|
||||
const currentBoard = sourceData.payload.boardId;
|
||||
const currentBoard = sourceData.payload.board_id;
|
||||
return currentBoard !== 'none';
|
||||
}
|
||||
|
||||
@@ -503,12 +503,12 @@ export const removeImageFromBoardDndTarget: DndTarget<
|
||||
handler: ({ sourceData, dispatch }) => {
|
||||
if (singleImageDndSource.typeGuard(sourceData)) {
|
||||
const { imageDTO } = sourceData.payload;
|
||||
removeImagesFromBoard({ imageDTOs: [imageDTO], dispatch });
|
||||
removeImagesFromBoard({ image_names: [imageDTO.image_name], dispatch });
|
||||
}
|
||||
|
||||
if (multipleImageDndSource.typeGuard(sourceData)) {
|
||||
const { imageDTOs } = sourceData.payload;
|
||||
removeImagesFromBoard({ imageDTOs, dispatch });
|
||||
const { image_names } = sourceData.payload;
|
||||
removeImagesFromBoard({ image_names, dispatch });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -91,7 +91,7 @@ const DeleteBoardModal = () => {
|
||||
if (!boardToDelete || boardToDelete === 'none') {
|
||||
return;
|
||||
}
|
||||
deleteBoardOnly(boardToDelete.board_id);
|
||||
deleteBoardOnly({ board_id: boardToDelete.board_id });
|
||||
$boardToDelete.set(null);
|
||||
}, [boardToDelete, deleteBoardOnly]);
|
||||
|
||||
@@ -99,7 +99,7 @@ const DeleteBoardModal = () => {
|
||||
if (!boardToDelete || boardToDelete === 'none') {
|
||||
return;
|
||||
}
|
||||
deleteBoardAndImages(boardToDelete.board_id);
|
||||
deleteBoardAndImages({ board_id: boardToDelete.board_id });
|
||||
$boardToDelete.set(null);
|
||||
}, [boardToDelete, deleteBoardAndImages]);
|
||||
|
||||
|
||||
@@ -25,9 +25,8 @@ import { useBoardName } from 'services/api/hooks/useBoardName';
|
||||
|
||||
import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover';
|
||||
import { GalleryUploadButton } from './GalleryUploadButton';
|
||||
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
|
||||
import { GalleryPagination } from './ImageGrid/GalleryPagination';
|
||||
import { GallerySearch } from './ImageGrid/GallerySearch';
|
||||
import { NewGallery } from './NewGallery';
|
||||
|
||||
const BASE_STYLES: ChakraProps['sx'] = {
|
||||
fontWeight: 'semibold',
|
||||
@@ -71,7 +70,7 @@ export const GalleryPanel = memo(() => {
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full" p={2} minH={0}>
|
||||
<Tabs index={galleryView === 'images' ? 0 : 1} variant="enclosed" display="flex" flexDir="column" w="full">
|
||||
<Tabs index={galleryView === 'images' ? 0 : 1} variant="enclosed" display="flex" flexDir="column" w="full" pb={2}>
|
||||
<TabList gap={2} fontSize="sm" borderColor="base.800" alignItems="center" w="full">
|
||||
<Text fontSize="sm" fontWeight="semibold" noOfLines={1} px="2" wordBreak="break-all">
|
||||
{boardName}
|
||||
@@ -90,6 +89,7 @@ export const GalleryPanel = memo(() => {
|
||||
<Flex h="full" justifyContent="flex-end">
|
||||
<GalleryUploadButton />
|
||||
<GallerySettingsPopover />
|
||||
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="link"
|
||||
@@ -101,19 +101,20 @@ export const GalleryPanel = memo(() => {
|
||||
/>
|
||||
</Flex>
|
||||
</TabList>
|
||||
<Collapse in={searchDisclosure.isOpen} style={COLLAPSE_STYLES}>
|
||||
<Box w="full" pt={2}>
|
||||
<GallerySearch
|
||||
searchTerm={searchTerm}
|
||||
onChangeSearchTerm={onChangeSearchTerm}
|
||||
onResetSearchTerm={onResetSearchTerm}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Tabs>
|
||||
|
||||
<Collapse in={searchDisclosure.isOpen} style={COLLAPSE_STYLES}>
|
||||
<Box w="full" pt={2}>
|
||||
<GallerySearch
|
||||
searchTerm={searchTerm}
|
||||
onChangeSearchTerm={onChangeSearchTerm}
|
||||
onResetSearchTerm={onResetSearchTerm}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
<GalleryImageGrid />
|
||||
<GalleryPagination />
|
||||
{/* <GalleryImageGrid />
|
||||
<GalleryPagination /> */}
|
||||
<NewGallery />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ImageMenuItemChangeBoard = memo(() => {
|
||||
const imageDTO = useImageDTOContext();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(imagesToChangeSelected([imageDTO]));
|
||||
dispatch(imagesToChangeSelected([imageDTO.image_name]));
|
||||
dispatch(isModalOpenChanged(true));
|
||||
}, [dispatch, imageDTO]);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ImageMenuItemDelete = memo(() => {
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
try {
|
||||
await deleteImageModal.delete([imageDTO]);
|
||||
await deleteImageModal.delete([imageDTO.image_name]);
|
||||
} catch {
|
||||
// noop;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ImageMenuItemOpenInViewer = memo(() => {
|
||||
const imageDTO = useImageDTOContext();
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(imageToCompareChanged(null));
|
||||
dispatch(imageSelected(imageDTO));
|
||||
dispatch(imageSelected(imageDTO.image_name));
|
||||
// TODO: figure out how to select the closest image viewer...
|
||||
}, [dispatch, imageDTO]);
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ export const ImageMenuItemSelectForCompare = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const selectMaySelectForCompare = useMemo(
|
||||
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name !== imageDTO.image_name),
|
||||
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare !== imageDTO.image_name),
|
||||
[imageDTO.image_name]
|
||||
);
|
||||
const maySelectForCompare = useAppSelector(selectMaySelectForCompare);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(imageToCompareChanged(imageDTO));
|
||||
dispatch(imageToCompareChanged(imageDTO.image_name));
|
||||
}, [dispatch, imageDTO]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,13 +16,13 @@ export const ImageMenuItemStarUnstar = memo(() => {
|
||||
|
||||
const starImage = useCallback(() => {
|
||||
if (imageDTO) {
|
||||
starImages({ imageDTOs: [imageDTO] });
|
||||
starImages({ image_names: [imageDTO.image_name] });
|
||||
}
|
||||
}, [starImages, imageDTO]);
|
||||
|
||||
const unstarImage = useCallback(() => {
|
||||
if (imageDTO) {
|
||||
unstarImages({ imageDTOs: [imageDTO] });
|
||||
unstarImages({ image_names: [imageDTO.image_name] });
|
||||
}
|
||||
}, [unstarImages, imageDTO]);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice';
|
||||
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiDownloadSimpleBold, PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi';
|
||||
import {
|
||||
@@ -37,37 +37,25 @@ const MultipleSelectionMenuItems = () => {
|
||||
}, [deleteImageModal, selection]);
|
||||
|
||||
const handleStarSelection = useCallback(() => {
|
||||
starImages({ imageDTOs: selection });
|
||||
starImages({ image_names: selection });
|
||||
}, [starImages, selection]);
|
||||
|
||||
const handleUnstarSelection = useCallback(() => {
|
||||
unstarImages({ imageDTOs: selection });
|
||||
unstarImages({ image_names: selection });
|
||||
}, [unstarImages, selection]);
|
||||
|
||||
const handleBulkDownload = useCallback(() => {
|
||||
bulkDownload({ image_names: selection.map((img) => img.image_name) });
|
||||
bulkDownload({ image_names: selection });
|
||||
}, [selection, bulkDownload]);
|
||||
|
||||
const areAllStarred = useMemo(() => {
|
||||
return selection.every((img) => img.starred);
|
||||
}, [selection]);
|
||||
|
||||
const areAllUnstarred = useMemo(() => {
|
||||
return selection.every((img) => !img.starred);
|
||||
}, [selection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{areAllStarred && (
|
||||
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarBold />} onClickCapture={handleUnstarSelection}>
|
||||
{customStarUi ? customStarUi.off.text : `Unstar All`}
|
||||
</MenuItem>
|
||||
)}
|
||||
{(areAllUnstarred || (!areAllStarred && !areAllUnstarred)) && (
|
||||
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarFill />} onClickCapture={handleStarSelection}>
|
||||
{customStarUi ? customStarUi.on.text : `Star All`}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarBold />} onClickCapture={handleUnstarSelection}>
|
||||
{customStarUi ? customStarUi.off.text : `Unstar All`}
|
||||
</MenuItem>
|
||||
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarFill />} onClickCapture={handleStarSelection}>
|
||||
{customStarUi ? customStarUi.on.text : `Star All`}
|
||||
</MenuItem>
|
||||
{isBulkDownloadEnabled && (
|
||||
<MenuItem icon={<PiDownloadSimpleBold />} onClickCapture={handleBulkDownload}>
|
||||
{t('gallery.downloadSelection')}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/Ga
|
||||
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
||||
import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
|
||||
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
|
||||
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -92,7 +93,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const dndId = useId();
|
||||
const selectIsSelectedForCompare = useMemo(
|
||||
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name),
|
||||
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare === imageDTO.image_name),
|
||||
[imageDTO.image_name]
|
||||
);
|
||||
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
|
||||
@@ -100,7 +101,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
() =>
|
||||
createSelector(selectGallerySlice, (gallery) => {
|
||||
for (const selectedImage of gallery.selection) {
|
||||
if (selectedImage.image_name === imageDTO.image_name) {
|
||||
if (selectedImage === imageDTO.image_name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -125,11 +126,11 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
// multi-image drag.
|
||||
if (
|
||||
gallery.selection.length > 1 &&
|
||||
gallery.selection.find(({ image_name }) => image_name === imageDTO.image_name) !== undefined
|
||||
gallery.selection.find((image_name) => image_name === imageDTO.image_name) !== undefined
|
||||
) {
|
||||
return multipleImageDndSource.getData({
|
||||
imageDTOs: gallery.selection,
|
||||
boardId: gallery.selectedBoardId,
|
||||
image_names: gallery.selection,
|
||||
board_id: gallery.selectedBoardId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,7 +167,10 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
onDragStart: ({ source }) => {
|
||||
// When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
|
||||
// selection. This is called for all drag events.
|
||||
if (multipleImageDndSource.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) {
|
||||
if (
|
||||
multipleImageDndSource.typeGuard(source.data) &&
|
||||
source.data.payload.image_names.includes(imageDTO.image_name)
|
||||
) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
@@ -192,7 +196,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
(e) => {
|
||||
store.dispatch(
|
||||
galleryImageClicked({
|
||||
imageDTO,
|
||||
imageName: imageDTO.image_name,
|
||||
shiftKey: e.shiftKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
metaKey: e.metaKey,
|
||||
@@ -205,7 +209,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
|
||||
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(() => {
|
||||
store.dispatch(imageToCompareChanged(null));
|
||||
autoLayoutContext.focusImageViewer();
|
||||
autoLayoutContext.focusPanel(VIEWER_PANEL_ID);
|
||||
}, [autoLayoutContext, store]);
|
||||
|
||||
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);
|
||||
|
||||
@@ -22,7 +22,7 @@ export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
deleteImageModal.delete([imageDTO]);
|
||||
deleteImageModal.delete([imageDTO.image_name]);
|
||||
},
|
||||
[deleteImageModal, imageDTO]
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { DndImageIcon } from 'features/dnd/DndImageIcon';
|
||||
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
|
||||
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowsOutBold } from 'react-icons/pi';
|
||||
@@ -13,14 +14,14 @@ type Props = {
|
||||
|
||||
export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { focusImageViewer } = useAutoLayoutContext();
|
||||
const { focusPanel } = useAutoLayoutContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(imageToCompareChanged(null));
|
||||
dispatch(imageSelected(imageDTO));
|
||||
focusImageViewer();
|
||||
}, [dispatch, focusImageViewer, imageDTO]);
|
||||
dispatch(imageSelected(imageDTO.image_name));
|
||||
focusPanel(VIEWER_PANEL_ID);
|
||||
}, [dispatch, focusPanel, imageDTO]);
|
||||
|
||||
return (
|
||||
<DndImageIcon
|
||||
|
||||
@@ -17,9 +17,9 @@ export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => {
|
||||
|
||||
const toggleStarredState = useCallback(() => {
|
||||
if (imageDTO.starred) {
|
||||
unstarImages({ imageDTOs: [imageDTO] });
|
||||
unstarImages({ image_names: [imageDTO.image_name] });
|
||||
} else {
|
||||
starImages({ imageDTOs: [imageDTO] });
|
||||
starImages({ image_names: [imageDTO.image_name] });
|
||||
}
|
||||
}, [starImages, unstarImages, imageDTO]);
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { useDebouncedImageCollectionQueryArgs } from 'features/gallery/components/NewGallery';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
import { useGetImageCollectionCountsQuery } from 'services/api/endpoints/images';
|
||||
|
||||
type Props = {
|
||||
searchTerm: string;
|
||||
@@ -15,10 +14,8 @@ type Props = {
|
||||
|
||||
export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const { isPending } = useListImagesQuery(queryArgs, {
|
||||
selectFromResult: ({ isLoading, isFetching }) => ({ isPending: isLoading || isFetching }),
|
||||
});
|
||||
const queryArgs = useDebouncedImageCollectionQueryArgs();
|
||||
const { isFetching } = useGetImageCollectionCountsQuery(queryArgs);
|
||||
|
||||
const handleChangeInput = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -46,12 +43,12 @@ export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSear
|
||||
data-testid="image-search-input"
|
||||
onKeyDown={handleKeydown}
|
||||
/>
|
||||
{isPending && (
|
||||
{isFetching && (
|
||||
<InputRightElement h="full" pe={2}>
|
||||
<Spinner size="sm" opacity={0.5} />
|
||||
</InputRightElement>
|
||||
)}
|
||||
{!isPending && searchTerm.length && (
|
||||
{!isFetching && searchTerm.length && (
|
||||
<InputRightElement h="full" pe={2}>
|
||||
<IconButton
|
||||
onClick={onResetSearchTerm}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const GallerySelectionCountTag = memo(() => {
|
||||
const isGalleryFocused = useIsRegionFocused('gallery');
|
||||
|
||||
const onSelectPage = useCallback(() => {
|
||||
dispatch(selectionChanged([...selection, ...imageDTOs]));
|
||||
dispatch(selectionChanged([...selection, ...imageDTOs.map(({ image_name }) => image_name)]));
|
||||
}, [dispatch, selection, imageDTOs]);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectSearchTerm } from 'features/gallery/store/gallerySelectors';
|
||||
import { searchTermChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const useGallerySearchTerm = () => {
|
||||
// Highlander!
|
||||
@@ -11,27 +10,16 @@ export const useGallerySearchTerm = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const searchTerm = useAppSelector(selectSearchTerm);
|
||||
|
||||
const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm);
|
||||
|
||||
const debouncedSetSearchTerm = useMemo(() => {
|
||||
return debounce((val: string) => {
|
||||
dispatch(searchTermChanged(val));
|
||||
}, 1000);
|
||||
}, [dispatch]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string) => {
|
||||
setLocalSearchTerm(val);
|
||||
debouncedSetSearchTerm(val);
|
||||
dispatch(searchTermChanged(val));
|
||||
},
|
||||
[debouncedSetSearchTerm]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
debouncedSetSearchTerm.cancel();
|
||||
setLocalSearchTerm('');
|
||||
dispatch(searchTermChanged(''));
|
||||
}, [debouncedSetSearchTerm, dispatch]);
|
||||
}, [dispatch]);
|
||||
|
||||
return [localSearchTerm, onChange, onReset] as const;
|
||||
return [searchTerm, onChange, onReset] as const;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
|
||||
@@ -10,6 +9,7 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
|
||||
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
|
||||
import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -21,14 +21,22 @@ import {
|
||||
PiQuotesBold,
|
||||
PiRulerBold,
|
||||
} from 'react-icons/pi';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
|
||||
import { useImageViewerContext } from './context';
|
||||
|
||||
export const CurrentImageButtons = memo(() => {
|
||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
||||
const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
|
||||
const { t } = useTranslation();
|
||||
const ctx = useImageViewerContext();
|
||||
const hasProgressImage = useStore(ctx.$hasProgressImage);
|
||||
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
|
||||
|
||||
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
|
||||
|
||||
const imageName = useAppSelector(selectLastSelectedImage);
|
||||
const imageDTO = useImageDTO(imageName);
|
||||
const hasTemplates = useStore($hasTemplates);
|
||||
const imageActions = useImageActions(imageDTO ?? null);
|
||||
const imageActions = useImageActions(imageDTO);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const isUpscalingEnabled = useFeatureStatus('upscaling');
|
||||
|
||||
@@ -39,7 +47,7 @@ export const CurrentImageButtons = memo(() => {
|
||||
as={IconButton}
|
||||
aria-label={t('parameters.imageActions')}
|
||||
tooltip={t('parameters.imageActions')}
|
||||
isDisabled={!imageDTO}
|
||||
isDisabled={isDisabledOverride || !imageDTO}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
icon={<PiDotsThreeOutlineFill />}
|
||||
@@ -53,7 +61,7 @@ export const CurrentImageButtons = memo(() => {
|
||||
icon={<PiFlowArrowBold />}
|
||||
tooltip={`${t('nodes.loadWorkflow')} (W)`}
|
||||
aria-label={`${t('nodes.loadWorkflow')} (W)`}
|
||||
isDisabled={!imageDTO || !imageActions.hasWorkflow || !hasTemplates}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasWorkflow || !hasTemplates}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.loadWorkflow}
|
||||
@@ -62,7 +70,7 @@ export const CurrentImageButtons = memo(() => {
|
||||
icon={<PiArrowsCounterClockwiseBold />}
|
||||
tooltip={`${t('parameters.remixImage')} (R)`}
|
||||
aria-label={`${t('parameters.remixImage')} (R)`}
|
||||
isDisabled={!imageDTO || !imageActions.hasMetadata}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.remix}
|
||||
@@ -71,7 +79,7 @@ export const CurrentImageButtons = memo(() => {
|
||||
icon={<PiQuotesBold />}
|
||||
tooltip={`${t('parameters.usePrompt')} (P)`}
|
||||
aria-label={`${t('parameters.usePrompt')} (P)`}
|
||||
isDisabled={!imageDTO || !imageActions.hasPrompts}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasPrompts}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallPrompts}
|
||||
@@ -80,7 +88,7 @@ export const CurrentImageButtons = memo(() => {
|
||||
icon={<PiPlantBold />}
|
||||
tooltip={`${t('parameters.useSeed')} (S)`}
|
||||
aria-label={`${t('parameters.useSeed')} (S)`}
|
||||
isDisabled={!imageDTO || !imageActions.hasSeed}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasSeed}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallSeed}
|
||||
@@ -92,23 +100,23 @@ export const CurrentImageButtons = memo(() => {
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallSize}
|
||||
isDisabled={!imageDTO || isStaging}
|
||||
isDisabled={isDisabledOverride || !imageDTO || isStaging}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PiAsteriskBold />}
|
||||
tooltip={`${t('parameters.useAll')} (A)`}
|
||||
aria-label={`${t('parameters.useAll')} (A)`}
|
||||
isDisabled={!imageDTO || !imageActions.hasMetadata}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallAll}
|
||||
/>
|
||||
|
||||
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} />}
|
||||
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={isDisabledOverride} />}
|
||||
|
||||
<Divider orientation="vertical" h={8} mx={2} />
|
||||
|
||||
<DeleteImageButton onClick={imageActions.delete} isDisabled={!imageDTO} />
|
||||
<DeleteImageButton onClick={imageActions.delete} isDisabled={isDisabledOverride || !imageDTO} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { Box, Flex } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
|
||||
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
|
||||
import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors';
|
||||
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
|
||||
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
|
||||
import type { AnimationProps } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import { $socket } from 'services/events/stores';
|
||||
|
||||
import { ImageMetadataMini } from './ImageMetadataMini';
|
||||
import { NoContentForViewer } from './NoContentForViewer';
|
||||
import { ProgressImage } from './ProgressImage2';
|
||||
import { ProgressIndicator } from './ProgressIndicator2';
|
||||
|
||||
export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => {
|
||||
export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => {
|
||||
const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails);
|
||||
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
|
||||
|
||||
const socket = useStore($socket);
|
||||
const [progressEvent, setProgressEvent] = useState<S['InvocationProgressEvent'] | null>(null);
|
||||
const [progressImage, setProgressImage] = useState<ProgressImageType | null>(null);
|
||||
|
||||
// Show and hide the next/prev buttons on mouse move
|
||||
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState<boolean>(false);
|
||||
@@ -29,6 +39,36 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onInvocationProgress = (data: S['InvocationProgressEvent']) => {
|
||||
setProgressEvent(data);
|
||||
if (data.image) {
|
||||
setProgressImage(data.image);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('invocation_progress', onInvocationProgress);
|
||||
|
||||
return () => {
|
||||
socket.off('invocation_progress', onInvocationProgress);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const onLoadImage = useCallback(() => {
|
||||
if (!progressEvent || !imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (progressEvent.session_id === imageDTO.session_id) {
|
||||
setProgressImage(null);
|
||||
}
|
||||
}, [imageDTO, progressEvent]);
|
||||
|
||||
const withProgress = shouldShowProgressInViewer && progressEvent && progressImage;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
onMouseOver={onMouseOver}
|
||||
@@ -39,12 +79,23 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
>
|
||||
<ImageContent imageDTO={imageDTO} />
|
||||
{imageDTO && (
|
||||
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
|
||||
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} />
|
||||
</Flex>
|
||||
)}
|
||||
{!imageDTO && <NoContentForViewer />}
|
||||
{withProgress && (
|
||||
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center" bg="base.900">
|
||||
<ProgressImage progressImage={progressImage} />
|
||||
<ProgressIndicator progressEvent={progressEvent} position="absolute" top={6} right={6} size={8} />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex flexDir="column" gap={2} position="absolute" top={0} insetInlineStart={0} alignItems="flex-start">
|
||||
<CanvasAlertsInvocationProgress />
|
||||
{imageDTO && <ImageMetadataMini imageName={imageDTO.image_name} />}
|
||||
{imageDTO && !withProgress && <ImageMetadataMini imageName={imageDTO.image_name} />}
|
||||
</Flex>
|
||||
{shouldShowImageDetails && imageDTO && (
|
||||
{shouldShowImageDetails && imageDTO && !withProgress && (
|
||||
<Box position="absolute" opacity={0.8} top={0} width="full" height="full" borderRadius="base">
|
||||
<ImageMetadataViewer image={imageDTO} />
|
||||
</Box>
|
||||
@@ -73,19 +124,6 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
|
||||
});
|
||||
CurrentImagePreview.displayName = 'CurrentImagePreview';
|
||||
|
||||
const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => {
|
||||
if (!imageDTO) {
|
||||
return <NoContentForViewer />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
|
||||
<DndImage imageDTO={imageDTO} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
ImageContent.displayName = 'ImageContent';
|
||||
|
||||
const initial: AnimationProps['initial'] = {
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Flex } from '@invoke-ai/ui-library';
|
||||
import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { ProgressIndicator } from './ProgressIndicator';
|
||||
|
||||
export const GenerationProgressPanel = memo(() => (
|
||||
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2}>
|
||||
<Flex position="relative" flexDir="column" w="full" h="full" overflow="hidden" p={2}>
|
||||
<ProgressImage />
|
||||
<ProgressIndicator position="absolute" top={6} right={6} size={8} />
|
||||
</Flex>
|
||||
));
|
||||
GenerationProgressPanel.displayName = 'GenerationProgressPanel';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
|
||||
import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
||||
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
|
||||
import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo } from 'react';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
|
||||
// type Props = {
|
||||
// closeButton?: ReactNode;
|
||||
@@ -28,9 +27,10 @@ import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
// };
|
||||
|
||||
export const ImageViewer = memo(() => {
|
||||
const lastSelectedImageName = useAppSelector(selectLastSelectedImageName);
|
||||
const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken);
|
||||
const comparisonImageDTO = useAppSelector(selectImageToCompare);
|
||||
const lastSelectedImageName = useAppSelector(selectLastSelectedImage);
|
||||
const lastSelectedImageDTO = useImageDTO(lastSelectedImageName);
|
||||
const comparisonImageName = useAppSelector(selectImageToCompare);
|
||||
const comparisonImageDTO = useImageDTO(comparisonImageName);
|
||||
|
||||
if (lastSelectedImageDTO && comparisonImageDTO) {
|
||||
return <ImageComparison firstImage={lastSelectedImageDTO} secondImage={comparisonImageDTO} />;
|
||||
|
||||
@@ -3,11 +3,17 @@ import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer
|
||||
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const ImageViewerPanel = memo(() => (
|
||||
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2} gap={2}>
|
||||
<ViewerToolbar />
|
||||
<Divider />
|
||||
<ImageViewer />
|
||||
</Flex>
|
||||
));
|
||||
import { ImageViewerContextProvider } from './context';
|
||||
|
||||
export const ImageViewerPanel = memo(() => {
|
||||
return (
|
||||
<ImageViewerContextProvider>
|
||||
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2} gap={2}>
|
||||
<ViewerToolbar />
|
||||
<Divider />
|
||||
<ImageViewer />
|
||||
</Flex>
|
||||
</ImageViewerContextProvider>
|
||||
);
|
||||
});
|
||||
ImageViewerPanel.displayName = 'ImageViewerPanel';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2';
|
||||
import { ProgressIndicator } from 'features/gallery/components/ImageViewer/ProgressIndicator2';
|
||||
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
|
||||
import { memo } from 'react';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
export const Progress = memo(
|
||||
({
|
||||
progressEvent,
|
||||
progressImage,
|
||||
}: {
|
||||
progressEvent: S['InvocationProgressEvent'];
|
||||
progressImage: ProgressImageType;
|
||||
}) => (
|
||||
<Flex position="relative" flexDir="column" w="full" h="full" overflow="hidden" p={2}>
|
||||
<ProgressImage progressImage={progressImage} />
|
||||
<ProgressIndicator progressEvent={progressEvent} position="absolute" top={6} right={6} size={8} />
|
||||
</Flex>
|
||||
)
|
||||
);
|
||||
Progress.displayName = 'Progress';
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, Image } from '@invoke-ai/ui-library';
|
||||
import { Flex, Heading, Image } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
@@ -7,6 +7,7 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { selectSystemSlice } from 'features/system/store/systemSlice';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { PiPulseBold } from 'react-icons/pi';
|
||||
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
|
||||
import { $lastProgressImage } from 'services/events/stores';
|
||||
|
||||
const selectShouldAntialiasProgressImage = createSelector(
|
||||
@@ -15,6 +16,7 @@ const selectShouldAntialiasProgressImage = createSelector(
|
||||
);
|
||||
|
||||
export const ProgressImage = memo(() => {
|
||||
const isGenerationInProgress = useIsGenerationInProgress();
|
||||
const progressImage = useStore($lastProgressImage);
|
||||
const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage);
|
||||
|
||||
@@ -25,7 +27,7 @@ export const ProgressImage = memo(() => {
|
||||
[shouldAntialiasProgressImage]
|
||||
);
|
||||
|
||||
if (!progressImage) {
|
||||
if (!isGenerationInProgress) {
|
||||
return (
|
||||
<Flex width="full" height="full" alignItems="center" justifyContent="center">
|
||||
<IAINoContentFallback icon={PiPulseBold} label="No Generation in Progress" />
|
||||
@@ -33,6 +35,14 @@ export const ProgressImage = memo(() => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!progressImage) {
|
||||
return (
|
||||
<Flex width="full" height="full" alignItems="center" justifyContent="center" minW={0} minH={0}>
|
||||
<Heading>Waiting for Image</Heading>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex width="full" height="full" alignItems="center" justifyContent="center" minW={0} minH={0}>
|
||||
<Image
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, Image } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
|
||||
import { selectSystemSlice } from 'features/system/store/systemSlice';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
const selectShouldAntialiasProgressImage = createSelector(
|
||||
selectSystemSlice,
|
||||
(system) => system.shouldAntialiasProgressImage
|
||||
);
|
||||
|
||||
export const ProgressImage = memo(({ progressImage }: { progressImage: ProgressImageType }) => {
|
||||
const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage);
|
||||
|
||||
const sx = useMemo<SystemStyleObject>(
|
||||
() => ({
|
||||
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
|
||||
}),
|
||||
[shouldAntialiasProgressImage]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex width="full" height="full" alignItems="center" justifyContent="center" minW={0} minH={0}>
|
||||
<Image
|
||||
src={progressImage.dataURL}
|
||||
width={progressImage.width}
|
||||
height={progressImage.height}
|
||||
draggable={false}
|
||||
data-testid="progress-image"
|
||||
objectFit="contain"
|
||||
maxWidth="full"
|
||||
maxHeight="full"
|
||||
borderRadius="base"
|
||||
sx={sx}
|
||||
minH={0}
|
||||
minW={0}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
ProgressImage.displayName = 'ProgressImage';
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo } from 'react';
|
||||
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
|
||||
import { $lastProgressEvent, formatProgressMessage } from 'services/events/stores';
|
||||
|
||||
const circleStyles: SystemStyleObject = {
|
||||
circle: {
|
||||
transitionProperty: 'none',
|
||||
transitionDuration: '0s',
|
||||
},
|
||||
};
|
||||
|
||||
export const ProgressIndicator = memo((props: CircularProgressProps) => {
|
||||
const isGenerationInProgress = useIsGenerationInProgress();
|
||||
const lastProgressEvent = useStore($lastProgressEvent);
|
||||
if (!isGenerationInProgress) {
|
||||
return null;
|
||||
}
|
||||
if (!lastProgressEvent) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip label={formatProgressMessage(lastProgressEvent)}>
|
||||
<CircularProgress
|
||||
size="14px"
|
||||
color="invokeBlue.500"
|
||||
thickness={14}
|
||||
isIndeterminate={!lastProgressEvent || lastProgressEvent.percentage === null}
|
||||
value={lastProgressEvent?.percentage ? lastProgressEvent.percentage * 100 : undefined}
|
||||
sx={circleStyles}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
ProgressIndicator.displayName = 'ProgressMessage';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
import type { S } from 'services/api/types';
|
||||
import { formatProgressMessage } from 'services/events/stores';
|
||||
|
||||
const circleStyles: SystemStyleObject = {
|
||||
circle: {
|
||||
transitionProperty: 'none',
|
||||
transitionDuration: '0s',
|
||||
},
|
||||
};
|
||||
|
||||
export const ProgressIndicator = memo(
|
||||
({ progressEvent, ...rest }: { progressEvent: S['InvocationProgressEvent'] } & CircularProgressProps) => {
|
||||
return (
|
||||
<Tooltip label={formatProgressMessage(progressEvent)}>
|
||||
<CircularProgress
|
||||
size="14px"
|
||||
color="invokeBlue.500"
|
||||
thickness={14}
|
||||
isIndeterminate={!progressEvent || progressEvent.percentage === null}
|
||||
value={progressEvent?.percentage ? progressEvent.percentage * 100 : undefined}
|
||||
sx={circleStyles}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
ProgressIndicator.displayName = 'ProgressMessage';
|
||||
@@ -1,15 +1,24 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors';
|
||||
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
|
||||
import { setShouldShowImageDetails } from 'features/ui/store/uiSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiInfoBold } from 'react-icons/pi';
|
||||
|
||||
import { useImageViewerContext } from './context';
|
||||
|
||||
export const ToggleMetadataViewerButton = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const ctx = useImageViewerContext();
|
||||
const hasProgressImage = useStore(ctx.$hasProgressImage);
|
||||
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
|
||||
|
||||
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
|
||||
|
||||
const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails);
|
||||
const imageDTO = useAppSelector(selectLastSelectedImage);
|
||||
const { t } = useTranslation();
|
||||
@@ -35,6 +44,7 @@ export const ToggleMetadataViewerButton = memo(() => {
|
||||
alignSelf="stretch"
|
||||
colorScheme={shouldShowImageDetails ? 'invokeBlue' : 'base'}
|
||||
data-testid="toggle-show-metadata-button"
|
||||
isDisabled={isDisabledOverride}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
|
||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { CurrentImageButtons } from './CurrentImageButtons';
|
||||
import { ToggleProgressButton } from './ToggleProgressButton';
|
||||
|
||||
export const ViewerToolbar = memo(() => {
|
||||
return (
|
||||
<Flex w="full" justifyContent="center" h={8}>
|
||||
<ButtonGroup>
|
||||
<ToggleMetadataViewerButton />
|
||||
<CurrentImageButtons />
|
||||
</ButtonGroup>
|
||||
<ToggleProgressButton />
|
||||
<Spacer />
|
||||
<CurrentImageButtons />
|
||||
<Spacer />
|
||||
<ToggleMetadataViewerButton />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
|
||||
import { type Atom, atom, computed } from 'nanostores';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import { $socket } from 'services/events/stores';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
type ImageViewerContextValue = {
|
||||
$progressEvent: Atom<S['InvocationProgressEvent'] | null>;
|
||||
$progressImage: Atom<ProgressImageType | null>;
|
||||
$hasProgressImage: Atom<boolean>;
|
||||
onLoadImage: (imageDTO: ImageDTO) => void;
|
||||
};
|
||||
|
||||
const ImageViewerContext = createContext<ImageViewerContextValue | null>(null);
|
||||
|
||||
export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
|
||||
const socket = useStore($socket);
|
||||
const $progressEvent = useState(() => atom<S['InvocationProgressEvent'] | null>(null))[0];
|
||||
const $progressImage = useState(() => atom<ProgressImageType | null>(null))[0];
|
||||
const $hasProgressImage = useState(() => computed($progressImage, (progressImage) => progressImage !== null))[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onInvocationProgress = (data: S['InvocationProgressEvent']) => {
|
||||
$progressEvent.set(data);
|
||||
if (data.image) {
|
||||
$progressImage.set(data.image);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('invocation_progress', onInvocationProgress);
|
||||
|
||||
return () => {
|
||||
socket.off('invocation_progress', onInvocationProgress);
|
||||
};
|
||||
}, [$progressEvent, $progressImage, socket]);
|
||||
|
||||
const onLoadImage = useCallback(
|
||||
(imageDTO: ImageDTO) => {
|
||||
const progressEvent = $progressEvent.get();
|
||||
if (!progressEvent || !imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (progressEvent.session_id === imageDTO.session_id) {
|
||||
$progressEvent.set(null);
|
||||
$progressImage.set(null);
|
||||
}
|
||||
},
|
||||
[$progressEvent, $progressImage]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ $progressEvent, $progressImage, $hasProgressImage, onLoadImage }),
|
||||
[$hasProgressImage, $progressEvent, $progressImage, onLoadImage]
|
||||
);
|
||||
|
||||
return <ImageViewerContext.Provider value={value}>{props.children}</ImageViewerContext.Provider>;
|
||||
});
|
||||
ImageViewerContextProvider.displayName = 'ImageViewerContextProvider';
|
||||
|
||||
export const useImageViewerContext = () => {
|
||||
const value = useContext(ImageViewerContext);
|
||||
assert(value !== null, 'useImageViewerContext must be used within a ImageViewerContextProvider');
|
||||
return value;
|
||||
};
|
||||
@@ -0,0 +1,464 @@
|
||||
import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
selectGalleryImageMinimumWidth,
|
||||
selectImageCollectionQueryArgs,
|
||||
selectLastSelectedImage,
|
||||
} from 'features/gallery/store/gallerySelectors';
|
||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
GridComponents,
|
||||
GridComputeItemKey,
|
||||
GridItemContent,
|
||||
ListRange,
|
||||
ScrollSeekConfiguration,
|
||||
VirtuosoGridHandle,
|
||||
} from 'react-virtuoso';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { useGetImageNamesQuery, useListImagesQuery } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO, ListImagesArgs } from 'services/api/types';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { GalleryImage } from './ImageGrid/GalleryImage';
|
||||
|
||||
const log = logger('gallery');
|
||||
|
||||
// Constants
|
||||
const PAGE_SIZE = 100;
|
||||
const VIEWPORT_BUFFER = 2048;
|
||||
const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096;
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
const GRID_GAP = 1;
|
||||
const SPINNER_OPACITY = 0.3;
|
||||
|
||||
type GridContext = {
|
||||
queryArgs: ListImagesArgs;
|
||||
imageNames: string[];
|
||||
};
|
||||
|
||||
export const useDebouncedImageCollectionQueryArgs = () => {
|
||||
const _galleryQueryArgs = useAppSelector(selectImageCollectionQueryArgs);
|
||||
const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY);
|
||||
return queryArgs;
|
||||
};
|
||||
|
||||
// Hook to get an image DTO from cache or trigger loading
|
||||
const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: ListImagesArgs): ImageDTO | null => {
|
||||
const { arg, options } = useMemo(() => {
|
||||
const pageOffset = Math.floor(index / PAGE_SIZE) * PAGE_SIZE;
|
||||
return {
|
||||
arg: {
|
||||
...queryArgs,
|
||||
offset: pageOffset,
|
||||
limit: PAGE_SIZE,
|
||||
} satisfies Parameters<typeof useListImagesQuery>[0],
|
||||
options: {
|
||||
selectFromResult: ({ data }) => {
|
||||
const imageDTO = data?.items?.[index - pageOffset] || null;
|
||||
if (imageDTO && imageDTO.image_name !== imageName) {
|
||||
log.warn(`Image at index ${index} does not match expected image name ${imageName}`);
|
||||
}
|
||||
return { imageDTO };
|
||||
},
|
||||
} satisfies Parameters<typeof useListImagesQuery>[1],
|
||||
};
|
||||
}, [index, queryArgs, imageName]);
|
||||
|
||||
const { imageDTO } = useListImagesQuery(arg, options);
|
||||
|
||||
return imageDTO;
|
||||
};
|
||||
|
||||
// Individual image component that gets its data from RTK Query cache
|
||||
const ImageAtPosition = memo(
|
||||
({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesArgs }) => {
|
||||
const imageDTO = useImageDTOFromListQuery(index, imageName, queryArgs);
|
||||
|
||||
if (!imageDTO) {
|
||||
return <Skeleton w="full" h="full" />;
|
||||
}
|
||||
|
||||
return <GalleryImage imageDTO={imageDTO} />;
|
||||
}
|
||||
);
|
||||
ImageAtPosition.displayName = 'ImageAtPosition';
|
||||
|
||||
// Memoized compute key function using image names
|
||||
const computeItemKey: GridComputeItemKey<string, GridContext> = (index, imageName, { queryArgs }) => {
|
||||
return `${JSON.stringify(queryArgs)}-${imageName}`;
|
||||
};
|
||||
|
||||
// Physical DOM-based grid calculation using refs (based on working old implementation)
|
||||
const getImagesPerRow = (rootEl: HTMLDivElement): number => {
|
||||
// Start from root and find virtuoso grid elements
|
||||
const gridElement = rootEl.querySelector('.virtuoso-grid-list');
|
||||
|
||||
if (!gridElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstGridItem = gridElement.querySelector('.virtuoso-grid-item');
|
||||
|
||||
if (!firstGridItem) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const itemRect = firstGridItem.getBoundingClientRect();
|
||||
const containerRect = gridElement.getBoundingClientRect();
|
||||
|
||||
// Get the computed gap from CSS
|
||||
const gridStyle = window.getComputedStyle(gridElement);
|
||||
const gapValue = gridStyle.gap;
|
||||
const gap = parseFloat(gapValue);
|
||||
|
||||
if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use the exact calculation from the working old implementation
|
||||
let imagesPerRow = 0;
|
||||
let spaceUsed = 0;
|
||||
|
||||
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
|
||||
// this, without the possibility of accidentally adding an extra column.
|
||||
while (spaceUsed + itemRect.width <= containerRect.width + 1) {
|
||||
imagesPerRow++; // Increment the number of images
|
||||
spaceUsed += itemRect.width; // Add image size to the used space
|
||||
if (spaceUsed + gap <= containerRect.width) {
|
||||
spaceUsed += gap; // Add gap size to the used space after each image except after the last image
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(1, imagesPerRow);
|
||||
};
|
||||
|
||||
// Check if an item at a given index is visible in the viewport
|
||||
const scrollIntoView = (
|
||||
index: number,
|
||||
rootEl: HTMLDivElement,
|
||||
virtuosoGridHandle: VirtuosoGridHandle,
|
||||
range: ListRange
|
||||
) => {
|
||||
if (range.endIndex === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First get the virtuoso grid list root element
|
||||
const gridList = rootEl.querySelector('.virtuoso-grid-list') as HTMLElement;
|
||||
|
||||
if (!gridList) {
|
||||
// No grid - cannot scroll!
|
||||
return;
|
||||
}
|
||||
|
||||
// Then find the specific item within the grid list
|
||||
const targetItem = gridList.querySelector(`.virtuoso-grid-item[data-index="${index}"]`) as HTMLElement;
|
||||
|
||||
if (!targetItem) {
|
||||
if (index > range.endIndex) {
|
||||
virtuosoGridHandle.scrollToIndex({
|
||||
index,
|
||||
behavior: 'auto',
|
||||
align: 'start',
|
||||
});
|
||||
} else if (index < range.startIndex) {
|
||||
virtuosoGridHandle.scrollToIndex({
|
||||
index,
|
||||
behavior: 'auto',
|
||||
align: 'end',
|
||||
});
|
||||
} else {
|
||||
log.warn(`Unable to find item index ${index} but it is in range ${range.startIndex}-${range.endIndex}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const itemRect = targetItem.getBoundingClientRect();
|
||||
const rootRect = rootEl.getBoundingClientRect();
|
||||
|
||||
if (itemRect.top < rootRect.top) {
|
||||
virtuosoGridHandle.scrollToIndex({
|
||||
index,
|
||||
behavior: 'auto',
|
||||
align: 'start',
|
||||
});
|
||||
} else if (itemRect.bottom > rootRect.bottom) {
|
||||
virtuosoGridHandle.scrollToIndex({
|
||||
index,
|
||||
behavior: 'auto',
|
||||
align: 'end',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// Hook for keyboard navigation using physical DOM measurements
|
||||
const useKeyboardNavigation = (
|
||||
imageNames: string[],
|
||||
virtuosoRef: React.RefObject<VirtuosoGridHandle>,
|
||||
rootRef: React.RefObject<HTMLDivElement>,
|
||||
rangeRef: MutableRefObject<ListRange>
|
||||
) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
||||
|
||||
// Get current index of selected image
|
||||
const currentIndex = useMemo(() => {
|
||||
if (!lastSelectedImage || imageNames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const index = imageNames.findIndex((name) => name === lastSelectedImage);
|
||||
return index >= 0 ? index : 0;
|
||||
}, [lastSelectedImage, imageNames]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const rootEl = rootRef.current;
|
||||
const virtuosoGridHandle = virtuosoRef.current;
|
||||
const range = rangeRef.current;
|
||||
if (!rootEl || !virtuosoGridHandle) {
|
||||
return;
|
||||
}
|
||||
if (imageNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle arrow keys
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't interfere if user is typing in an input
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imagesPerRow = getImagesPerRow(rootEl);
|
||||
|
||||
if (imagesPerRow === 0) {
|
||||
// This can happen if the grid is not yet rendered or has no items
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
let newIndex = currentIndex;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
if (currentIndex > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
} else {
|
||||
// Wrap to last image
|
||||
newIndex = imageNames.length - 1;
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (currentIndex < imageNames.length - 1) {
|
||||
newIndex = currentIndex + 1;
|
||||
} else {
|
||||
// Wrap to first image
|
||||
newIndex = 0;
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
// If on first row, stay on current image
|
||||
if (currentIndex < imagesPerRow) {
|
||||
newIndex = currentIndex;
|
||||
} else {
|
||||
newIndex = Math.max(0, currentIndex - imagesPerRow);
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
// If no images below, stay on current image
|
||||
if (currentIndex >= imageNames.length - imagesPerRow) {
|
||||
newIndex = currentIndex;
|
||||
} else {
|
||||
newIndex = Math.min(imageNames.length - 1, currentIndex + imagesPerRow);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) {
|
||||
const newImageName = imageNames[newIndex];
|
||||
if (newImageName) {
|
||||
dispatch(selectionChanged([newImageName]));
|
||||
scrollIntoView(newIndex, rootEl, virtuosoGridHandle, range);
|
||||
}
|
||||
}
|
||||
},
|
||||
[rootRef, virtuosoRef, rangeRef, imageNames, currentIndex, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
|
||||
const getImageNamesQueryOptions = {
|
||||
selectFromResult: ({ data, isLoading }) => ({
|
||||
imageNames: data ?? EMPTY_ARRAY,
|
||||
isLoading,
|
||||
}),
|
||||
} satisfies Parameters<typeof useGetImageNamesQuery>[1];
|
||||
|
||||
// Main gallery component
|
||||
export const NewGallery = memo(() => {
|
||||
const queryArgs = useDebouncedImageCollectionQueryArgs();
|
||||
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
|
||||
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
|
||||
|
||||
// Get the ordered list of image names - this is our primary data source for virtualization
|
||||
const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions);
|
||||
|
||||
// Reset scroll position when query parameters change
|
||||
useEffect(() => {
|
||||
if (virtuosoRef.current && imageNames.length > 0) {
|
||||
virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' });
|
||||
}
|
||||
}, [queryArgs, imageNames.length]);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Enable keyboard navigation
|
||||
useKeyboardNavigation(imageNames, virtuosoRef, rootRef, rangeRef);
|
||||
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
events: {
|
||||
initialized(osInstance) {
|
||||
// force overflow styles
|
||||
const { viewport } = osInstance.elements();
|
||||
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
|
||||
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { current: root } = rootRef;
|
||||
|
||||
if (scroller && root) {
|
||||
initialize({
|
||||
target: root,
|
||||
elements: {
|
||||
viewport: scroller,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
osInstance()?.destroy();
|
||||
};
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
// Handle range changes - RTK Query will automatically cache and manage loading
|
||||
const handleRangeChanged = useCallback((range: ListRange) => {
|
||||
rangeRef.current = range;
|
||||
}, []);
|
||||
|
||||
const context = useMemo(
|
||||
() =>
|
||||
({
|
||||
imageNames,
|
||||
queryArgs,
|
||||
}) satisfies GridContext,
|
||||
[imageNames, queryArgs]
|
||||
);
|
||||
|
||||
// Item content function
|
||||
const itemContent: GridItemContent<string, GridContext> = useCallback((index, imageName, ctx) => {
|
||||
return <ImageAtPosition index={index} imageName={imageName} queryArgs={ctx.queryArgs} />;
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex height="100%" alignItems="center" justifyContent="center">
|
||||
<Spinner size="lg" opacity={SPINNER_OPACITY} />
|
||||
<Text ml={4}>Loading gallery...</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (imageNames.length === 0) {
|
||||
return (
|
||||
<Flex height="100%" alignItems="center" justifyContent="center">
|
||||
<Text color="base.300">No images found</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-overlayscrollbars-initialize="" ref={rootRef} w="full" h="full">
|
||||
<VirtuosoGrid<string, GridContext>
|
||||
ref={virtuosoRef}
|
||||
context={context}
|
||||
totalCount={imageNames.length}
|
||||
data={imageNames}
|
||||
increaseViewportBy={VIEWPORT_BUFFER}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
components={components}
|
||||
style={style}
|
||||
scrollerRef={setScroller}
|
||||
scrollSeekConfiguration={scrollSeekConfiguration}
|
||||
rangeChanged={handleRangeChanged}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
NewGallery.displayName = 'NewGallery';
|
||||
|
||||
const scrollSeekConfiguration: ScrollSeekConfiguration = {
|
||||
enter: (velocity) => velocity > SCROLL_SEEK_VELOCITY_THRESHOLD,
|
||||
exit: (velocity) => velocity === 0,
|
||||
};
|
||||
|
||||
// Styles
|
||||
const style = { height: '100%', width: '100%' };
|
||||
|
||||
// Grid components
|
||||
const ListComponent: GridComponents<GridContext>['List'] = forwardRef((props, ref) => {
|
||||
const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
ref={ref}
|
||||
gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`}
|
||||
gap={GRID_GAP}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
ListComponent.displayName = 'ListComponent';
|
||||
|
||||
const ItemComponent: GridComponents<GridContext>['Item'] = forwardRef((props, ref) => (
|
||||
<GridItem ref={ref} aspectRatio="1/1" {...props} />
|
||||
));
|
||||
ItemComponent.displayName = 'ItemComponent';
|
||||
|
||||
const ScrollSeekPlaceholderComponent: GridComponents<GridContext>['ScrollSeekPlaceholder'] = forwardRef(
|
||||
(props, ref) => (
|
||||
<GridItem ref={ref} aspectRatio="1/1" {...props}>
|
||||
<Skeleton w="full" h="full" />
|
||||
</GridItem>
|
||||
)
|
||||
);
|
||||
ScrollSeekPlaceholderComponent.displayName = 'ScrollSeekPlaceholderComponent';
|
||||
|
||||
const components: GridComponents<GridContext> = {
|
||||
Item: ItemComponent,
|
||||
List: ListComponent,
|
||||
ScrollSeekPlaceholder: ScrollSeekPlaceholderComponent,
|
||||
};
|
||||
@@ -177,7 +177,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
||||
if (imageDTOs.length === 0 || !lastSelectedImage) {
|
||||
return 0;
|
||||
}
|
||||
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage.image_name);
|
||||
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage);
|
||||
}, [imageDTOs, lastSelectedImage]);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
@@ -187,9 +187,9 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
||||
return;
|
||||
}
|
||||
if (alt) {
|
||||
dispatch(imageToCompareChanged(image));
|
||||
dispatch(imageToCompareChanged(image.image_name));
|
||||
} else {
|
||||
dispatch(imageSelected(image));
|
||||
dispatch(imageSelected(image.image_name));
|
||||
}
|
||||
scrollToImage(image.image_name, index);
|
||||
},
|
||||
|
||||
@@ -199,7 +199,7 @@ export const useImageActions = (imageDTO: ImageDTO | null) => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
deleteImageModal.delete([imageDTO]);
|
||||
deleteImageModal.delete([imageDTO.image_name]);
|
||||
}, [deleteImageModal, imageDTO]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,14 +4,10 @@ import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
|
||||
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
|
||||
import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types';
|
||||
import type { ListBoardsArgs, ListImagesArgs, SQLiteDirection } from 'services/api/types';
|
||||
|
||||
export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0));
|
||||
export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1));
|
||||
export const selectLastSelectedImageName = createSelector(
|
||||
selectGallerySlice,
|
||||
(gallery) => gallery.selection.at(-1)?.image_name
|
||||
);
|
||||
|
||||
export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit);
|
||||
export const selectListImagesQueryArgs = createMemoizedSelector(
|
||||
@@ -42,6 +38,15 @@ export const selectListBoardsQueryArgs = createMemoizedSelector(
|
||||
|
||||
export const selectAutoAddBoardId = createSelector(selectGallerySlice, (gallery) => gallery.autoAddBoardId);
|
||||
export const selectSelectedBoardId = createSelector(selectGallerySlice, (gallery) => gallery.selectedBoardId);
|
||||
|
||||
export const selectImageCollectionQueryArgs = createMemoizedSelector(selectGallerySlice, (gallery) => ({
|
||||
board_id: gallery.selectedBoardId === 'none' ? undefined : gallery.selectedBoardId,
|
||||
categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
|
||||
search_term: gallery.searchTerm || undefined,
|
||||
order_dir: gallery.orderDir as SQLiteDirection,
|
||||
is_intermediate: false,
|
||||
starred_first: true,
|
||||
}));
|
||||
export const selectAutoAssignBoardOnClick = createSelector(
|
||||
selectGallerySlice,
|
||||
(gallery) => gallery.autoAssignBoardOnClick
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { isEqual, uniqBy } from 'lodash-es';
|
||||
import type { BoardRecordOrderBy, ImageDTO } from 'services/api/types';
|
||||
import { isEqual, uniq } from 'lodash-es';
|
||||
import type { BoardRecordOrderBy } from 'services/api/types';
|
||||
|
||||
import type { BoardId, ComparisonMode, GalleryState, GalleryView, OrderDir } from './types';
|
||||
|
||||
@@ -33,14 +33,14 @@ export const gallerySlice = createSlice({
|
||||
name: 'gallery',
|
||||
initialState: initialGalleryState,
|
||||
reducers: {
|
||||
imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
|
||||
imageSelected: (state, action: PayloadAction<string | null>) => {
|
||||
// Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent
|
||||
// unnecessary re-renders of the gallery.
|
||||
|
||||
const selectedImage = action.payload;
|
||||
const selectedImageName = action.payload;
|
||||
|
||||
// If we got `null`, clear the selection
|
||||
if (!selectedImage) {
|
||||
if (!selectedImageName) {
|
||||
// But only if we have images selected
|
||||
if (state.selection.length > 0) {
|
||||
state.selection = [];
|
||||
@@ -50,24 +50,24 @@ export const gallerySlice = createSlice({
|
||||
|
||||
// If we have multiple images selected, clear the selection and select the new image
|
||||
if (state.selection.length !== 1) {
|
||||
state.selection = [selectedImage];
|
||||
state.selection = [selectedImageName];
|
||||
return;
|
||||
}
|
||||
|
||||
// If the selected image is different from the current selection, clear the selection and select the new image
|
||||
if (!isEqual(state.selection[0], selectedImage)) {
|
||||
state.selection = [selectedImage];
|
||||
if (!isEqual(state.selection[0], selectedImageName)) {
|
||||
state.selection = [selectedImageName];
|
||||
return;
|
||||
}
|
||||
|
||||
// Else we have the same image selected, do nothing
|
||||
},
|
||||
selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
|
||||
selectionChanged: (state, action: PayloadAction<string[]>) => {
|
||||
// Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent
|
||||
// unnecessary re-renders of the gallery.
|
||||
|
||||
// Remove duplicates from the selection
|
||||
const newSelection = uniqBy(action.payload, (i) => i.image_name);
|
||||
const newSelection = uniq(action.payload);
|
||||
|
||||
// If the new selection has a different length, update the selection
|
||||
if (newSelection.length !== state.selection.length) {
|
||||
@@ -83,7 +83,7 @@ export const gallerySlice = createSlice({
|
||||
|
||||
// Else we have the same selection, do nothing
|
||||
},
|
||||
imageToCompareChanged: (state, action: PayloadAction<ImageDTO | null>) => {
|
||||
imageToCompareChanged: (state, action: PayloadAction<string | null>) => {
|
||||
state.imageToCompare = action.payload;
|
||||
},
|
||||
comparisonModeChanged: (state, action: PayloadAction<ComparisonMode>) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BoardRecordOrderBy, ImageCategory, ImageDTO } from 'services/api/types';
|
||||
import type { BoardRecordOrderBy, ImageCategory } from 'services/api/types';
|
||||
|
||||
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
|
||||
export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other'];
|
||||
@@ -10,7 +10,7 @@ export type ComparisonFit = 'contain' | 'fill';
|
||||
export type OrderDir = 'ASC' | 'DESC';
|
||||
|
||||
export type GalleryState = {
|
||||
selection: ImageDTO[];
|
||||
selection: string[];
|
||||
shouldAutoSwitch: boolean;
|
||||
autoAssignBoardOnClick: boolean;
|
||||
autoAddBoardId: BoardId;
|
||||
@@ -24,7 +24,7 @@ export type GalleryState = {
|
||||
orderDir: OrderDir;
|
||||
searchTerm: string;
|
||||
alwaysShowImageSizeBadge: boolean;
|
||||
imageToCompare: ImageDTO | null;
|
||||
imageToCompare: string | null;
|
||||
comparisonMode: ComparisonMode;
|
||||
comparisonFit: ComparisonFit;
|
||||
shouldShowArchivedBoards: boolean;
|
||||
|
||||
@@ -71,9 +71,9 @@ export const setNodeImageFieldImage = (arg: {
|
||||
dispatch(fieldImageValueChanged({ ...fieldIdentifier, value: imageDTO }));
|
||||
};
|
||||
|
||||
export const setComparisonImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => {
|
||||
const { imageDTO, dispatch } = arg;
|
||||
dispatch(imageToCompareChanged(imageDTO));
|
||||
export const setComparisonImage = (arg: { image_name: string; dispatch: AppDispatch }) => {
|
||||
const { image_name, dispatch } = arg;
|
||||
dispatch(imageToCompareChanged(image_name));
|
||||
};
|
||||
|
||||
export const createNewCanvasEntityFromImage = (arg: {
|
||||
@@ -292,14 +292,14 @@ export const replaceCanvasEntityObjectsWithImage = (arg: {
|
||||
);
|
||||
};
|
||||
|
||||
export const addImagesToBoard = (arg: { imageDTOs: ImageDTO[]; boardId: BoardId; dispatch: AppDispatch }) => {
|
||||
const { imageDTOs, boardId, dispatch } = arg;
|
||||
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false }));
|
||||
export const addImagesToBoard = (arg: { image_names: string[]; boardId: BoardId; dispatch: AppDispatch }) => {
|
||||
const { image_names, boardId, dispatch } = arg;
|
||||
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ image_names, board_id: boardId }, { track: false }));
|
||||
dispatch(selectionChanged([]));
|
||||
};
|
||||
|
||||
export const removeImagesFromBoard = (arg: { imageDTOs: ImageDTO[]; dispatch: AppDispatch }) => {
|
||||
const { imageDTOs, dispatch } = arg;
|
||||
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false }));
|
||||
export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: AppDispatch }) => {
|
||||
const { image_names, dispatch } = arg;
|
||||
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false }));
|
||||
dispatch(selectionChanged([]));
|
||||
};
|
||||
|
||||
@@ -13,11 +13,13 @@ import { motion } from 'framer-motion';
|
||||
import type { CSSProperties, PropsWithChildren } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
import { $lastProgressEvent } from 'services/events/stores';
|
||||
|
||||
const CurrentImageNode = (props: NodeProps) => {
|
||||
const imageDTO = useAppSelector(selectLastSelectedImage);
|
||||
const image_name = useAppSelector(selectLastSelectedImage);
|
||||
const lastProgressEvent = useStore($lastProgressEvent);
|
||||
const imageDTO = useImageDTO(image_name);
|
||||
|
||||
if (lastProgressEvent?.image) {
|
||||
return (
|
||||
|
||||
@@ -21,10 +21,10 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { PiFrameCornersBold } from 'react-icons/pi';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
type Props = { imageDTO?: ImageDTO };
|
||||
type Props = { imageDTO: ImageDTO | null; isDisabled: boolean };
|
||||
|
||||
export const PostProcessingPopover = memo((props: Props) => {
|
||||
const { imageDTO } = props;
|
||||
const { imageDTO, isDisabled } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
const postProcessingModel = useAppSelector(selectPostProcessingModel);
|
||||
const inProgress = useIsQueueMutationInProgress();
|
||||
@@ -49,6 +49,7 @@ export const PostProcessingPopover = memo((props: Props) => {
|
||||
aria-label={t('parameters.postProcessing')}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
@@ -56,7 +57,11 @@ export const PostProcessingPopover = memo((props: Props) => {
|
||||
<Flex flexDirection="column" gap={4}>
|
||||
<ParamPostProcessingModel />
|
||||
{!postProcessingModel && <MissingModelWarning />}
|
||||
<Button size="sm" isDisabled={!imageDTO || inProgress || !postProcessingModel} onClick={handleClickUpscale}>
|
||||
<Button
|
||||
size="sm"
|
||||
isDisabled={isDisabled || !imageDTO || inProgress || !postProcessingModel}
|
||||
onClick={handleClickUpscale}
|
||||
>
|
||||
{t('parameters.processImage')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { parseify } from 'common/util/serialize';
|
||||
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
|
||||
import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows';
|
||||
import { $isReadyToEnqueue } from 'features/queue/store/readiness';
|
||||
import { useAutoLayoutContextSafe } from 'features/ui/layouts/auto-layout-context';
|
||||
import { VIEWER_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback } from 'react';
|
||||
import { serializeError } from 'serialize-error';
|
||||
@@ -17,6 +19,7 @@ const log = logger('generation');
|
||||
|
||||
export const useInvoke = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const ctx = useAutoLayoutContextSafe();
|
||||
const tabName = useAppSelector(selectActiveTab);
|
||||
const isReady = useStore($isReadyToEnqueue);
|
||||
const isLocked = useIsWorkflowEditorLocked();
|
||||
@@ -56,11 +59,21 @@ export const useInvoke = () => {
|
||||
|
||||
const enqueueBack = useCallback(() => {
|
||||
enqueue(false, false);
|
||||
}, [enqueue]);
|
||||
if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
|
||||
ctx?.focusPanel(VIEWER_PANEL_ID);
|
||||
} else if (tabName === 'canvas') {
|
||||
ctx?.focusPanel(WORKSPACE_PANEL_ID);
|
||||
}
|
||||
}, [ctx, enqueue, tabName]);
|
||||
|
||||
const enqueueFront = useCallback(() => {
|
||||
enqueue(true, false);
|
||||
}, [enqueue]);
|
||||
if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
|
||||
ctx?.focusPanel(VIEWER_PANEL_ID);
|
||||
} else if (tabName === 'canvas') {
|
||||
ctx?.focusPanel(WORKSPACE_PANEL_ID);
|
||||
}
|
||||
}, [ctx, enqueue, tabName]);
|
||||
|
||||
return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady || isLocked, enqueue };
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProgressProps } from '@invoke-ai/ui-library';
|
||||
import { Progress } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useMemo } from 'react';
|
||||
@@ -5,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
|
||||
import { $isConnected, $lastProgressEvent } from 'services/events/stores';
|
||||
|
||||
const ProgressBar = () => {
|
||||
const ProgressBar = (props: ProgressProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: queueStatus } = useGetQueueStatusQuery();
|
||||
const isConnected = useStore($isConnected);
|
||||
@@ -45,6 +46,7 @@ const ProgressBar = () => {
|
||||
h={2}
|
||||
w="full"
|
||||
colorScheme="invokeBlue"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter';
|
||||
import type { IDockviewPanelHeaderProps } from 'dockview';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
|
||||
export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => {
|
||||
export const TabWithoutCloseButton = memo((props: IDockviewPanelHeaderProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const setActive = useCallback(() => {
|
||||
if (!props.api.isActive) {
|
||||
@@ -15,8 +15,10 @@ export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => {
|
||||
|
||||
return (
|
||||
<Flex ref={ref} alignItems="center" h="full">
|
||||
<Text userSelect="none">{props.api.title ?? props.api.id}</Text>
|
||||
<Text userSelect="none" px={4}>
|
||||
{props.api.title ?? props.api.id}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
});
|
||||
TabWithoutCloseButton.displayName = 'TabWithoutCloseButton';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter';
|
||||
import type { IDockviewPanelHeaderProps } from 'dockview';
|
||||
import ProgressBar from 'features/system/components/ProgressBar';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
|
||||
|
||||
export const TabWithoutCloseButtonAndWithProgressIndicator = memo((props: IDockviewPanelHeaderProps) => {
|
||||
const isGenerationInProgress = useIsGenerationInProgress();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const setActive = useCallback(() => {
|
||||
if (!props.api.isActive) {
|
||||
props.api.setActive();
|
||||
}
|
||||
}, [props.api]);
|
||||
|
||||
useCallbackOnDragEnter(setActive, ref, 300);
|
||||
|
||||
return (
|
||||
<Flex ref={ref} position="relative" alignItems="center" h="full">
|
||||
<Text userSelect="none" px={4}>
|
||||
{props.api.title ?? props.api.id}
|
||||
</Text>
|
||||
{isGenerationInProgress && (
|
||||
<ProgressBar position="absolute" bottom={0} left={0} right={0} h={1} borderRadius="none" />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
TabWithoutCloseButtonAndWithProgressIndicator.displayName = 'TabWithoutCloseButtonAndWithProgressIndicator';
|
||||
@@ -5,20 +5,14 @@ import { atom } from 'nanostores';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
LEFT_PANEL_ID,
|
||||
LEFT_PANEL_MIN_SIZE_PX,
|
||||
RIGHT_PANEL_ID,
|
||||
RIGHT_PANEL_MIN_SIZE_PX,
|
||||
VIEWER_PANEL_ID,
|
||||
} from './shared';
|
||||
import { LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX } from './shared';
|
||||
|
||||
type AutoLayoutContextValue = {
|
||||
toggleLeftPanel: () => void;
|
||||
toggleRightPanel: () => void;
|
||||
toggleBothPanels: () => void;
|
||||
resetPanels: () => void;
|
||||
focusImageViewer: () => void;
|
||||
focusPanel: (id: string) => void;
|
||||
_$rootPanelApi: WritableAtom<GridviewApi | null>;
|
||||
_$leftPanelApi: WritableAtom<GridviewApi | null>;
|
||||
_$centerPanelApi: WritableAtom<DockviewApi | null>;
|
||||
@@ -116,13 +110,16 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable
|
||||
expandPanel(api, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX);
|
||||
}, [$rootApi]);
|
||||
|
||||
const focusImageViewer = useCallback(() => {
|
||||
const api = $centerApi.get();
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
activatePanel(api, VIEWER_PANEL_ID);
|
||||
}, [$centerApi]);
|
||||
const focusPanel = useCallback(
|
||||
(id: string) => {
|
||||
const api = $centerApi.get();
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
activatePanel(api, id);
|
||||
},
|
||||
[$centerApi]
|
||||
);
|
||||
|
||||
const value = useMemo<AutoLayoutContextValue>(
|
||||
() => ({
|
||||
@@ -130,7 +127,7 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable
|
||||
toggleRightPanel,
|
||||
toggleBothPanels,
|
||||
resetPanels,
|
||||
focusImageViewer,
|
||||
focusPanel,
|
||||
_$rootPanelApi: $rootApi,
|
||||
_$leftPanelApi: $leftApi,
|
||||
_$centerPanelApi: $centerApi,
|
||||
@@ -141,7 +138,7 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable
|
||||
$leftApi,
|
||||
$rightApi,
|
||||
$rootApi,
|
||||
focusImageViewer,
|
||||
focusPanel,
|
||||
resetPanels,
|
||||
toggleBothPanels,
|
||||
toggleLeftPanel,
|
||||
@@ -159,6 +156,11 @@ export const useAutoLayoutContext = () => {
|
||||
return value;
|
||||
};
|
||||
|
||||
export const useAutoLayoutContextSafe = () => {
|
||||
const value = useContext(AutoLayoutContext);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const PanelHotkeysLogical = memo(() => {
|
||||
const { toggleBothPanels, resetPanels, toggleLeftPanel, toggleRightPanel } = useAutoLayoutContext();
|
||||
useRegisteredHotkeys({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CanvasTabLeftPanel } from './CanvasTabLeftPanel';
|
||||
import { CanvasWorkspacePanel } from './CanvasWorkspacePanel';
|
||||
import {
|
||||
BOARDS_PANEL_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
GALLERY_PANEL_ID,
|
||||
LAUNCHPAD_PANEL_ID,
|
||||
LAYERS_PANEL_ID,
|
||||
@@ -28,11 +29,18 @@ import {
|
||||
RIGHT_PANEL_ID,
|
||||
RIGHT_PANEL_MIN_SIZE_PX,
|
||||
SETTINGS_PANEL_ID,
|
||||
TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
VIEWER_PANEL_ID,
|
||||
WORKSPACE_PANEL_ID,
|
||||
} from './shared';
|
||||
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
|
||||
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
|
||||
|
||||
const tabComponents = {
|
||||
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
|
||||
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
|
||||
};
|
||||
|
||||
const centerPanelComponents: IDockviewReactProps['components'] = {
|
||||
[LAUNCHPAD_PANEL_ID]: CanvasLaunchpadPanel,
|
||||
[WORKSPACE_PANEL_ID]: CanvasWorkspacePanel,
|
||||
@@ -45,11 +53,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
});
|
||||
api.addPanel({
|
||||
id: WORKSPACE_PANEL_ID,
|
||||
component: WORKSPACE_PANEL_ID,
|
||||
title: 'Canvas',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
@@ -59,15 +69,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
},
|
||||
});
|
||||
api.addPanel({
|
||||
id: PROGRESS_PANEL_ID,
|
||||
component: PROGRESS_PANEL_ID,
|
||||
title: 'Generation Progress',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
@@ -107,10 +109,10 @@ const CenterPanel = memo(() => {
|
||||
locked={true}
|
||||
disableFloatingGroups={true}
|
||||
dndEdges={false}
|
||||
defaultTabComponent={TabWithoutCloseButton}
|
||||
components={centerPanelComponents}
|
||||
onReady={onReady}
|
||||
theme={dockviewTheme}
|
||||
tabComponents={tabComponents}
|
||||
/>
|
||||
<FloatingCanvasLeftPanelButtons />
|
||||
<FloatingRightPanelButtons />
|
||||
|
||||
@@ -16,6 +16,7 @@ import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { GenerateTabLeftPanel } from './GenerateTabLeftPanel';
|
||||
import {
|
||||
BOARDS_PANEL_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
GALLERY_PANEL_ID,
|
||||
LAUNCHPAD_PANEL_ID,
|
||||
LEFT_PANEL_ID,
|
||||
@@ -25,10 +26,17 @@ import {
|
||||
RIGHT_PANEL_ID,
|
||||
RIGHT_PANEL_MIN_SIZE_PX,
|
||||
SETTINGS_PANEL_ID,
|
||||
TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
VIEWER_PANEL_ID,
|
||||
} from './shared';
|
||||
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
|
||||
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
|
||||
|
||||
const tabComponents = {
|
||||
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
|
||||
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
|
||||
};
|
||||
|
||||
const centerPanelComponents: IDockviewReactProps['components'] = {
|
||||
[LAUNCHPAD_PANEL_ID]: GenerateLaunchpadPanel,
|
||||
[VIEWER_PANEL_ID]: ImageViewerPanel,
|
||||
@@ -40,20 +48,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
});
|
||||
api.addPanel({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
},
|
||||
});
|
||||
api.addPanel({
|
||||
id: PROGRESS_PANEL_ID,
|
||||
component: PROGRESS_PANEL_ID,
|
||||
title: 'Generation Progress',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
@@ -93,7 +94,7 @@ const CenterPanel = memo(() => {
|
||||
locked={true}
|
||||
disableFloatingGroups={true}
|
||||
dndEdges={false}
|
||||
defaultTabComponent={TabWithoutCloseButton}
|
||||
tabComponents={tabComponents}
|
||||
components={centerPanelComponents}
|
||||
onReady={onReady}
|
||||
theme={dockviewTheme}
|
||||
|
||||
@@ -13,5 +13,8 @@ export const LAYERS_PANEL_ID = 'layers';
|
||||
|
||||
export const SETTINGS_PANEL_ID = 'settings';
|
||||
|
||||
export const DEFAULT_TAB_ID = 'default-tab';
|
||||
export const TAB_WITH_PROGRESS_INDICATOR_ID = 'tab-with-progress-indicator';
|
||||
|
||||
export const LEFT_PANEL_MIN_SIZE_PX = 420;
|
||||
export const RIGHT_PANEL_MIN_SIZE_PX = 420;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { memo, useCallback, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
BOARDS_PANEL_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
GALLERY_PANEL_ID,
|
||||
LAUNCHPAD_PANEL_ID,
|
||||
LEFT_PANEL_ID,
|
||||
@@ -24,11 +25,18 @@ import {
|
||||
RIGHT_PANEL_ID,
|
||||
RIGHT_PANEL_MIN_SIZE_PX,
|
||||
SETTINGS_PANEL_ID,
|
||||
TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
VIEWER_PANEL_ID,
|
||||
} from './shared';
|
||||
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
|
||||
import { UpscalingTabLeftPanel } from './UpscalingTabLeftPanel';
|
||||
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
|
||||
|
||||
const tabComponents = {
|
||||
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
|
||||
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
|
||||
};
|
||||
|
||||
const centerComponents: IDockviewReactProps['components'] = {
|
||||
[LAUNCHPAD_PANEL_ID]: UpscalingLaunchpadPanel,
|
||||
[VIEWER_PANEL_ID]: ImageViewerPanel,
|
||||
@@ -40,20 +48,13 @@ const initializeCenterLayout = (api: DockviewApi) => {
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
});
|
||||
api.addPanel({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
},
|
||||
});
|
||||
api.addPanel({
|
||||
id: PROGRESS_PANEL_ID,
|
||||
component: PROGRESS_PANEL_ID,
|
||||
title: 'Generation Progress',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
@@ -92,7 +93,7 @@ const CenterPanel = memo(() => {
|
||||
locked={true}
|
||||
disableFloatingGroups={true}
|
||||
dndEdges={false}
|
||||
defaultTabComponent={TabWithoutCloseButton}
|
||||
tabComponents={tabComponents}
|
||||
components={centerComponents}
|
||||
onReady={onReady}
|
||||
theme={dockviewTheme}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { memo, useCallback, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
BOARDS_PANEL_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
GALLERY_PANEL_ID,
|
||||
LAUNCHPAD_PANEL_ID,
|
||||
LEFT_PANEL_ID,
|
||||
@@ -26,11 +27,18 @@ import {
|
||||
RIGHT_PANEL_ID,
|
||||
RIGHT_PANEL_MIN_SIZE_PX,
|
||||
SETTINGS_PANEL_ID,
|
||||
TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
VIEWER_PANEL_ID,
|
||||
WORKSPACE_PANEL_ID,
|
||||
} from './shared';
|
||||
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
|
||||
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
|
||||
|
||||
const tabComponents = {
|
||||
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
|
||||
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
|
||||
};
|
||||
|
||||
const centerPanelComponents: IDockviewReactProps['components'] = {
|
||||
[LAUNCHPAD_PANEL_ID]: WorkflowsLaunchpadPanel,
|
||||
[WORKSPACE_PANEL_ID]: NodeEditor,
|
||||
@@ -43,11 +51,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
});
|
||||
api.addPanel({
|
||||
id: WORKSPACE_PANEL_ID,
|
||||
component: WORKSPACE_PANEL_ID,
|
||||
title: 'Workflow Editor',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
@@ -57,15 +67,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
},
|
||||
});
|
||||
api.addPanel({
|
||||
id: PROGRESS_PANEL_ID,
|
||||
component: PROGRESS_PANEL_ID,
|
||||
title: 'Generation Progress',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: LAUNCHPAD_PANEL_ID,
|
||||
@@ -105,7 +107,7 @@ const CenterPanel = memo(() => {
|
||||
locked={true}
|
||||
disableFloatingGroups={true}
|
||||
dndEdges={false}
|
||||
defaultTabComponent={TabWithoutCloseButton}
|
||||
tabComponents={tabComponents}
|
||||
components={centerPanelComponents}
|
||||
onReady={onReady}
|
||||
theme={dockviewTheme}
|
||||
|
||||
@@ -57,8 +57,9 @@
|
||||
|
||||
.dv-tab {
|
||||
/* margin-right: 2px; */
|
||||
padding-inline-start: var(--invoke-sizes-4);
|
||||
padding-inline-end: var(--invoke-sizes-4);
|
||||
/* padding-inline-start: var(--invoke-sizes-4);
|
||||
padding-inline-end: var(--invoke-sizes-4); */
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.dv-inactive-group .dv-tabs-container.dv-horizontal .dv-tab:not(:first-child)::before {
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { $authToken } from 'app/store/nanostores/authToken';
|
||||
import { getStore } from 'app/store/nanostores/store';
|
||||
import type { BoardId } from 'features/gallery/store/types';
|
||||
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import type { components, paths } from 'services/api/schema';
|
||||
import type {
|
||||
DeleteBoardResult,
|
||||
GraphAndWorkflowResponse,
|
||||
ImageCategory,
|
||||
ImageDTO,
|
||||
ImageUploadEntryRequest,
|
||||
ImageUploadEntryResponse,
|
||||
ListImagesArgs,
|
||||
ListImagesResponse,
|
||||
SQLiteDirection,
|
||||
UploadImageArg,
|
||||
} from 'services/api/types';
|
||||
import { getCategories, getListImagesUrl } from 'services/api/util';
|
||||
import stableHash from 'stable-hash';
|
||||
import type { Param0 } from 'tsafe';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
@@ -29,7 +28,8 @@ import { buildBoardsUrl } from './boards';
|
||||
* buildImagesUrl('some-path')
|
||||
* // '/api/v1/images/some-path'
|
||||
*/
|
||||
const buildImagesUrl = (path: string = '') => buildV1Url(`images/${path}`);
|
||||
const buildImagesUrl = (path: string = '', query?: Parameters<typeof buildV1Url>[1]) =>
|
||||
buildV1Url(`images/${path}`, query);
|
||||
|
||||
/**
|
||||
* Builds an endpoint URL for the board_images router
|
||||
@@ -50,10 +50,10 @@ export const imagesApi = api.injectEndpoints({
|
||||
url: getListImagesUrl(queryArgs),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: (result, error, { board_id, categories }) => {
|
||||
providesTags: (result, error, queryArgs) => {
|
||||
return [
|
||||
// Make the tags the same as the cache key
|
||||
{ type: 'ImageList', id: getListImagesUrl({ board_id, categories }) },
|
||||
{ type: 'ImageList', id: JSON.stringify(queryArgs) },
|
||||
'FetchOnReconnect',
|
||||
];
|
||||
},
|
||||
@@ -80,7 +80,12 @@ export const imagesApi = api.injectEndpoints({
|
||||
}),
|
||||
clearIntermediates: build.mutation<number, void>({
|
||||
query: () => ({ url: buildImagesUrl('intermediates'), method: 'DELETE' }),
|
||||
invalidatesTags: ['IntermediatesCount', 'InvocationCacheStatus'],
|
||||
invalidatesTags: [
|
||||
'IntermediatesCount',
|
||||
'InvocationCacheStatus',
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
],
|
||||
}),
|
||||
getImageDTO: build.query<ImageDTO, string>({
|
||||
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}`) }),
|
||||
@@ -94,141 +99,89 @@ export const imagesApi = api.injectEndpoints({
|
||||
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}/workflow`) }),
|
||||
providesTags: (result, error, image_name) => [{ type: 'ImageWorkflow', id: image_name }],
|
||||
}),
|
||||
deleteImage: build.mutation<void, ImageDTO>({
|
||||
deleteImage: build.mutation<
|
||||
paths['/api/v1/images/i/{image_name}']['delete']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/i/{image_name}']['delete']['parameters']['path']
|
||||
>({
|
||||
query: ({ image_name }) => ({
|
||||
url: buildImagesUrl(`i/${image_name}`),
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (result, error, imageDTO) => {
|
||||
const categories = getCategories(imageDTO);
|
||||
const boardId = imageDTO.board_id ?? 'none';
|
||||
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: boardId,
|
||||
categories,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'Board',
|
||||
id: boardId,
|
||||
},
|
||||
{
|
||||
type: 'BoardImagesTotal',
|
||||
id: boardId,
|
||||
},
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
|
||||
deleteImages: build.mutation<components['schemas']['DeleteImagesFromListResult'], { imageDTOs: ImageDTO[] }>({
|
||||
query: ({ imageDTOs }) => {
|
||||
const image_names = imageDTOs.map((imageDTO) => imageDTO.image_name);
|
||||
return {
|
||||
url: buildImagesUrl('delete'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
image_names,
|
||||
},
|
||||
};
|
||||
},
|
||||
invalidatesTags: (result, error, { imageDTOs }) => {
|
||||
const tags: ApiTagDescription[] = [];
|
||||
for (const imageDTO of imageDTOs) {
|
||||
const categories = getCategories(imageDTO);
|
||||
const boardId = imageDTO.board_id ?? 'none';
|
||||
|
||||
tags.push(
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: boardId,
|
||||
categories,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'Board',
|
||||
id: boardId,
|
||||
},
|
||||
{
|
||||
type: 'BoardImagesTotal',
|
||||
id: boardId,
|
||||
}
|
||||
);
|
||||
deleteImages: build.mutation<
|
||||
paths['/api/v1/images/delete']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/delete']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildImagesUrl('delete'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dedupedTags = uniqBy(tags, stableHash);
|
||||
return dedupedTags;
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
deleteUncategorizedImages: build.mutation<components['schemas']['DeleteImagesFromListResult'], void>({
|
||||
deleteUncategorizedImages: build.mutation<
|
||||
paths['/api/v1/images/uncategorized']['delete']['responses']['200']['content']['application/json'],
|
||||
void
|
||||
>({
|
||||
query: () => ({ url: buildImagesUrl('uncategorized'), method: 'DELETE' }),
|
||||
invalidatesTags: (result) => {
|
||||
if (result && result.deleted_images.length > 0) {
|
||||
const boardId = 'none';
|
||||
|
||||
const tags: ApiTagDescription[] = [
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: boardId,
|
||||
categories: IMAGE_CATEGORIES,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: boardId,
|
||||
categories: ASSETS_CATEGORIES,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'Board',
|
||||
id: boardId,
|
||||
},
|
||||
{
|
||||
type: 'BoardImagesTotal',
|
||||
id: boardId,
|
||||
},
|
||||
];
|
||||
|
||||
return tags;
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Change an image's `is_intermediate` property.
|
||||
*/
|
||||
changeImageIsIntermediate: build.mutation<ImageDTO, { imageDTO: ImageDTO; is_intermediate: boolean }>({
|
||||
query: ({ imageDTO, is_intermediate }) => ({
|
||||
url: buildImagesUrl(`i/${imageDTO.image_name}`),
|
||||
changeImageIsIntermediate: build.mutation<
|
||||
paths['/api/v1/images/i/{image_name}']['patch']['responses']['200']['content']['application/json'],
|
||||
{ image_name: string; is_intermediate: boolean }
|
||||
>({
|
||||
query: ({ image_name, is_intermediate }) => ({
|
||||
url: buildImagesUrl(`i/${image_name}`),
|
||||
method: 'PATCH',
|
||||
body: { is_intermediate },
|
||||
}),
|
||||
invalidatesTags: (result, error, { imageDTO }) => {
|
||||
const categories = getCategories(imageDTO);
|
||||
const boardId = imageDTO.board_id ?? 'none';
|
||||
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ type: 'Image', id: imageDTO.image_name },
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: boardId,
|
||||
categories,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'Board',
|
||||
id: boardId,
|
||||
},
|
||||
{
|
||||
type: 'BoardImagesTotal',
|
||||
id: boardId,
|
||||
},
|
||||
...getTagsToInvalidateForImageMutation([result.image_name]),
|
||||
...getTagsToInvalidateForBoardAffectingMutation([result.board_id ?? 'none']),
|
||||
];
|
||||
},
|
||||
}),
|
||||
@@ -236,38 +189,25 @@ export const imagesApi = api.injectEndpoints({
|
||||
* Star a list of images.
|
||||
*/
|
||||
starImages: build.mutation<
|
||||
paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'],
|
||||
{ imageDTOs: ImageDTO[] }
|
||||
paths['/api/v1/images/star']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/star']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: ({ imageDTOs: images }) => ({
|
||||
query: (body) => ({
|
||||
url: buildImagesUrl('star'),
|
||||
method: 'POST',
|
||||
body: { image_names: images.map((img) => img.image_name) },
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result, error, { imageDTOs }) => {
|
||||
// assume all images are on the same board/category
|
||||
if (imageDTOs[0]) {
|
||||
const categories = getCategories(imageDTOs[0]);
|
||||
const boardId = imageDTOs[0].board_id ?? 'none';
|
||||
const tags: ApiTagDescription[] = [
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: boardId,
|
||||
categories,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'Board',
|
||||
id: boardId,
|
||||
},
|
||||
];
|
||||
for (const imageDTO of imageDTOs) {
|
||||
tags.push({ type: 'Image', id: imageDTO.image_name });
|
||||
}
|
||||
return tags;
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.starred_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: 'starred' },
|
||||
{ type: 'ImageCollection', id: 'unstarred' },
|
||||
];
|
||||
},
|
||||
}),
|
||||
/**
|
||||
@@ -275,40 +215,30 @@ export const imagesApi = api.injectEndpoints({
|
||||
*/
|
||||
unstarImages: build.mutation<
|
||||
paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'],
|
||||
{ imageDTOs: ImageDTO[] }
|
||||
paths['/api/v1/images/unstar']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: ({ imageDTOs: images }) => ({
|
||||
query: (body) => ({
|
||||
url: buildImagesUrl('unstar'),
|
||||
method: 'POST',
|
||||
body: { image_names: images.map((img) => img.image_name) },
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result, error, { imageDTOs }) => {
|
||||
// assume all images are on the same board/category
|
||||
if (imageDTOs[0]) {
|
||||
const categories = getCategories(imageDTOs[0]);
|
||||
const boardId = imageDTOs[0].board_id ?? 'none';
|
||||
const tags: ApiTagDescription[] = [
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: boardId,
|
||||
categories,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'Board',
|
||||
id: boardId,
|
||||
},
|
||||
];
|
||||
for (const imageDTO of imageDTOs) {
|
||||
tags.push({ type: 'Image', id: imageDTO.image_name });
|
||||
}
|
||||
return tags;
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.unstarred_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: 'starred' },
|
||||
{ type: 'ImageCollection', id: 'unstarred' },
|
||||
];
|
||||
},
|
||||
}),
|
||||
uploadImage: build.mutation<ImageDTO, UploadImageArg>({
|
||||
uploadImage: build.mutation<
|
||||
paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'],
|
||||
UploadImageArg
|
||||
>({
|
||||
query: ({ file, image_category, is_intermediate, session_id, board_id, crop_visible, metadata, resize_to }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -366,8 +296,11 @@ export const imagesApi = api.injectEndpoints({
|
||||
body: { width, height, board_id },
|
||||
}),
|
||||
}),
|
||||
deleteBoard: build.mutation<DeleteBoardResult, string>({
|
||||
query: (board_id) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }),
|
||||
deleteBoard: build.mutation<
|
||||
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/boards/{board_id}']['delete']['parameters']['path']
|
||||
>({
|
||||
query: ({ board_id }) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }),
|
||||
invalidatesTags: () => [
|
||||
{ type: 'Board', id: LIST_TAG },
|
||||
// invalidate the 'No Board' cache
|
||||
@@ -388,192 +321,95 @@ export const imagesApi = api.injectEndpoints({
|
||||
],
|
||||
}),
|
||||
|
||||
deleteBoardAndImages: build.mutation<DeleteBoardResult, string>({
|
||||
query: (board_id) => ({
|
||||
deleteBoardAndImages: build.mutation<
|
||||
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/boards/{board_id}']['delete']['parameters']['path']
|
||||
>({
|
||||
query: ({ board_id }) => ({
|
||||
url: buildBoardsUrl(board_id),
|
||||
method: 'DELETE',
|
||||
params: { include_images: true },
|
||||
}),
|
||||
invalidatesTags: () => [{ type: 'Board', id: LIST_TAG }],
|
||||
}),
|
||||
addImageToBoard: build.mutation<void, { board_id: BoardId; imageDTO: ImageDTO }>({
|
||||
query: ({ board_id, imageDTO }) => {
|
||||
const { image_name } = imageDTO;
|
||||
addImageToBoard: build.mutation<
|
||||
paths['/api/v1/board_images/']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => {
|
||||
return {
|
||||
url: buildBoardImagesUrl(),
|
||||
method: 'POST',
|
||||
body: { board_id, image_name },
|
||||
body,
|
||||
};
|
||||
},
|
||||
invalidatesTags: (result, error, { board_id, imageDTO }) => {
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ type: 'Image', id: imageDTO.image_name },
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id,
|
||||
categories: getCategories(imageDTO),
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: imageDTO.board_id ?? 'none',
|
||||
categories: getCategories(imageDTO),
|
||||
}),
|
||||
},
|
||||
{ type: 'Board', id: board_id },
|
||||
{ type: 'Board', id: imageDTO.board_id ?? 'none' },
|
||||
{
|
||||
type: 'BoardImagesTotal',
|
||||
id: imageDTO.board_id ?? 'none',
|
||||
},
|
||||
{
|
||||
type: 'BoardImagesTotal',
|
||||
id: board_id,
|
||||
},
|
||||
...getTagsToInvalidateForImageMutation(result.added_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
removeImageFromBoard: build.mutation<void, { imageDTO: ImageDTO }>({
|
||||
query: ({ imageDTO }) => {
|
||||
const { image_name } = imageDTO;
|
||||
removeImageFromBoard: build.mutation<
|
||||
paths['/api/v1/board_images/']['delete']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => {
|
||||
return {
|
||||
url: buildBoardImagesUrl(),
|
||||
method: 'DELETE',
|
||||
body: { image_name },
|
||||
body,
|
||||
};
|
||||
},
|
||||
invalidatesTags: (result, error, { imageDTO }) => {
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ type: 'Image', id: imageDTO.image_name },
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: imageDTO.board_id,
|
||||
categories: getCategories(imageDTO),
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: 'none',
|
||||
categories: getCategories(imageDTO),
|
||||
}),
|
||||
},
|
||||
{ type: 'Board', id: imageDTO.board_id ?? 'none' },
|
||||
{ type: 'Board', id: 'none' },
|
||||
{
|
||||
type: 'BoardImagesTotal',
|
||||
id: imageDTO.board_id ?? 'none',
|
||||
},
|
||||
{ type: 'BoardImagesTotal', id: 'none' },
|
||||
...getTagsToInvalidateForImageMutation(result.removed_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
addImagesToBoard: build.mutation<
|
||||
components['schemas']['AddImagesToBoardResult'],
|
||||
{
|
||||
board_id: string;
|
||||
imageDTOs: ImageDTO[];
|
||||
}
|
||||
paths['/api/v1/board_images/batch']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/batch']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: ({ board_id, imageDTOs }) => ({
|
||||
query: (body) => ({
|
||||
url: buildBoardImagesUrl('batch'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
image_names: imageDTOs.map((i) => i.image_name),
|
||||
board_id,
|
||||
},
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result, error, { board_id, imageDTOs }) => {
|
||||
const tags: ApiTagDescription[] = [];
|
||||
if (imageDTOs[0]) {
|
||||
tags.push({
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: imageDTOs[0].board_id ?? 'none',
|
||||
categories: getCategories(imageDTOs[0]),
|
||||
}),
|
||||
});
|
||||
tags.push({
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: board_id,
|
||||
categories: getCategories(imageDTOs[0]),
|
||||
}),
|
||||
});
|
||||
tags.push({ type: 'Board', id: imageDTOs[0].board_id ?? 'none' });
|
||||
tags.push({
|
||||
type: 'BoardImagesTotal',
|
||||
id: imageDTOs[0].board_id ?? 'none',
|
||||
});
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
for (const imageDTO of imageDTOs) {
|
||||
tags.push({ type: 'Image', id: imageDTO.image_name });
|
||||
}
|
||||
tags.push({ type: 'Board', id: board_id });
|
||||
tags.push({
|
||||
type: 'BoardImagesTotal',
|
||||
id: board_id ?? 'none',
|
||||
});
|
||||
return tags;
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.added_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
removeImagesFromBoard: build.mutation<
|
||||
components['schemas']['RemoveImagesFromBoardResult'],
|
||||
{
|
||||
imageDTOs: ImageDTO[];
|
||||
}
|
||||
paths['/api/v1/board_images/batch/delete']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/batch/delete']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: ({ imageDTOs }) => ({
|
||||
query: (body) => ({
|
||||
url: buildBoardImagesUrl('batch/delete'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
image_names: imageDTOs.map((i) => i.image_name),
|
||||
},
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result, error, { imageDTOs }) => {
|
||||
const touchedBoardIds: string[] = [];
|
||||
const tags: ApiTagDescription[] = [];
|
||||
|
||||
if (imageDTOs[0]) {
|
||||
tags.push({
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: imageDTOs[0].board_id,
|
||||
categories: getCategories(imageDTOs[0]),
|
||||
}),
|
||||
});
|
||||
tags.push({
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id: 'none',
|
||||
categories: getCategories(imageDTOs[0]),
|
||||
}),
|
||||
});
|
||||
tags.push({
|
||||
type: 'BoardImagesTotal',
|
||||
id: 'none',
|
||||
});
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
result?.removed_image_names.forEach((image_name) => {
|
||||
const board_id = imageDTOs.find((i) => i.image_name === image_name)?.board_id;
|
||||
|
||||
if (!board_id || touchedBoardIds.includes(board_id)) {
|
||||
tags.push({ type: 'Board', id: 'none' });
|
||||
return;
|
||||
}
|
||||
tags.push({ type: 'Image', id: image_name });
|
||||
tags.push({ type: 'Board', id: board_id });
|
||||
tags.push({
|
||||
type: 'BoardImagesTotal',
|
||||
id: board_id ?? 'none',
|
||||
});
|
||||
});
|
||||
|
||||
return tags;
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.removed_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
bulkDownloadImages: build.mutation<
|
||||
@@ -589,6 +425,113 @@ export const imagesApi = api.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
}),
|
||||
/**
|
||||
* Get counts for starred and unstarred image collections
|
||||
*/
|
||||
getImageCollectionCounts: build.query<
|
||||
paths['/api/v1/images/collections/counts']['get']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/collections/counts']['get']['parameters']['query']
|
||||
>({
|
||||
query: (queryArgs) => ({
|
||||
url: buildImagesUrl('collections/counts', queryArgs),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['ImageCollectionCounts', 'FetchOnReconnect'],
|
||||
}),
|
||||
/**
|
||||
* Get images from a specific collection (starred or unstarred)
|
||||
*/
|
||||
getImageCollection: build.query<
|
||||
paths['/api/v1/images/collections/{collection}']['get']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/collections/{collection}']['get']['parameters']['path'] &
|
||||
paths['/api/v1/images/collections/{collection}']['get']['parameters']['query']
|
||||
>({
|
||||
query: ({ collection, ...queryArgs }) => ({
|
||||
url: buildImagesUrl(`collections/${collection}`, queryArgs),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: (result, error, { collection, board_id, categories }) => {
|
||||
const cacheKey = `${collection}-${board_id || 'all'}-${categories?.join(',') || 'all'}`;
|
||||
return [
|
||||
{ type: 'ImageCollection', id: collection },
|
||||
{ type: 'ImageCollection', id: cacheKey },
|
||||
'FetchOnReconnect',
|
||||
];
|
||||
},
|
||||
async onQueryStarted(_, { dispatch, queryFulfilled }) {
|
||||
// Populate the getImageDTO cache with these images, similar to listImages
|
||||
const res = await queryFulfilled;
|
||||
const imageDTOs = res.data.items;
|
||||
const updates: Param0<typeof imagesApi.util.upsertQueryEntries> = [];
|
||||
for (const imageDTO of imageDTOs) {
|
||||
updates.push({
|
||||
endpointName: 'getImageDTO',
|
||||
arg: imageDTO.image_name,
|
||||
value: imageDTO,
|
||||
});
|
||||
}
|
||||
dispatch(imagesApi.util.upsertQueryEntries(updates));
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Get ordered list of image names for selection operations
|
||||
*/
|
||||
getImageNames: build.query<
|
||||
string[],
|
||||
{
|
||||
image_origin?: 'internal' | 'external' | null;
|
||||
categories?: ImageCategory[] | null;
|
||||
is_intermediate?: boolean | null;
|
||||
board_id?: string | null;
|
||||
search_term?: string | null;
|
||||
order_dir?: SQLiteDirection;
|
||||
}
|
||||
>({
|
||||
query: (queryArgs) => ({
|
||||
url: buildImagesUrl('names', queryArgs),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['ImageNameList', 'FetchOnReconnect'],
|
||||
}),
|
||||
/**
|
||||
* Get paginated images with starred first (unified list)
|
||||
*/
|
||||
getUnifiedImageList: build.query<
|
||||
ListImagesResponse,
|
||||
{
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
image_origin?: 'internal' | 'external' | null;
|
||||
categories?: ImageCategory[] | null;
|
||||
is_intermediate?: boolean | null;
|
||||
board_id?: string | null;
|
||||
search_term?: string | null;
|
||||
order_dir?: SQLiteDirection;
|
||||
}
|
||||
>({
|
||||
query: (queryArgs) => ({
|
||||
url: getListImagesUrl({ ...queryArgs, starred_first: true }),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: (result, error, { board_id, categories }) => [
|
||||
{ type: 'ImageList', id: getListImagesUrl({ board_id, categories }) },
|
||||
'FetchOnReconnect',
|
||||
],
|
||||
async onQueryStarted(_, { dispatch, queryFulfilled }) {
|
||||
// Populate the getImageDTO cache with these images
|
||||
const res = await queryFulfilled;
|
||||
const imageDTOs = res.data.items;
|
||||
const updates: Param0<typeof imagesApi.util.upsertQueryEntries> = [];
|
||||
for (const imageDTO of imageDTOs) {
|
||||
updates.push({
|
||||
endpointName: 'getImageDTO',
|
||||
arg: imageDTO.image_name,
|
||||
value: imageDTO,
|
||||
});
|
||||
}
|
||||
dispatch(imagesApi.util.upsertQueryEntries(updates));
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -610,6 +553,11 @@ export const {
|
||||
useStarImagesMutation,
|
||||
useUnstarImagesMutation,
|
||||
useBulkDownloadImagesMutation,
|
||||
useGetImageCollectionCountsQuery,
|
||||
useGetImageCollectionQuery,
|
||||
useLazyGetImageCollectionQuery,
|
||||
useGetImageNamesQuery,
|
||||
useGetUnifiedImageListQuery,
|
||||
} = imagesApi;
|
||||
|
||||
/**
|
||||
@@ -711,3 +659,63 @@ export const imageDTOToFile = async (imageDTO: ImageDTO): Promise<File> => {
|
||||
const file = new File([blob], `copy_of_${imageDTO.image_name}`, { type: 'image/png' });
|
||||
return file;
|
||||
};
|
||||
|
||||
export const useImageDTO = (imageName: string | null | undefined) => {
|
||||
const { data: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
|
||||
return imageDTO ?? null;
|
||||
};
|
||||
|
||||
export const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => {
|
||||
const tags: ApiTagDescription[] = [];
|
||||
|
||||
for (const image_name of image_names) {
|
||||
tags.push({
|
||||
type: 'Image',
|
||||
id: image_name,
|
||||
});
|
||||
tags.push({
|
||||
type: 'ImageMetadata',
|
||||
id: image_name,
|
||||
});
|
||||
tags.push({
|
||||
type: 'ImageWorkflow',
|
||||
id: image_name,
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
};
|
||||
|
||||
export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => {
|
||||
const tags: ApiTagDescription[] = [];
|
||||
|
||||
for (const board_id of affected_boards) {
|
||||
tags.push({
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id,
|
||||
categories: IMAGE_CATEGORIES,
|
||||
}),
|
||||
});
|
||||
|
||||
tags.push({
|
||||
type: 'ImageList',
|
||||
id: getListImagesUrl({
|
||||
board_id,
|
||||
categories: ASSETS_CATEGORIES,
|
||||
}),
|
||||
});
|
||||
|
||||
tags.push({
|
||||
type: 'Board',
|
||||
id: board_id,
|
||||
});
|
||||
|
||||
tags.push({
|
||||
type: 'BoardImagesTotal',
|
||||
id: board_id,
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
};
|
||||
|
||||
@@ -396,3 +396,8 @@ export const selectCanvasQueueCounts = queueApi.endpoints.getQueueCountsByDestin
|
||||
export const enqueueMutationFixedCacheKeyOptions = {
|
||||
fixedCacheKey: 'enqueueBatch',
|
||||
} as const;
|
||||
|
||||
export const useIsGenerationInProgress = () => {
|
||||
const { data } = useGetQueueStatusQuery();
|
||||
return data && data.queue.in_progress > 0;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildCreateApi, coreModule, fetchBaseQuery, reactHooksModule } from '@r
|
||||
import { $authToken } from 'app/store/nanostores/authToken';
|
||||
import { $baseUrl } from 'app/store/nanostores/baseUrl';
|
||||
import { $projectId } from 'app/store/nanostores/projectId';
|
||||
import queryString from 'query-string';
|
||||
|
||||
const tagTypes = [
|
||||
'AppVersion',
|
||||
@@ -23,6 +24,8 @@ const tagTypes = [
|
||||
'ImageList',
|
||||
'ImageMetadata',
|
||||
'ImageWorkflow',
|
||||
'ImageCollectionCounts',
|
||||
'ImageCollection',
|
||||
'ImageMetadataFromFile',
|
||||
'IntermediatesCount',
|
||||
'SessionQueueItem',
|
||||
@@ -131,5 +134,10 @@ function getCircularReplacer() {
|
||||
};
|
||||
}
|
||||
|
||||
export const buildV1Url = (path: string): string => `api/v1/${path}`;
|
||||
export const buildV1Url = (path: string, query?: Parameters<typeof queryString.stringify>[0]): string => {
|
||||
if (!query) {
|
||||
return `api/v1/${path}`;
|
||||
}
|
||||
return `api/v1/${path}?${queryString.stringify(query)}`;
|
||||
};
|
||||
export const buildV2Url = (path: string): string => `api/v2/${path}`;
|
||||
|
||||
@@ -752,6 +752,46 @@ export type paths = {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/images/collections/counts": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Image Collection Counts
|
||||
* @description Gets counts for starred and unstarred image collections
|
||||
*/
|
||||
get: operations["get_image_collection_counts"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/images/collections/{collection}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Image Collection
|
||||
* @description Gets images from a specific collection (starred or unstarred)
|
||||
*/
|
||||
get: operations["get_image_collection"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/boards/": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1781,15 +1821,15 @@ export type components = {
|
||||
/** AddImagesToBoardResult */
|
||||
AddImagesToBoardResult: {
|
||||
/**
|
||||
* Board Id
|
||||
* @description The id of the board the images were added to
|
||||
* Affected Boards
|
||||
* @description The ids of boards affected by the delete operation
|
||||
*/
|
||||
board_id: string;
|
||||
affected_boards: string[];
|
||||
/**
|
||||
* Added Image Names
|
||||
* Added Images
|
||||
* @description The image names that were added to the board
|
||||
*/
|
||||
added_image_names: string[];
|
||||
added_images: string[];
|
||||
};
|
||||
/**
|
||||
* Add Integers
|
||||
@@ -5945,9 +5985,17 @@ export type components = {
|
||||
*/
|
||||
deleted: number;
|
||||
};
|
||||
/** DeleteImagesFromListResult */
|
||||
DeleteImagesFromListResult: {
|
||||
/** Deleted Images */
|
||||
/** DeleteImagesResult */
|
||||
DeleteImagesResult: {
|
||||
/**
|
||||
* Affected Boards
|
||||
* @description The ids of boards affected by the delete operation
|
||||
*/
|
||||
affected_boards: string[];
|
||||
/**
|
||||
* Deleted Images
|
||||
* @description The names of the images that were deleted
|
||||
*/
|
||||
deleted_images: string[];
|
||||
};
|
||||
/**
|
||||
@@ -9791,6 +9839,19 @@ export type components = {
|
||||
*/
|
||||
type: "img_channel_offset";
|
||||
};
|
||||
/** ImageCollectionCounts */
|
||||
ImageCollectionCounts: {
|
||||
/**
|
||||
* Starred Count
|
||||
* @description The number of starred images in the collection.
|
||||
*/
|
||||
starred_count: number;
|
||||
/**
|
||||
* Unstarred Count
|
||||
* @description The number of unstarred images in the collection.
|
||||
*/
|
||||
unstarred_count: number;
|
||||
};
|
||||
/**
|
||||
* Image Collection Primitive
|
||||
* @description A collection of image primitive values
|
||||
@@ -11019,14 +11080,6 @@ export type components = {
|
||||
*/
|
||||
bulk_download_item_name?: string | null;
|
||||
};
|
||||
/** ImagesUpdatedFromListResult */
|
||||
ImagesUpdatedFromListResult: {
|
||||
/**
|
||||
* Updated Image Names
|
||||
* @description The image names that were updated
|
||||
*/
|
||||
updated_image_names: string[];
|
||||
};
|
||||
/**
|
||||
* Solid Color Infill
|
||||
* @description Infills transparent areas of an image with a solid color
|
||||
@@ -17798,10 +17851,15 @@ export type components = {
|
||||
/** RemoveImagesFromBoardResult */
|
||||
RemoveImagesFromBoardResult: {
|
||||
/**
|
||||
* Removed Image Names
|
||||
* Affected Boards
|
||||
* @description The ids of boards affected by the delete operation
|
||||
*/
|
||||
affected_boards: string[];
|
||||
/**
|
||||
* Removed Images
|
||||
* @description The image names that were removed from their board
|
||||
*/
|
||||
removed_image_names: string[];
|
||||
removed_images: string[];
|
||||
};
|
||||
/**
|
||||
* Resize Latents
|
||||
@@ -19602,6 +19660,19 @@ export type components = {
|
||||
*/
|
||||
type: "spandrel_image_to_image";
|
||||
};
|
||||
/** StarredImagesResult */
|
||||
StarredImagesResult: {
|
||||
/**
|
||||
* Affected Boards
|
||||
* @description The ids of boards affected by the delete operation
|
||||
*/
|
||||
affected_boards: string[];
|
||||
/**
|
||||
* Starred Images
|
||||
* @description The names of the images that were starred
|
||||
*/
|
||||
starred_images: string[];
|
||||
};
|
||||
/** StarterModel */
|
||||
StarterModel: {
|
||||
/** Description */
|
||||
@@ -21207,6 +21278,19 @@ export type components = {
|
||||
*/
|
||||
type: "unsharp_mask";
|
||||
};
|
||||
/** UnstarredImagesResult */
|
||||
UnstarredImagesResult: {
|
||||
/**
|
||||
* Affected Boards
|
||||
* @description The ids of boards affected by the delete operation
|
||||
*/
|
||||
affected_boards: string[];
|
||||
/**
|
||||
* Unstarred Images
|
||||
* @description The names of the images that were unstarred
|
||||
*/
|
||||
unstarred_images: string[];
|
||||
};
|
||||
/** Upscaler */
|
||||
Upscaler: {
|
||||
/**
|
||||
@@ -23064,7 +23148,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
"application/json": components["schemas"]["DeleteImagesResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -23386,7 +23470,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeleteImagesFromListResult"];
|
||||
"application/json": components["schemas"]["DeleteImagesResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -23415,7 +23499,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeleteImagesFromListResult"];
|
||||
"application/json": components["schemas"]["DeleteImagesResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -23439,7 +23523,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ImagesUpdatedFromListResult"];
|
||||
"application/json": components["schemas"]["StarredImagesResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -23472,7 +23556,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ImagesUpdatedFromListResult"];
|
||||
"application/json": components["schemas"]["UnstarredImagesResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -23558,6 +23642,95 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_image_collection_counts: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description The origin of images to count. */
|
||||
image_origin?: components["schemas"]["ResourceOrigin"] | null;
|
||||
/** @description The categories of image to include. */
|
||||
categories?: components["schemas"]["ImageCategory"][] | null;
|
||||
/** @description Whether to include intermediate images. */
|
||||
is_intermediate?: boolean | null;
|
||||
/** @description The board id to filter by. Use 'none' to find images without a board. */
|
||||
board_id?: string | null;
|
||||
/** @description The term to search for */
|
||||
search_term?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ImageCollectionCounts"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_image_collection: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description The origin of images to list. */
|
||||
image_origin?: components["schemas"]["ResourceOrigin"] | null;
|
||||
/** @description The categories of image to include. */
|
||||
categories?: components["schemas"]["ImageCategory"][] | null;
|
||||
/** @description Whether to list intermediate images. */
|
||||
is_intermediate?: boolean | null;
|
||||
/** @description The board id to filter by. Use 'none' to find images without a board. */
|
||||
board_id?: string | null;
|
||||
/** @description The offset within the collection */
|
||||
offset?: number;
|
||||
/** @description The number of images to return */
|
||||
limit?: number;
|
||||
/** @description The order of sort */
|
||||
order_dir?: components["schemas"]["SQLiteDirection"];
|
||||
/** @description The term to search for */
|
||||
search_term?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The collection to retrieve from */
|
||||
collection: "starred" | "unstarred";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_boards: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -23793,7 +23966,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
"application/json": components["schemas"]["AddImagesToBoardResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -23826,7 +23999,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
"application/json": components["schemas"]["RemoveImagesFromBoardResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
|
||||
@@ -117,7 +117,7 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi
|
||||
);
|
||||
} else {
|
||||
// Else just select the image, no need to switch boards
|
||||
dispatch(imageSelected(lastImageDTO));
|
||||
dispatch(imageSelected(lastImageDTO.image_name));
|
||||
|
||||
if (galleryView !== 'images') {
|
||||
// We also need to update the gallery view to images. This also updates the offset.
|
||||
|
||||
@@ -21,10 +21,12 @@ export const $lastProgressMessage = computed($lastProgressEvent, (val) => {
|
||||
if (!val) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message = val.message;
|
||||
if (val.percentage) {
|
||||
message += ` (${round(val.percentage * 100)}%)`;
|
||||
return formatProgressMessage(val);
|
||||
});
|
||||
export const formatProgressMessage = (data: S['InvocationProgressEvent']): string => {
|
||||
let message = data.message;
|
||||
if (data.percentage) {
|
||||
message += ` (${round(data.percentage * 100)}%)`;
|
||||
}
|
||||
return message;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "6.0.0a4"
|
||||
__version__ = "6.0.0a6"
|
||||
|
||||
Reference in New Issue
Block a user