diff --git a/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from pull_request_template.md rename to .github/pull_request_template.md diff --git a/.github/workflows/style-checks.yml b/.github/workflows/style-checks.yml index 8aceb6469e..d29b489418 100644 --- a/.github/workflows/style-checks.yml +++ b/.github/workflows/style-checks.yml @@ -1,13 +1,14 @@ -name: Black # TODO: add isort and flake8 later +name: style checks +# just formatting for now +# TODO: add isort and flake8 later on: - pull_request: {} + pull_request: push: - branches: master - tags: "*" + branches: main jobs: - test: + black: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -19,8 +20,7 @@ jobs: - name: Install dependencies with pip run: | - pip install --upgrade pip wheel - pip install .[test] + pip install black # - run: isort --check-only . - run: black --check . diff --git a/.github/workflows/test-invoke-pip-skip.yml b/.github/workflows/test-invoke-pip-skip.yml deleted file mode 100644 index 004b46d5a8..0000000000 --- a/.github/workflows/test-invoke-pip-skip.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Test invoke.py pip - -# This is a dummy stand-in for the actual tests -# we don't need to run python tests on non-Python changes -# But PRs require passing tests to be mergeable - -on: - pull_request: - paths: - - '**' - - '!pyproject.toml' - - '!invokeai/**' - - '!tests/**' - - 'invokeai/frontend/web/**' - merge_group: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - matrix: - if: github.event.pull_request.draft == false - strategy: - matrix: - python-version: - - '3.10' - pytorch: - - linux-cuda-11_7 - - linux-rocm-5_2 - - linux-cpu - - macos-default - - windows-cpu - include: - - pytorch: linux-cuda-11_7 - os: ubuntu-22.04 - - pytorch: linux-rocm-5_2 - os: ubuntu-22.04 - - pytorch: linux-cpu - os: ubuntu-22.04 - - pytorch: macos-default - os: macOS-12 - - pytorch: windows-cpu - os: windows-2022 - name: ${{ matrix.pytorch }} on ${{ matrix.python-version }} - runs-on: ${{ matrix.os }} - steps: - - name: skip - run: echo "no build required" diff --git a/.github/workflows/test-invoke-pip.yml b/.github/workflows/test-invoke-pip.yml index 40be0a529e..6086d10069 100644 --- a/.github/workflows/test-invoke-pip.yml +++ b/.github/workflows/test-invoke-pip.yml @@ -3,16 +3,7 @@ on: push: branches: - 'main' - paths: - - 'pyproject.toml' - - 'invokeai/**' - - '!invokeai/frontend/web/**' pull_request: - paths: - - 'pyproject.toml' - - 'invokeai/**' - - 'tests/**' - - '!invokeai/frontend/web/**' types: - 'ready_for_review' - 'opened' @@ -65,10 +56,23 @@ jobs: id: checkout-sources uses: actions/checkout@v3 + - name: Check for changed python files + id: changed-files + uses: tj-actions/changed-files@v37 + with: + files_yaml: | + python: + - 'pyproject.toml' + - 'invokeai/**' + - '!invokeai/frontend/web/**' + - 'tests/**' + - name: set test prompt to main branch validation + if: steps.changed-files.outputs.python_any_changed == 'true' run: echo "TEST_PROMPTS=tests/validate_pr_prompt.txt" >> ${{ matrix.github-env }} - name: setup python + if: steps.changed-files.outputs.python_any_changed == 'true' uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -76,6 +80,7 @@ jobs: cache-dependency-path: pyproject.toml - name: install invokeai + if: steps.changed-files.outputs.python_any_changed == 'true' env: PIP_EXTRA_INDEX_URL: ${{ matrix.extra-index-url }} run: > @@ -83,6 +88,7 @@ jobs: --editable=".[test]" - name: run pytest + if: steps.changed-files.outputs.python_any_changed == 'true' id: run-pytest run: pytest diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 6cb073ca7c..73607ecb7d 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -1,24 +1,30 @@ -from fastapi import Body, HTTPException, Path, Query +from fastapi import Body, HTTPException from fastapi.routing import APIRouter -from invokeai.app.services.board_record_storage import BoardRecord, BoardChanges -from invokeai.app.services.image_record_storage import OffsetPaginatedResults -from invokeai.app.services.models.board_record import BoardDTO -from invokeai.app.services.models.image_record import ImageDTO +from pydantic import BaseModel, Field from ..dependencies import ApiDependencies 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="create_board_image", + operation_id="add_image_to_board", responses={ 201: {"description": "The image was added to a board successfully"}, }, status_code=201, ) -async def create_board_image( +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"), ): @@ -29,26 +35,78 @@ async def create_board_image( ) return result except Exception as e: - raise HTTPException(status_code=500, detail="Failed to add to board") + raise HTTPException(status_code=500, detail="Failed to add image to board") @board_images_router.delete( "/", - operation_id="remove_board_image", + operation_id="remove_image_from_board", responses={ 201: {"description": "The image was removed from the board successfully"}, }, status_code=201, ) -async def remove_board_image( - board_id: str = Body(description="The id of the board"), - image_name: str = Body(description="The name of the image to remove"), +async def remove_image_from_board( + image_name: str = Body(description="The name of the image to remove", embed=True), ): - """Deletes a board_image""" + """Removes an image from its board, if it had one""" try: - result = ApiDependencies.invoker.services.board_images.remove_image_from_board( - board_id=board_id, image_name=image_name - ) + result = ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) return result except Exception as e: - raise HTTPException(status_code=500, detail="Failed to update board") + raise HTTPException(status_code=500, detail="Failed to remove image from board") + + +@board_images_router.post( + "/batch", + operation_id="add_images_to_board", + responses={ + 201: {"description": "Images were added to board successfully"}, + }, + status_code=201, + response_model=AddImagesToBoardResult, +) +async def add_images_to_board( + board_id: str = Body(description="The id of the board to add to"), + image_names: list[str] = Body(description="The names of the images to add", embed=True), +) -> AddImagesToBoardResult: + """Adds a list of images to a board""" + try: + added_image_names: list[str] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.board_images.add_image_to_board( + board_id=board_id, image_name=image_name + ) + added_image_names.append(image_name) + except: + pass + return AddImagesToBoardResult(board_id=board_id, added_image_names=added_image_names) + except Exception as e: + raise HTTPException(status_code=500, detail="Failed to add images to board") + + +@board_images_router.post( + "/batch/delete", + operation_id="remove_images_from_board", + responses={ + 201: {"description": "Images were removed from board successfully"}, + }, + status_code=201, + response_model=RemoveImagesFromBoardResult, +) +async def remove_images_from_board( + image_names: list[str] = Body(description="The names of the images to remove", embed=True), +) -> RemoveImagesFromBoardResult: + """Removes a list of images from their board, if they had one""" + try: + removed_image_names: list[str] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) + removed_image_names.append(image_name) + except: + pass + return RemoveImagesFromBoardResult(removed_image_names=removed_image_names) + except Exception as e: + raise HTTPException(status_code=500, detail="Failed to remove images from board") diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 498a1139e4..aff409e9e5 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -5,6 +5,7 @@ from fastapi import Body, HTTPException, Path, Query, Request, Response, UploadF from fastapi.responses import FileResponse from fastapi.routing import APIRouter from PIL import Image +from pydantic import BaseModel, Field from invokeai.app.invocations.metadata import ImageMetadata from invokeai.app.models.image import ImageCategory, ResourceOrigin @@ -25,7 +26,7 @@ IMAGE_MAX_AGE = 31536000 @images_router.post( - "/", + "/upload", operation_id="upload_image", responses={ 201: {"description": "The image was uploaded successfully"}, @@ -77,7 +78,7 @@ async def upload_image( raise HTTPException(status_code=500, detail="Failed to create image") -@images_router.delete("/{image_name}", operation_id="delete_image") +@images_router.delete("/i/{image_name}", operation_id="delete_image") async def delete_image( image_name: str = Path(description="The name of the image to delete"), ) -> None: @@ -103,7 +104,7 @@ async def clear_intermediates() -> int: @images_router.patch( - "/{image_name}", + "/i/{image_name}", operation_id="update_image", response_model=ImageDTO, ) @@ -120,7 +121,7 @@ async def update_image( @images_router.get( - "/{image_name}", + "/i/{image_name}", operation_id="get_image_dto", response_model=ImageDTO, ) @@ -136,7 +137,7 @@ async def get_image_dto( @images_router.get( - "/{image_name}/metadata", + "/i/{image_name}/metadata", operation_id="get_image_metadata", response_model=ImageMetadata, ) @@ -152,7 +153,7 @@ async def get_image_metadata( @images_router.get( - "/{image_name}/full", + "/i/{image_name}/full", operation_id="get_image_full", response_class=Response, responses={ @@ -187,7 +188,7 @@ async def get_image_full( @images_router.get( - "/{image_name}/thumbnail", + "/i/{image_name}/thumbnail", operation_id="get_image_thumbnail", response_class=Response, responses={ @@ -216,7 +217,7 @@ async def get_image_thumbnail( @images_router.get( - "/{image_name}/urls", + "/i/{image_name}/urls", operation_id="get_image_urls", response_model=ImageUrlsDTO, ) @@ -265,3 +266,24 @@ 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) +async def delete_images_from_list( + image_names: list[str] = Body(description="The list of names of images to delete", embed=True), +) -> DeleteImagesFromListResult: + try: + deleted_images: list[str] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.images.delete(image_name) + deleted_images.append(image_name) + except: + pass + return DeleteImagesFromListResult(deleted_images=deleted_images) + except Exception as e: + raise HTTPException(status_code=500, detail="Failed to delete images") diff --git a/invokeai/app/invocations/metadata.py b/invokeai/app/invocations/metadata.py index 3588ef4ebe..f91e6cc4c7 100644 --- a/invokeai/app/invocations/metadata.py +++ b/invokeai/app/invocations/metadata.py @@ -1,6 +1,6 @@ from typing import Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import Field from invokeai.app.invocations.baseinvocation import ( BaseInvocation, @@ -10,16 +10,17 @@ from invokeai.app.invocations.baseinvocation import ( ) from invokeai.app.invocations.controlnet_image_processors import ControlField from invokeai.app.invocations.model import LoRAModelField, MainModelField, VAEModelField +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull -class LoRAMetadataField(BaseModel): +class LoRAMetadataField(BaseModelExcludeNull): """LoRA metadata for an image generated in InvokeAI.""" lora: LoRAModelField = Field(description="The LoRA model") weight: float = Field(description="The weight of the LoRA model") -class CoreMetadata(BaseModel): +class CoreMetadata(BaseModelExcludeNull): """Core generation metadata for an image generated in InvokeAI.""" generation_mode: str = Field( @@ -70,7 +71,7 @@ class CoreMetadata(BaseModel): refiner_start: Union[float, None] = Field(default=None, description="The start value used for refiner denoising") -class ImageMetadata(BaseModel): +class ImageMetadata(BaseModelExcludeNull): """An image's generation metadata""" metadata: Optional[dict] = Field( diff --git a/invokeai/app/services/board_image_record_storage.py b/invokeai/app/services/board_image_record_storage.py index f0007c8cef..03badf9866 100644 --- a/invokeai/app/services/board_image_record_storage.py +++ b/invokeai/app/services/board_image_record_storage.py @@ -25,7 +25,6 @@ class BoardImageRecordStorageBase(ABC): @abstractmethod def remove_image_from_board( self, - board_id: str, image_name: str, ) -> None: """Removes an image from a board.""" @@ -154,7 +153,6 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase): def remove_image_from_board( self, - board_id: str, image_name: str, ) -> None: try: @@ -162,9 +160,9 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase): self._cursor.execute( """--sql DELETE FROM board_images - WHERE board_id = ? AND image_name = ?; + WHERE image_name = ?; """, - (board_id, image_name), + (image_name,), ) self._conn.commit() except sqlite3.Error as e: diff --git a/invokeai/app/services/board_images.py b/invokeai/app/services/board_images.py index 22332d6c29..f41526bfa7 100644 --- a/invokeai/app/services/board_images.py +++ b/invokeai/app/services/board_images.py @@ -31,7 +31,6 @@ class BoardImagesServiceABC(ABC): @abstractmethod def remove_image_from_board( self, - board_id: str, image_name: str, ) -> None: """Removes an image from a board.""" @@ -93,10 +92,9 @@ class BoardImagesService(BoardImagesServiceABC): def remove_image_from_board( self, - board_id: str, image_name: str, ) -> None: - self._services.board_image_records.remove_image_from_board(board_id, image_name) + self._services.board_image_records.remove_image_from_board(image_name) def get_all_board_image_names_for_board( self, diff --git a/invokeai/app/services/images.py b/invokeai/app/services/images.py index f8376eb626..2240846dac 100644 --- a/invokeai/app/services/images.py +++ b/invokeai/app/services/images.py @@ -289,9 +289,10 @@ class ImageService(ImageServiceABC): def get_metadata(self, image_name: str) -> Optional[ImageMetadata]: try: image_record = self._services.image_records.get(image_name) + metadata = self._services.image_records.get_metadata(image_name) if not image_record.session_id: - return ImageMetadata() + return ImageMetadata(metadata=metadata) session_raw = self._services.graph_execution_manager.get_raw(image_record.session_id) graph = None @@ -303,7 +304,6 @@ class ImageService(ImageServiceABC): self._services.logger.warn(f"Failed to parse session graph: {e}") graph = None - metadata = self._services.image_records.get_metadata(image_name) return ImageMetadata(graph=graph, metadata=metadata) except ImageRecordNotFoundException: self._services.logger.error("Image record not found") diff --git a/invokeai/app/services/models/board_image.py b/invokeai/app/services/models/board_image.py new file mode 100644 index 0000000000..fe585215f3 --- /dev/null +++ b/invokeai/app/services/models/board_image.py @@ -0,0 +1,8 @@ +from pydantic import Field + +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull + + +class BoardImage(BaseModelExcludeNull): + board_id: str = Field(description="The id of the board") + image_name: str = Field(description="The name of the image") diff --git a/invokeai/app/services/models/board_record.py b/invokeai/app/services/models/board_record.py index 658698e794..53fa299faf 100644 --- a/invokeai/app/services/models/board_record.py +++ b/invokeai/app/services/models/board_record.py @@ -1,10 +1,11 @@ from typing import Optional, Union from datetime import datetime -from pydantic import BaseModel, Extra, Field, StrictBool, StrictStr +from pydantic import Field from invokeai.app.util.misc import get_iso_timestamp +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull -class BoardRecord(BaseModel): +class BoardRecord(BaseModelExcludeNull): """Deserialized board record.""" board_id: str = Field(description="The unique ID of the board.") diff --git a/invokeai/app/services/models/image_record.py b/invokeai/app/services/models/image_record.py index a105d03ba8..294b760630 100644 --- a/invokeai/app/services/models/image_record.py +++ b/invokeai/app/services/models/image_record.py @@ -1,13 +1,14 @@ import datetime from typing import Optional, Union -from pydantic import BaseModel, Extra, Field, StrictBool, StrictStr +from pydantic import Extra, Field, StrictBool, StrictStr from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.util.misc import get_iso_timestamp +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull -class ImageRecord(BaseModel): +class ImageRecord(BaseModelExcludeNull): """Deserialized image record without metadata.""" image_name: str = Field(description="The unique name of the image.") @@ -40,7 +41,7 @@ class ImageRecord(BaseModel): """The node ID that generated this image, if it is a generated image.""" -class ImageRecordChanges(BaseModel, extra=Extra.forbid): +class ImageRecordChanges(BaseModelExcludeNull, extra=Extra.forbid): """A set of changes to apply to an image record. Only limited changes are valid: @@ -60,7 +61,7 @@ class ImageRecordChanges(BaseModel, extra=Extra.forbid): """The image's new `is_intermediate` flag.""" -class ImageUrlsDTO(BaseModel): +class ImageUrlsDTO(BaseModelExcludeNull): """The URLs for an image and its thumbnail.""" image_name: str = Field(description="The unique name of the image.") @@ -76,11 +77,15 @@ class ImageDTO(ImageRecord, ImageUrlsDTO): board_id: Optional[str] = Field(description="The id of the board the image belongs to, if one exists.") """The id of the board the image belongs to, if one exists.""" + pass def image_record_to_dto( - image_record: ImageRecord, image_url: str, thumbnail_url: str, board_id: Optional[str] + image_record: ImageRecord, + image_url: str, + thumbnail_url: str, + board_id: Optional[str], ) -> ImageDTO: """Converts an image record to an image DTO.""" return ImageDTO( diff --git a/invokeai/app/services/urls.py b/invokeai/app/services/urls.py index 73d8ddadf4..7688b3bdd3 100644 --- a/invokeai/app/services/urls.py +++ b/invokeai/app/services/urls.py @@ -20,6 +20,6 @@ class LocalUrlService(UrlServiceBase): # These paths are determined by the routes in invokeai/app/api/routers/images.py if thumbnail: - return f"{self._base_url}/images/{image_basename}/thumbnail" + return f"{self._base_url}/images/i/{image_basename}/thumbnail" - return f"{self._base_url}/images/{image_basename}/full" + return f"{self._base_url}/images/i/{image_basename}/full" diff --git a/invokeai/app/util/model_exclude_null.py b/invokeai/app/util/model_exclude_null.py new file mode 100644 index 0000000000..d864b8fab8 --- /dev/null +++ b/invokeai/app/util/model_exclude_null.py @@ -0,0 +1,23 @@ +from typing import Any +from pydantic import BaseModel + + +""" +We want to exclude null values from objects that make their way to the client. + +Unfortunately there is no built-in way to do this in pydantic, so we need to override the default +dict method to do this. + +From https://github.com/tiangolo/fastapi/discussions/8882#discussioncomment-5154541 +""" + + +class BaseModelExcludeNull(BaseModel): + def dict(self, *args, **kwargs) -> dict[str, Any]: + """ + Override the default dict method to exclude None values in the response + """ + kwargs.pop("exclude_none", None) + return super().dict(*args, exclude_none=True, **kwargs) + + pass diff --git a/invokeai/backend/model_management/models/base.py b/invokeai/backend/model_management/models/base.py index e6a20e79ec..d335b645c8 100644 --- a/invokeai/backend/model_management/models/base.py +++ b/invokeai/backend/model_management/models/base.py @@ -292,8 +292,9 @@ class DiffusersModel(ModelBase): ) break except Exception as e: - # print("====ERR LOAD====") - # print(f"{variant}: {e}") + if not str(e).startswith("Error no file"): + print("====ERR LOAD====") + print(f"{variant}: {e}") pass else: raise Exception(f"Failed to load {self.base_model}:{self.model_type}:{child_type} model") diff --git a/invokeai/backend/model_management/models/stable_diffusion.py b/invokeai/backend/model_management/models/stable_diffusion.py index d81b0150e5..a112e8bc96 100644 --- a/invokeai/backend/model_management/models/stable_diffusion.py +++ b/invokeai/backend/model_management/models/stable_diffusion.py @@ -4,6 +4,7 @@ from enum import Enum from pydantic import Field from pathlib import Path from typing import Literal, Optional, Union +from diffusers import StableDiffusionInpaintPipeline, StableDiffusionPipeline from .base import ( ModelConfigBase, BaseModelType, @@ -263,6 +264,8 @@ def _convert_ckpt_and_cache( weights = app_config.models_path / model_config.path config_file = app_config.root_path / model_config.config output_path = Path(output_path) + variant = model_config.variant + pipeline_class = StableDiffusionInpaintPipeline if variant == "inpaint" else StableDiffusionPipeline # return cached version if it exists if output_path.exists(): @@ -289,6 +292,7 @@ def _convert_ckpt_and_cache( original_config_file=config_file, extract_ema=True, scan_needed=True, + pipeline_class=pipeline_class, from_safetensors=weights.suffix == ".safetensors", precision=torch_dtype(choose_torch_device()), **kwargs, diff --git a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py index 272518e928..c01cf82c57 100644 --- a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py +++ b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py @@ -78,10 +78,9 @@ class InvokeAIDiffuserComponent: self.cross_attention_control_context = None self.sequential_guidance = config.sequential_guidance - @classmethod @contextmanager def custom_attention_context( - cls, + self, unet: UNet2DConditionModel, # note: also may futz with the text encoder depending on requested LoRAs extra_conditioning_info: Optional[ExtraConditioningInfo], step_count: int, @@ -91,18 +90,19 @@ class InvokeAIDiffuserComponent: old_attn_processors = unet.attn_processors # Load lora conditions into the model if extra_conditioning_info.wants_cross_attention_control: - cross_attention_control_context = Context( + self.cross_attention_control_context = Context( arguments=extra_conditioning_info.cross_attention_control_args, step_count=step_count, ) setup_cross_attention_control_attention_processors( unet, - cross_attention_control_context, + self.cross_attention_control_context, ) try: yield None finally: + self.cross_attention_control_context = None if old_attn_processors is not None: unet.set_attn_processor(old_attn_processors) # TODO resuscitate attention map saving diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index a76c2ecc02..8cc2c158be 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -23,7 +23,7 @@ "dev": "concurrently \"vite dev\" \"yarn run theme:watch\"", "dev:host": "concurrently \"vite dev --host\" \"yarn run theme:watch\"", "build": "yarn run lint && vite build", - "typegen": "npx ts-node scripts/typegen.ts", + "typegen": "node scripts/typegen.js", "preview": "vite preview", "lint:madge": "madge --circular src/main.tsx", "lint:eslint": "eslint --max-warnings=0 .", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index cf84e4d773..63380a19fa 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -124,7 +124,8 @@ "deleteImageBin": "Deleted images will be sent to your operating system's Bin.", "deleteImagePermanent": "Deleted images cannot be restored.", "images": "Images", - "assets": "Assets" + "assets": "Assets", + "autoAssignBoardOnClick": "Auto-Assign Board on Click" }, "hotkeys": { "keyboardShortcuts": "Keyboard Shortcuts", diff --git a/invokeai/frontend/web/scripts/typegen.ts b/invokeai/frontend/web/scripts/typegen.js similarity index 100% rename from invokeai/frontend/web/scripts/typegen.ts rename to invokeai/frontend/web/scripts/typegen.js diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 963d285f72..fa45ae93cd 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -4,8 +4,9 @@ import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/ap import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { PartialAppConfig } from 'app/types/invokeai'; import ImageUploader from 'common/components/ImageUploader'; +import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal'; +import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal'; import GalleryDrawer from 'features/gallery/components/GalleryPanel'; -import DeleteImageModal from 'features/imageDeletion/components/DeleteImageModal'; import SiteHeader from 'features/system/components/SiteHeader'; import { configChanged } from 'features/system/store/configSlice'; import { languageSelector } from 'features/system/store/systemSelectors'; @@ -16,7 +17,6 @@ import ParametersDrawer from 'features/ui/components/ParametersDrawer'; import i18n from 'i18n'; import { size } from 'lodash-es'; import { ReactNode, memo, useEffect } from 'react'; -import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import GlobalHotkeys from './GlobalHotkeys'; import Toaster from './Toaster'; @@ -84,7 +84,7 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => { - + diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx index 82526900ad..c97778ffcd 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/DragPreview.tsx @@ -58,7 +58,7 @@ const DragPreview = (props: OverlayDragImageProps) => { ); } - if (props.dragData.payloadType === 'IMAGE_NAMES') { + if (props.dragData.payloadType === 'IMAGE_DTOS') { return ( { ...STYLES, }} > - {props.dragData.payload.image_names.length} + {props.dragData.payload.imageDTOs.length} Images ); diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx index 24bdceac3a..56eeb9b5db 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/ImageDndContext.tsx @@ -18,27 +18,32 @@ import { DragStartEvent, TypesafeDraggableData, } from './typesafeDnd'; +import { logger } from 'app/logging/logger'; type ImageDndContextProps = PropsWithChildren; const ImageDndContext = (props: ImageDndContextProps) => { const [activeDragData, setActiveDragData] = useState(null); + const log = logger('images'); const dispatch = useAppDispatch(); - const handleDragStart = useCallback((event: DragStartEvent) => { - console.log('dragStart', event.active.data.current); - const activeData = event.active.data.current; - if (!activeData) { - return; - } - setActiveDragData(activeData); - }, []); + const handleDragStart = useCallback( + (event: DragStartEvent) => { + log.trace({ dragData: event.active.data.current }, 'Drag started'); + const activeData = event.active.data.current; + if (!activeData) { + return; + } + setActiveDragData(activeData); + }, + [log] + ); const handleDragEnd = useCallback( (event: DragEndEvent) => { - console.log('dragEnd', event.active.data.current); + log.trace({ dragData: event.active.data.current }, 'Drag ended'); const overData = event.over?.data.current; if (!activeDragData || !overData) { return; @@ -46,7 +51,7 @@ const ImageDndContext = (props: ImageDndContextProps) => { dispatch(dndDropped({ overData, activeData: activeDragData })); setActiveDragData(null); }, - [activeDragData, dispatch] + [activeDragData, dispatch, log] ); const mouseSensor = useSensor(MouseSensor, { diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx index 5f08466710..6f24302070 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx @@ -11,7 +11,6 @@ import { useDraggable as useOriginalDraggable, useDroppable as useOriginalDroppable, } from '@dnd-kit/core'; -import { BoardId } from 'features/gallery/store/types'; import { ImageDTO } from 'services/api/types'; type BaseDropData = { @@ -54,9 +53,13 @@ export type AddToBatchDropData = BaseDropData & { actionType: 'ADD_TO_BATCH'; }; -export type MoveBoardDropData = BaseDropData & { - actionType: 'MOVE_BOARD'; - context: { boardId: BoardId }; +export type AddToBoardDropData = BaseDropData & { + actionType: 'ADD_TO_BOARD'; + context: { boardId: string }; +}; + +export type RemoveFromBoardDropData = BaseDropData & { + actionType: 'REMOVE_FROM_BOARD'; }; export type TypesafeDroppableData = @@ -67,7 +70,8 @@ export type TypesafeDroppableData = | NodesImageDropData | AddToBatchDropData | NodesMultiImageDropData - | MoveBoardDropData; + | AddToBoardDropData + | RemoveFromBoardDropData; type BaseDragData = { id: string; @@ -78,14 +82,12 @@ export type ImageDraggableData = BaseDragData & { payload: { imageDTO: ImageDTO }; }; -export type ImageNamesDraggableData = BaseDragData & { - payloadType: 'IMAGE_NAMES'; - payload: { image_names: string[] }; +export type ImageDTOsDraggableData = BaseDragData & { + payloadType: 'IMAGE_DTOS'; + payload: { imageDTOs: ImageDTO[] }; }; -export type TypesafeDraggableData = - | ImageDraggableData - | ImageNamesDraggableData; +export type TypesafeDraggableData = ImageDraggableData | ImageDTOsDraggableData; interface UseDroppableTypesafeArguments extends Omit { @@ -156,14 +158,39 @@ export const isValidDrop = ( case 'SET_NODES_IMAGE': return payloadType === 'IMAGE_DTO'; case 'SET_MULTI_NODES_IMAGE': - return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; + return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS'; case 'ADD_TO_BATCH': - return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; - case 'MOVE_BOARD': { + return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS'; + case 'ADD_TO_BOARD': { // If the board is the same, don't allow the drop // Check the payload types - const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; + const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS'; + if (!isPayloadValid) { + return false; + } + + // Check if the image's board is the board we are dragging onto + if (payloadType === 'IMAGE_DTO') { + const { imageDTO } = active.data.current.payload; + const currentBoard = imageDTO.board_id ?? 'none'; + const destinationBoard = overData.context.boardId; + + return currentBoard !== destinationBoard; + } + + if (payloadType === 'IMAGE_DTOS') { + // TODO (multi-select) + return true; + } + + return false; + } + case 'REMOVE_FROM_BOARD': { + // If the board is the same, don't allow the drop + + // Check the payload types + const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS'; if (!isPayloadValid) { return false; } @@ -172,20 +199,16 @@ export const isValidDrop = ( if (payloadType === 'IMAGE_DTO') { const { imageDTO } = active.data.current.payload; const currentBoard = imageDTO.board_id; - const destinationBoard = overData.context.boardId; - const isSameBoard = currentBoard === destinationBoard; - const isDestinationValid = !currentBoard ? destinationBoard : true; - - return !isSameBoard && isDestinationValid; + return currentBoard !== 'none'; } - if (payloadType === 'IMAGE_NAMES') { + if (payloadType === 'IMAGE_DTOS') { // TODO (multi-select) - return false; + return true; } - return true; + return false; } default: return false; diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 7df390bce6..93b7825db7 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -1,4 +1,6 @@ +import { Middleware } from '@reduxjs/toolkit'; import { store } from 'app/store/store'; +import { PartialAppConfig } from 'app/types/invokeai'; import React, { lazy, memo, @@ -7,16 +9,11 @@ import React, { useEffect, } from 'react'; import { Provider } from 'react-redux'; - -import { PartialAppConfig } from 'app/types/invokeai'; import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares'; -import Loading from '../../common/components/Loading/Loading'; - -import { Middleware } from '@reduxjs/toolkit'; -import { $authToken, $baseUrl } from 'services/api/client'; +import { $authToken, $baseUrl, $projectId } from 'services/api/client'; import { socketMiddleware } from 'services/events/middleware'; +import Loading from '../../common/components/Loading/Loading'; import '../../i18n'; -import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext'; import ImageDndContext from './ImageDnd/ImageDndContext'; const App = lazy(() => import('./App')); @@ -37,6 +34,7 @@ const InvokeAIUI = ({ config, headerComponent, middleware, + projectId, }: Props) => { useEffect(() => { // configure API client token @@ -49,6 +47,11 @@ const InvokeAIUI = ({ $baseUrl.set(apiUrl); } + // configure API client project header + if (projectId) { + $projectId.set(projectId); + } + // reset dynamically added middlewares resetMiddlewares(); @@ -68,8 +71,9 @@ const InvokeAIUI = ({ // Reset the API client token and base url on unmount $baseUrl.set(undefined); $authToken.set(undefined); + $projectId.set(undefined); }; - }, [apiUrl, token, middleware]); + }, [apiUrl, token, middleware, projectId]); return ( @@ -77,9 +81,7 @@ const InvokeAIUI = ({ }> - - - + diff --git a/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx b/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx deleted file mode 100644 index d5b3b746f1..0000000000 --- a/invokeai/frontend/web/src/app/contexts/AddImageToBoardContext.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useDisclosure } from '@chakra-ui/react'; -import { PropsWithChildren, createContext, useCallback, useState } from 'react'; -import { ImageDTO } from 'services/api/types'; -import { imagesApi } from 'services/api/endpoints/images'; -import { useAppDispatch } from '../store/storeHooks'; - -export type ImageUsage = { - isInitialImage: boolean; - isCanvasImage: boolean; - isNodesImage: boolean; - isControlNetImage: boolean; -}; - -type AddImageToBoardContextValue = { - /** - * Whether the move image dialog is open. - */ - isOpen: boolean; - /** - * Closes the move image dialog. - */ - onClose: () => void; - /** - * The image pending movement - */ - image?: ImageDTO; - onClickAddToBoard: (image: ImageDTO) => void; - handleAddToBoard: (boardId: string) => void; -}; - -export const AddImageToBoardContext = - createContext({ - isOpen: false, - onClose: () => undefined, - onClickAddToBoard: () => undefined, - handleAddToBoard: () => undefined, - }); - -type Props = PropsWithChildren; - -export const AddImageToBoardContextProvider = (props: Props) => { - const [imageToMove, setImageToMove] = useState(); - const { isOpen, onOpen, onClose } = useDisclosure(); - const dispatch = useAppDispatch(); - - // Clean up after deleting or dismissing the modal - const closeAndClearImageToDelete = useCallback(() => { - setImageToMove(undefined); - onClose(); - }, [onClose]); - - const onClickAddToBoard = useCallback( - (image?: ImageDTO) => { - if (!image) { - return; - } - setImageToMove(image); - onOpen(); - }, - [setImageToMove, onOpen] - ); - - const handleAddToBoard = useCallback( - (boardId: string) => { - if (imageToMove) { - dispatch( - imagesApi.endpoints.addImageToBoard.initiate({ - imageDTO: imageToMove, - board_id: boardId, - }) - ); - closeAndClearImageToDelete(); - } - }, - [dispatch, closeAndClearImageToDelete, imageToMove] - ); - - return ( - - {props.children} - - ); -}; diff --git a/invokeai/frontend/web/src/app/contexts/ImageUploaderTriggerContext.ts b/invokeai/frontend/web/src/app/contexts/ImageUploaderTriggerContext.ts deleted file mode 100644 index 804e124625..0000000000 --- a/invokeai/frontend/web/src/app/contexts/ImageUploaderTriggerContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from 'react'; - -type VoidFunc = () => void; - -type ImageUploaderTriggerContextType = VoidFunc | null; - -export const ImageUploaderTriggerContext = - createContext(null); diff --git a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts index 3407b3f7de..1b21770aa0 100644 --- a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts +++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/serialize.ts @@ -23,6 +23,6 @@ const serializationDenylist: { }; export const serialize: SerializeFunction = (data, key) => { - const result = omit(data, serializationDenylist[key]); + const result = omit(data, serializationDenylist[key] ?? []); return JSON.stringify(result); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index f06c324bc6..c15b072a07 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -27,7 +27,8 @@ import { addImageDeletedFulfilledListener, addImageDeletedPendingListener, addImageDeletedRejectedListener, - addRequestedImageDeletionListener, + addRequestedSingleImageDeletionListener, + addRequestedMultipleImageDeletionListener, } from './listeners/imageDeleted'; import { addImageDroppedListener } from './listeners/imageDropped'; import { @@ -111,7 +112,8 @@ addImageUploadedRejectedListener(); addInitialImageSelectedListener(); // Image deleted -addRequestedImageDeletionListener(); +addRequestedSingleImageDeletionListener(); +addRequestedMultipleImageDeletionListener(); addImageDeletedPendingListener(); addImageDeletedFulfilledListener(); addImageDeletedRejectedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts index ee12f39a12..15e7d48708 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts.ts @@ -1,12 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; import { imageSelected } from 'features/gallery/store/gallerySlice'; import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import { - ImageCache, - getListImagesUrl, - imagesApi, -} from 'services/api/endpoints/images'; +import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; +import { getListImagesUrl, imagesAdapter } from 'services/api/util'; +import { ImageCache } from 'services/api/types'; export const appStarted = createAction('app/appStarted'); @@ -34,7 +32,8 @@ export const addFirstListImagesListener = () => { if (data.ids.length > 0) { // Select the first image - dispatch(imageSelected(data.ids[0] as string)); + const firstImage = imagesAdapter.getSelectors().selectAll(data)[0]; + dispatch(imageSelected(firstImage ?? null)); } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts index 2d0ece3595..700b4e7626 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts @@ -18,7 +18,9 @@ export const addAppConfigReceivedListener = () => { const infillMethod = getState().generation.infillMethod; if (!infill_methods.includes(infillMethod)) { - dispatch(setInfillMethod(infill_methods[0])); + // if there is no infill method, set it to the first one + // if there is no first one... god help us + dispatch(setInfillMethod(infill_methods[0] as string)); } if (!nsfw_methods.includes('nsfw_checker')) { diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts index f0af52ced6..d4a36d64dc 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts @@ -1,14 +1,14 @@ import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; -import { getImageUsage } from 'features/imageDeletion/store/imageDeletionSelectors'; +import { getImageUsage } from 'features/deleteImageModal/store/selectors'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice'; +import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; -import { boardsApi } from '../../../../../services/api/endpoints/boards'; export const addDeleteBoardAndImagesFulfilledListener = () => { startAppListening({ - matcher: boardsApi.endpoints.deleteBoardAndImages.matchFulfilled, + matcher: imagesApi.endpoints.deleteBoardAndImages.matchFulfilled, effect: async (action, { dispatch, getState }) => { const { deleted_images } = action.payload; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index f9c856d6cb..1b13181911 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -10,6 +10,7 @@ import { } from 'features/gallery/store/types'; import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; +import { imagesSelectors } from 'services/api/util'; export const addBoardIdSelectedListener = () => { startAppListening({ @@ -52,8 +53,9 @@ export const addBoardIdSelectedListener = () => { queryArgs )(getState()); - if (boardImagesData?.ids.length) { - dispatch(imageSelected((boardImagesData.ids[0] as string) ?? null)); + if (boardImagesData) { + const firstImage = imagesSelectors.selectAll(boardImagesData)[0]; + dispatch(imageSelected(firstImage ?? null)); } else { // board has no images - deselect dispatch(imageSelected(null)); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts index 47f7aded27..dbadb72a52 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts @@ -26,6 +26,8 @@ export const addCanvasSavedToGalleryListener = () => { return; } + const { autoAddBoardId } = state.gallery; + dispatch( imagesApi.endpoints.uploadImage.initiate({ file: new File([blob], 'savedCanvas.png', { @@ -33,7 +35,7 @@ export const addCanvasSavedToGalleryListener = () => { }), image_category: 'general', is_intermediate: false, - board_id: state.gallery.autoAddBoardId, + board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId, crop_visible: true, postUploadAction: { type: 'TOAST', diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts index 4a47e8d64e..61bcf28833 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetAutoProcess.ts @@ -31,15 +31,20 @@ const predicate: AnyListenerPredicate = ( // do not process if the user just disabled auto-config if ( prevState.controlNet.controlNets[action.payload.controlNetId] - .shouldAutoConfig === true + ?.shouldAutoConfig === true ) { return false; } } - const { controlImage, processorType, shouldAutoConfig } = - state.controlNet.controlNets[action.payload.controlNetId]; + const cn = state.controlNet.controlNets[action.payload.controlNetId]; + if (!cn) { + // something is wrong, the controlNet should exist + return false; + } + + const { controlImage, processorType, shouldAutoConfig } = cn; if (controlNetModelChanged.match(action) && !shouldAutoConfig) { // do not process if the action is a model change but the processor settings are dirty return false; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts index 313b2a02d8..fa915ef21b 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/controlNetImageProcessed.ts @@ -17,7 +17,7 @@ export const addControlNetImageProcessedListener = () => { const { controlNetId } = action.payload; const controlNet = getState().controlNet.controlNets[controlNetId]; - if (!controlNet.controlImage) { + if (!controlNet?.controlImage) { log.error('Unable to process ControlNet image'); return; } diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts index 428ce53219..cdfae0095e 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts @@ -1,57 +1,72 @@ import { logger } from 'app/logging/logger'; import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; +import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; +import { isModalOpenChanged } from 'features/deleteImageModal/store/slice'; import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { imageDeletionConfirmed } from 'features/imageDeletion/store/actions'; -import { isModalOpenChanged } from 'features/imageDeletion/store/imageDeletionSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { clamp } from 'lodash-es'; import { api } from 'services/api'; import { imagesApi } from 'services/api/endpoints/images'; +import { imagesAdapter } from 'services/api/util'; import { startAppListening } from '..'; -/** - * Called when the user requests an image deletion - */ -export const addRequestedImageDeletionListener = () => { +export const addRequestedSingleImageDeletionListener = () => { startAppListening({ actionCreator: imageDeletionConfirmed, effect: async (action, { dispatch, getState, condition }) => { - const { imageDTO, imageUsage } = action.payload; + const { imageDTOs, imagesUsage } = action.payload; + + if (imageDTOs.length !== 1 || imagesUsage.length !== 1) { + // handle multiples in separate listener + return; + } + + const imageDTO = imageDTOs[0]; + const imageUsage = imagesUsage[0]; + + if (!imageDTO || !imageUsage) { + // satisfy noUncheckedIndexedAccess + return; + } dispatch(isModalOpenChanged(false)); - const { image_name } = imageDTO; - const state = getState(); const lastSelectedImage = - state.gallery.selection[state.gallery.selection.length - 1]; + state.gallery.selection[state.gallery.selection.length - 1]?.image_name; + + if (imageDTO && imageDTO?.image_name === lastSelectedImage) { + const { image_name } = imageDTO; - if (lastSelectedImage === image_name) { const baseQueryArgs = selectListImagesBaseQueryArgs(state); const { data } = imagesApi.endpoints.listImages.select(baseQueryArgs)(state); - const ids = data?.ids ?? []; + const cachedImageDTOs = data + ? imagesAdapter.getSelectors().selectAll(data) + : []; - const deletedImageIndex = ids.findIndex( - (result) => result.toString() === image_name + const deletedImageIndex = cachedImageDTOs.findIndex( + (i) => i.image_name === image_name ); - const filteredIds = ids.filter((id) => id.toString() !== image_name); + const filteredImageDTOs = cachedImageDTOs.filter( + (i) => i.image_name !== image_name + ); const newSelectedImageIndex = clamp( deletedImageIndex, 0, - filteredIds.length - 1 + filteredImageDTOs.length - 1 ); - const newSelectedImageId = filteredIds[newSelectedImageIndex]; + const newSelectedImageDTO = filteredImageDTOs[newSelectedImageIndex]; - if (newSelectedImageId) { - dispatch(imageSelected(newSelectedImageId as string)); + if (newSelectedImageDTO) { + dispatch(imageSelected(newSelectedImageDTO)); } else { dispatch(imageSelected(null)); } @@ -97,6 +112,66 @@ export const addRequestedImageDeletionListener = () => { }); }; +/** + * Called when the user requests an image deletion + */ +export const addRequestedMultipleImageDeletionListener = () => { + startAppListening({ + actionCreator: imageDeletionConfirmed, + effect: async (action, { dispatch, getState }) => { + const { imageDTOs, imagesUsage } = action.payload; + + if (imageDTOs.length < 1 || imagesUsage.length < 1) { + // handle singles in separate listener + return; + } + + try { + // Delete from server + await dispatch( + imagesApi.endpoints.deleteImages.initiate({ imageDTOs }) + ).unwrap(); + const state = getState(); + const baseQueryArgs = selectListImagesBaseQueryArgs(state); + const { data } = + imagesApi.endpoints.listImages.select(baseQueryArgs)(state); + + const newSelectedImageDTO = data + ? imagesAdapter.getSelectors().selectAll(data)[0] + : undefined; + + if (newSelectedImageDTO) { + dispatch(imageSelected(newSelectedImageDTO)); + } else { + dispatch(imageSelected(null)); + } + + dispatch(isModalOpenChanged(false)); + + // We need to reset the features where the image is in use - none of these work if their image(s) don't exist + + if (imagesUsage.some((i) => i.isCanvasImage)) { + dispatch(resetCanvas()); + } + + if (imagesUsage.some((i) => i.isControlNetImage)) { + dispatch(controlNetReset()); + } + + if (imagesUsage.some((i) => i.isInitialImage)) { + dispatch(clearInitialImage()); + } + + if (imagesUsage.some((i) => i.isNodesImage)) { + dispatch(nodeEditorReset()); + } + } catch { + // no-op + } + }, + }); +}; + /** * Called when the actual delete request is sent to the server */ diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts index fdf0849a12..043105cb66 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts @@ -6,10 +6,7 @@ import { import { logger } from 'app/logging/logger'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; -import { - imageSelected, - imagesAddedToBatch, -} from 'features/gallery/store/gallerySlice'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { imagesApi } from 'services/api/endpoints/images'; @@ -27,19 +24,32 @@ export const addImageDroppedListener = () => { const log = logger('images'); const { activeData, overData } = action.payload; - log.debug({ activeData, overData }, 'Image or selection dropped'); + if (activeData.payloadType === 'IMAGE_DTO') { + log.debug({ activeData, overData }, 'Image dropped'); + } else if (activeData.payloadType === 'IMAGE_DTOS') { + log.debug( + { activeData, overData }, + `Images (${activeData.payload.imageDTOs.length}) dropped` + ); + } else { + log.debug({ activeData, overData }, `Unknown payload dropped`); + } - // set current image + /** + * Image dropped on current image + */ if ( overData.actionType === 'SET_CURRENT_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { - dispatch(imageSelected(activeData.payload.imageDTO.image_name)); + dispatch(imageSelected(activeData.payload.imageDTO)); return; } - // set initial image + /** + * Image dropped on initial image + */ if ( overData.actionType === 'SET_INITIAL_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && @@ -49,27 +59,9 @@ export const addImageDroppedListener = () => { return; } - // add image to batch - if ( - overData.actionType === 'ADD_TO_BATCH' && - activeData.payloadType === 'IMAGE_DTO' && - activeData.payload.imageDTO - ) { - dispatch(imagesAddedToBatch([activeData.payload.imageDTO.image_name])); - return; - } - - // add multiple images to batch - if ( - overData.actionType === 'ADD_TO_BATCH' && - activeData.payloadType === 'IMAGE_NAMES' - ) { - dispatch(imagesAddedToBatch(activeData.payload.image_names)); - - return; - } - - // set control image + /** + * Image dropped on ControlNet + */ if ( overData.actionType === 'SET_CONTROLNET_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && @@ -85,7 +77,9 @@ export const addImageDroppedListener = () => { return; } - // set canvas image + /** + * Image dropped on Canvas + */ if ( overData.actionType === 'SET_CANVAS_INITIAL_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && @@ -95,7 +89,9 @@ export const addImageDroppedListener = () => { return; } - // set nodes image + /** + * Image dropped on node image field + */ if ( overData.actionType === 'SET_NODES_IMAGE' && activeData.payloadType === 'IMAGE_DTO' && @@ -112,61 +108,36 @@ export const addImageDroppedListener = () => { return; } - // set multiple nodes images (single image handler) - if ( - overData.actionType === 'SET_MULTI_NODES_IMAGE' && - activeData.payloadType === 'IMAGE_DTO' && - activeData.payload.imageDTO - ) { - const { fieldName, nodeId } = overData.context; - dispatch( - fieldValueChanged({ - nodeId, - fieldName, - value: [activeData.payload.imageDTO], - }) - ); - return; - } - - // // set multiple nodes images (multiple images handler) + /** + * TODO + * Image selection dropped on node image collection field + */ // if ( // overData.actionType === 'SET_MULTI_NODES_IMAGE' && - // activeData.payloadType === 'IMAGE_NAMES' + // activeData.payloadType === 'IMAGE_DTO' && + // activeData.payload.imageDTO // ) { // const { fieldName, nodeId } = overData.context; // dispatch( - // imageCollectionFieldValueChanged({ + // fieldValueChanged({ // nodeId, // fieldName, - // value: activeData.payload.image_names.map((image_name) => ({ - // image_name, - // })), + // value: [activeData.payload.imageDTO], // }) // ); // return; // } - // add image to board + /** + * Image dropped on user board + */ if ( - overData.actionType === 'MOVE_BOARD' && + overData.actionType === 'ADD_TO_BOARD' && activeData.payloadType === 'IMAGE_DTO' && activeData.payload.imageDTO ) { const { imageDTO } = activeData.payload; const { boardId } = overData.context; - - // image was droppe on the "NoBoardBoard" - if (!boardId) { - dispatch( - imagesApi.endpoints.removeImageFromBoard.initiate({ - imageDTO, - }) - ); - return; - } - - // image was dropped on a user board dispatch( imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, @@ -176,67 +147,58 @@ export const addImageDroppedListener = () => { return; } - // // add gallery selection to board - // if ( - // overData.actionType === 'MOVE_BOARD' && - // activeData.payloadType === 'IMAGE_NAMES' && - // overData.context.boardId - // ) { - // console.log('adding gallery selection to board'); - // const board_id = overData.context.boardId; - // dispatch( - // boardImagesApi.endpoints.addManyBoardImages.initiate({ - // board_id, - // image_names: activeData.payload.image_names, - // }) - // ); - // return; - // } + /** + * Image dropped on 'none' board + */ + if ( + overData.actionType === 'REMOVE_FROM_BOARD' && + activeData.payloadType === 'IMAGE_DTO' && + activeData.payload.imageDTO + ) { + const { imageDTO } = activeData.payload; + dispatch( + imagesApi.endpoints.removeImageFromBoard.initiate({ + imageDTO, + }) + ); + return; + } - // // remove gallery selection from board - // if ( - // overData.actionType === 'MOVE_BOARD' && - // activeData.payloadType === 'IMAGE_NAMES' && - // overData.context.boardId === null - // ) { - // console.log('removing gallery selection to board'); - // dispatch( - // boardImagesApi.endpoints.deleteManyBoardImages.initiate({ - // image_names: activeData.payload.image_names, - // }) - // ); - // return; - // } + /** + * Multiple images dropped on user board + */ + if ( + overData.actionType === 'ADD_TO_BOARD' && + activeData.payloadType === 'IMAGE_DTOS' && + activeData.payload.imageDTOs + ) { + const { imageDTOs } = activeData.payload; + const { boardId } = overData.context; + dispatch( + imagesApi.endpoints.addImagesToBoard.initiate({ + imageDTOs, + board_id: boardId, + }) + ); + return; + } - // // add batch selection to board - // if ( - // overData.actionType === 'MOVE_BOARD' && - // activeData.payloadType === 'IMAGE_NAMES' && - // overData.context.boardId - // ) { - // const board_id = overData.context.boardId; - // dispatch( - // boardImagesApi.endpoints.addManyBoardImages.initiate({ - // board_id, - // image_names: activeData.payload.image_names, - // }) - // ); - // return; - // } - - // // remove batch selection from board - // if ( - // overData.actionType === 'MOVE_BOARD' && - // activeData.payloadType === 'IMAGE_NAMES' && - // overData.context.boardId === null - // ) { - // dispatch( - // boardImagesApi.endpoints.deleteManyBoardImages.initiate({ - // image_names: activeData.payload.image_names, - // }) - // ); - // return; - // } + /** + * Multiple images dropped on 'none' board + */ + if ( + overData.actionType === 'REMOVE_FROM_BOARD' && + activeData.payloadType === 'IMAGE_DTOS' && + activeData.payload.imageDTOs + ) { + const { imageDTOs } = activeData.payload; + dispatch( + imagesApi.endpoints.removeImagesFromBoard.initiate({ + imageDTOs, + }) + ); + return; + } }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts index 3a5eed95db..88a4e773d5 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts @@ -1,37 +1,32 @@ -import { imageDeletionConfirmed } from 'features/imageDeletion/store/actions'; -import { selectImageUsage } from 'features/imageDeletion/store/imageDeletionSelectors'; +import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; +import { selectImageUsage } from 'features/deleteImageModal/store/selectors'; import { - imageToDeleteSelected, + imagesToDeleteSelected, isModalOpenChanged, -} from 'features/imageDeletion/store/imageDeletionSlice'; +} from 'features/deleteImageModal/store/slice'; import { startAppListening } from '..'; export const addImageToDeleteSelectedListener = () => { startAppListening({ - actionCreator: imageToDeleteSelected, + actionCreator: imagesToDeleteSelected, effect: async (action, { dispatch, getState }) => { - const imageDTO = action.payload; + const imageDTOs = action.payload; const state = getState(); const { shouldConfirmOnDelete } = state.system; - const imageUsage = selectImageUsage(getState()); - - if (!imageUsage) { - // should never happen - return; - } + const imagesUsage = selectImageUsage(getState()); const isImageInUse = - imageUsage.isCanvasImage || - imageUsage.isInitialImage || - imageUsage.isControlNetImage || - imageUsage.isNodesImage; + imagesUsage.some((i) => i.isCanvasImage) || + imagesUsage.some((i) => i.isInitialImage) || + imagesUsage.some((i) => i.isControlNetImage) || + imagesUsage.some((i) => i.isNodesImage); if (shouldConfirmOnDelete || isImageInUse) { dispatch(isModalOpenChanged(true)); return; } - dispatch(imageDeletionConfirmed({ imageDTO, imageUsage })); + dispatch(imageDeletionConfirmed({ imageDTOs, imagesUsage })); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts index dd581d893c..f488259eb7 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -2,14 +2,13 @@ import { UseToastOptions } from '@chakra-ui/react'; import { logger } from 'app/logging/logger'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; -import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { addToast } from 'features/system/store/systemSlice'; +import { omit } from 'lodash-es'; import { boardsApi } from 'services/api/endpoints/boards'; import { startAppListening } from '..'; import { imagesApi } from '../../../../../services/api/endpoints/images'; -import { omit } from 'lodash-es'; const DEFAULT_UPLOADED_TOAST: UseToastOptions = { title: 'Image Uploaded', @@ -121,17 +120,6 @@ export const addImageUploadedFulfilledListener = () => { ); return; } - - if (postUploadAction?.type === 'ADD_TO_BATCH') { - dispatch(imagesAddedToBatch([imageDTO.image_name])); - dispatch( - addToast({ - ...DEFAULT_UPLOADED_TOAST, - description: 'Added to batch', - }) - ); - return; - } }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts index 325e843900..436a58aa8e 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -15,7 +15,7 @@ import { setShouldUseSDXLRefiner, } from 'features/sdxl/store/sdxlSlice'; import { forEach, some } from 'lodash-es'; -import { modelsApi } from 'services/api/endpoints/models'; +import { modelsApi, vaeModelsAdapter } from 'services/api/endpoints/models'; import { startAppListening } from '..'; export const addModelsLoadedListener = () => { @@ -144,8 +144,9 @@ export const addModelsLoadedListener = () => { return; } - const firstModelId = action.payload.ids[0]; - const firstModel = action.payload.entities[firstModelId]; + const firstModel = vaeModelsAdapter + .getSelectors() + .selectAll(action.payload)[0]; if (!firstModel) { // No custom VAEs loaded at all; use the default diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts index e36c49be63..30e0bedb54 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts @@ -8,9 +8,10 @@ import { } from 'features/gallery/store/gallerySlice'; import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { progressImageSet } from 'features/system/store/systemSlice'; -import { imagesAdapter, imagesApi } from 'services/api/endpoints/images'; +import { imagesApi } from 'services/api/endpoints/images'; import { isImageOutput } from 'services/api/guards'; import { sessionCanceled } from 'services/api/thunks/session'; +import { imagesAdapter } from 'services/api/util'; import { appSocketInvocationComplete, socketInvocationComplete, @@ -67,7 +68,7 @@ export const addInvocationCompleteEventListener = () => { */ const { autoAddBoardId } = gallery; - if (autoAddBoardId) { + if (autoAddBoardId && autoAddBoardId !== 'none') { dispatch( imagesApi.endpoints.addImageToBoard.initiate({ board_id: autoAddBoardId, @@ -83,10 +84,7 @@ export const addInvocationCompleteEventListener = () => { categories: IMAGE_CATEGORIES, }, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.addOne(draft, imageDTO); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.addOne(draft, imageDTO); } ) ); @@ -94,8 +92,8 @@ export const addInvocationCompleteEventListener = () => { dispatch( imagesApi.util.invalidateTags([ - { type: 'BoardImagesTotal', id: autoAddBoardId ?? 'none' }, - { type: 'BoardAssetsTotal', id: autoAddBoardId ?? 'none' }, + { type: 'BoardImagesTotal', id: autoAddBoardId }, + { type: 'BoardAssetsTotal', id: autoAddBoardId }, ]) ); @@ -110,7 +108,7 @@ export const addInvocationCompleteEventListener = () => { } else if (!autoAddBoardId) { dispatch(galleryViewChanged('images')); } - dispatch(imageSelected(imageDTO.image_name)); + dispatch(imageSelected(imageDTO)); } } diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index d71a147913..6b544252db 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -8,9 +8,9 @@ import { import canvasReducer from 'features/canvas/store/canvasSlice'; import controlNetReducer from 'features/controlNet/store/controlNetSlice'; import dynamicPromptsReducer from 'features/dynamicPrompts/store/dynamicPromptsSlice'; -import boardsReducer from 'features/gallery/store/boardSlice'; import galleryReducer from 'features/gallery/store/gallerySlice'; -import imageDeletionReducer from 'features/imageDeletion/store/imageDeletionSlice'; +import deleteImageModalReducer from 'features/deleteImageModal/store/slice'; +import changeBoardModalReducer from 'features/changeBoardModal/store/slice'; import loraReducer from 'features/lora/store/loraSlice'; import nodesReducer from 'features/nodes/store/nodesSlice'; import generationReducer from 'features/parameters/store/generationSlice'; @@ -43,9 +43,9 @@ const allReducers = { ui: uiReducer, hotkeys: hotkeysReducer, controlNet: controlNetReducer, - boards: boardsReducer, dynamicPrompts: dynamicPromptsReducer, - imageDeletion: imageDeletionReducer, + deleteImageModal: deleteImageModalReducer, + changeBoardModal: changeBoardModalReducer, lora: loraReducer, modelmanager: modelmanagerReducer, sdxl: sdxlReducer, diff --git a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx b/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx index 7601758409..f9bb36cc50 100644 --- a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx @@ -1,4 +1,4 @@ -import { Flex, Text, useColorMode } from '@chakra-ui/react'; +import { Box, Flex, useColorMode } from '@chakra-ui/react'; import { motion } from 'framer-motion'; import { ReactNode, memo, useRef } from 'react'; import { mode } from 'theme/util/mode'; @@ -74,7 +74,7 @@ export const IAIDropOverlay = (props: Props) => { justifyContent: 'center', }} > - { }} > {label} - + diff --git a/invokeai/frontend/web/src/common/components/IAIMantineSearchableSelect.tsx b/invokeai/frontend/web/src/common/components/IAIMantineSearchableSelect.tsx index 2c3f5434ad..079421d4e5 100644 --- a/invokeai/frontend/web/src/common/components/IAIMantineSearchableSelect.tsx +++ b/invokeai/frontend/web/src/common/components/IAIMantineSearchableSelect.tsx @@ -53,7 +53,9 @@ const IAIMantineSearchableSelect = (props: IAISelectProps) => { // wrap onChange to clear search value on select const handleChange = useCallback( (v: string | null) => { - setSearchValue(''); + // cannot figure out why we were doing this, but it was causing an issue where if you + // select the currently-selected item, it reset the search value to empty + // setSearchValue(''); if (!onChange) { return; diff --git a/invokeai/frontend/web/src/common/components/ImageUploader.tsx b/invokeai/frontend/web/src/common/components/ImageUploader.tsx index de347b8381..c990a9a24e 100644 --- a/invokeai/frontend/web/src/common/components/ImageUploader.tsx +++ b/invokeai/frontend/web/src/common/components/ImageUploader.tsx @@ -78,7 +78,7 @@ const ImageUploader = (props: ImageUploaderProps) => { image_category: 'user', is_intermediate: false, postUploadAction, - board_id: autoAddBoardId, + board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId, }); }, [autoAddBoardId, postUploadAction, uploadImage] diff --git a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx index c04c0182cd..dcbd81b2dd 100644 --- a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx +++ b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx @@ -49,7 +49,7 @@ export const useImageUploadButton = ({ image_category: 'user', is_intermediate: false, postUploadAction: postUploadAction ?? { type: 'TOAST' }, - board_id: autoAddBoardId, + board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId, }); }, [autoAddBoardId, postUploadAction, uploadImage] diff --git a/invokeai/frontend/web/src/features/canvas/hooks/useColorUnderCursor.ts b/invokeai/frontend/web/src/features/canvas/hooks/useColorUnderCursor.ts index 1356b24416..64289a1fd3 100644 --- a/invokeai/frontend/web/src/features/canvas/hooks/useColorUnderCursor.ts +++ b/invokeai/frontend/web/src/features/canvas/hooks/useColorUnderCursor.ts @@ -33,6 +33,10 @@ const useColorPicker = () => { 1 ).data; + if (!(a && r && g && b)) { + return; + } + dispatch(setColorPickerColor({ r, g, b, a })); }, commitColorUnderCursor: () => { diff --git a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts index 3163e513e9..f63ab2fd67 100644 --- a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts @@ -727,10 +727,13 @@ export const canvasSlice = createSlice({ state.pastLayerStates.shift(); } - state.layerState.objects.push({ - ...images[selectedImageIndex], - }); + const imageToCommit = images[selectedImageIndex]; + if (imageToCommit) { + state.layerState.objects.push({ + ...imageToCommit, + }); + } state.layerState.stagingArea = { ...initialLayerState.stagingArea, }; diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx new file mode 100644 index 0000000000..2443fa6081 --- /dev/null +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -0,0 +1,132 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Flex, + Text, +} from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIButton from 'common/components/IAIButton'; +import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import { + useAddImagesToBoardMutation, + useRemoveImagesFromBoardMutation, +} from 'services/api/endpoints/images'; +import { changeBoardReset, isModalOpenChanged } from '../store/slice'; + +const selector = createSelector( + [stateSelector], + ({ changeBoardModal }) => { + const { isModalOpen, imagesToChange } = changeBoardModal; + + return { + isModalOpen, + imagesToChange, + }; + }, + defaultSelectorOptions +); + +const ChangeBoardModal = () => { + const dispatch = useAppDispatch(); + const [selectedBoard, setSelectedBoard] = useState(); + const { data: boards, isFetching } = useListAllBoardsQuery(); + const { imagesToChange, isModalOpen } = useAppSelector(selector); + const [addImagesToBoard] = useAddImagesToBoardMutation(); + const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation(); + + const data = useMemo(() => { + const data: { label: string; value: string }[] = [ + { label: 'Uncategorized', value: 'none' }, + ]; + (boards ?? []).forEach((board) => + data.push({ + label: board.board_name, + value: board.board_id, + }) + ); + + return data; + }, [boards]); + + const handleClose = useCallback(() => { + dispatch(changeBoardReset()); + dispatch(isModalOpenChanged(false)); + }, [dispatch]); + + const handleChangeBoard = useCallback(() => { + if (!imagesToChange.length || !selectedBoard) { + return; + } + + if (selectedBoard === 'none') { + removeImagesFromBoard({ imageDTOs: imagesToChange }); + } else { + addImagesToBoard({ + imageDTOs: imagesToChange, + board_id: selectedBoard, + }); + } + setSelectedBoard(null); + dispatch(changeBoardReset()); + }, [ + addImagesToBoard, + dispatch, + imagesToChange, + removeImagesFromBoard, + selectedBoard, + ]); + + const cancelRef = useRef(null); + + return ( + + + + + Change Board + + + + + + Moving {`${imagesToChange.length}`} image + {`${imagesToChange.length > 1 ? 's' : ''}`} to board: + + setSelectedBoard(v)} + value={selectedBoard} + data={data} + /> + + + + + Cancel + + + Move + + + + + + ); +}; + +export default memo(ChangeBoardModal); diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/initialState.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/initialState.ts new file mode 100644 index 0000000000..d737d0cdcd --- /dev/null +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/initialState.ts @@ -0,0 +1,6 @@ +import { ChangeBoardModalState } from './types'; + +export const initialState: ChangeBoardModalState = { + isModalOpen: false, + imagesToChange: [], +}; diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts new file mode 100644 index 0000000000..9855e2d7dd --- /dev/null +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts @@ -0,0 +1,25 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { ImageDTO } from 'services/api/types'; +import { initialState } from './initialState'; + +const changeBoardModal = createSlice({ + name: 'changeBoardModal', + initialState, + reducers: { + isModalOpenChanged: (state, action: PayloadAction) => { + state.isModalOpen = action.payload; + }, + imagesToChangeSelected: (state, action: PayloadAction) => { + state.imagesToChange = action.payload; + }, + changeBoardReset: (state) => { + state.imagesToChange = []; + state.isModalOpen = false; + }, + }, +}); + +export const { isModalOpenChanged, imagesToChangeSelected, changeBoardReset } = + changeBoardModal.actions; + +export default changeBoardModal.reducer; diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/types.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/types.ts new file mode 100644 index 0000000000..6ce13331d0 --- /dev/null +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/types.ts @@ -0,0 +1,6 @@ +import { ImageDTO } from 'services/api/types'; + +export type ChangeBoardModalState = { + isModalOpen: boolean; + imagesToChange: ImageDTO[]; +}; diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index d858e46fdb..3252207edc 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -3,6 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { memo, useCallback } from 'react'; import { FaCopy, FaTrash } from 'react-icons/fa'; import { + ControlNetConfig, controlNetDuplicated, controlNetRemoved, controlNetToggled, @@ -27,18 +28,27 @@ import ParamControlNetProcessorSelect from './parameters/ParamControlNetProcesso import ParamControlNetResizeMode from './parameters/ParamControlNetResizeMode'; type ControlNetProps = { - controlNetId: string; + controlNet: ControlNetConfig; }; const ControlNet = (props: ControlNetProps) => { - const { controlNetId } = props; + const { controlNet } = props; + const { controlNetId } = controlNet; const dispatch = useAppDispatch(); const selector = createSelector( stateSelector, ({ controlNet }) => { - const { isEnabled, shouldAutoConfig } = - controlNet.controlNets[controlNetId]; + const cn = controlNet.controlNets[controlNetId]; + + if (!cn) { + return { + isEnabled: false, + shouldAutoConfig: false, + }; + } + + const { isEnabled, shouldAutoConfig } = cn; return { isEnabled, shouldAutoConfig }; }, @@ -96,7 +106,7 @@ const ControlNet = (props: ControlNetProps) => { transitionDuration: '0.1s', }} > - + { justifyContent: 'space-between', }} > - - + + {!isExpanded && ( { aspectRatio: '1/1', }} > - + )} - - + + - + {isExpanded && ( <> - - - + + + )} diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx index 859495a941..cdab176cd2 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNetImagePreview.tsx @@ -12,50 +12,41 @@ import IAIDndImage from 'common/components/IAIDndImage'; import { memo, useCallback, useMemo, useState } from 'react'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { PostUploadAction } from 'services/api/types'; -import { controlNetImageChanged } from '../store/controlNetSlice'; +import { + ControlNetConfig, + controlNetImageChanged, +} from '../store/controlNetSlice'; type Props = { - controlNetId: string; + controlNet: ControlNetConfig; height: SystemStyleObject['h']; }; +const selector = createSelector( + stateSelector, + ({ controlNet }) => { + const { pendingControlImages } = controlNet; + + return { + pendingControlImages, + }; + }, + defaultSelectorOptions +); + const ControlNetImagePreview = (props: Props) => { - const { height, controlNetId } = props; + const { height } = props; + const { + controlImage: controlImageName, + processedControlImage: processedControlImageName, + processorType, + isEnabled, + controlNetId, + } = props.controlNet; + const dispatch = useAppDispatch(); - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { pendingControlImages } = controlNet; - const { - controlImage, - processedControlImage, - processorType, - isEnabled, - } = controlNet.controlNets[controlNetId]; - - return { - controlImageName: controlImage, - processedControlImageName: processedControlImage, - processorType, - isEnabled, - pendingControlImages, - }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { - controlImageName, - processedControlImageName, - processorType, - pendingControlImages, - isEnabled, - } = useAppSelector(selector); + const { pendingControlImages } = useAppSelector(selector); const [isMouseOverImage, setIsMouseOverImage] = useState(false); diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNetProcessorComponent.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNetProcessorComponent.tsx index b7fa329eac..681838ef27 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNetProcessorComponent.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNetProcessorComponent.tsx @@ -1,8 +1,5 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { memo, useMemo } from 'react'; +import { memo } from 'react'; +import { ControlNetConfig } from '../store/controlNetSlice'; import CannyProcessor from './processors/CannyProcessor'; import ContentShuffleProcessor from './processors/ContentShuffleProcessor'; import HedProcessor from './processors/HedProcessor'; @@ -17,28 +14,11 @@ import PidiProcessor from './processors/PidiProcessor'; import ZoeDepthProcessor from './processors/ZoeDepthProcessor'; export type ControlNetProcessorProps = { - controlNetId: string; + controlNet: ControlNetConfig; }; const ControlNetProcessorComponent = (props: ControlNetProcessorProps) => { - const { controlNetId } = props; - - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { isEnabled, processorNode } = - controlNet.controlNets[controlNetId]; - - return { isEnabled, processorNode }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { isEnabled, processorNode } = useAppSelector(selector); + const { controlNetId, isEnabled, processorNode } = props.controlNet; if (processorNode.type === 'canny_image_processor') { return ( diff --git a/invokeai/frontend/web/src/features/controlNet/components/ParamControlNetShouldAutoConfig.tsx b/invokeai/frontend/web/src/features/controlNet/components/ParamControlNetShouldAutoConfig.tsx index 285fcf7b80..0e044d4575 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ParamControlNetShouldAutoConfig.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ParamControlNetShouldAutoConfig.tsx @@ -1,34 +1,19 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAISwitch from 'common/components/IAISwitch'; -import { controlNetAutoConfigToggled } from 'features/controlNet/store/controlNetSlice'; +import { + ControlNetConfig, + controlNetAutoConfigToggled, +} from 'features/controlNet/store/controlNetSlice'; import { selectIsBusy } from 'features/system/store/systemSelectors'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback } from 'react'; type Props = { - controlNetId: string; + controlNet: ControlNetConfig; }; const ParamControlNetShouldAutoConfig = (props: Props) => { - const { controlNetId } = props; + const { controlNetId, isEnabled, shouldAutoConfig } = props.controlNet; const dispatch = useAppDispatch(); - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { isEnabled, shouldAutoConfig } = - controlNet.controlNets[controlNetId]; - return { isEnabled, shouldAutoConfig }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { isEnabled, shouldAutoConfig } = useAppSelector(selector); const isBusy = useAppSelector(selectIsBusy); const handleShouldAutoConfigChanged = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetBeginEnd.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetBeginEnd.tsx index 3dd420e7c9..1219239e5d 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetBeginEnd.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetBeginEnd.tsx @@ -9,48 +9,39 @@ import { RangeSliderTrack, Tooltip, } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useAppDispatch } from 'app/store/storeHooks'; import { + ControlNetConfig, controlNetBeginStepPctChanged, controlNetEndStepPctChanged, } from 'features/controlNet/store/controlNetSlice'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback } from 'react'; type Props = { - controlNetId: string; + controlNet: ControlNetConfig; }; const formatPct = (v: number) => `${Math.round(v * 100)}%`; const ParamControlNetBeginEnd = (props: Props) => { - const { controlNetId } = props; + const { beginStepPct, endStepPct, isEnabled, controlNetId } = + props.controlNet; const dispatch = useAppDispatch(); - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { beginStepPct, endStepPct, isEnabled } = - controlNet.controlNets[controlNetId]; - return { beginStepPct, endStepPct, isEnabled }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { beginStepPct, endStepPct, isEnabled } = useAppSelector(selector); - const handleStepPctChanged = useCallback( (v: number[]) => { dispatch( - controlNetBeginStepPctChanged({ controlNetId, beginStepPct: v[0] }) + controlNetBeginStepPctChanged({ + controlNetId, + beginStepPct: v[0] as number, + }) + ); + dispatch( + controlNetEndStepPctChanged({ + controlNetId, + endStepPct: v[1] as number, + }) ); - dispatch(controlNetEndStepPctChanged({ controlNetId, endStepPct: v[1] })); }, [controlNetId, dispatch] ); diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetControlMode.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetControlMode.tsx index e644e24a02..761edde42b 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetControlMode.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetControlMode.tsx @@ -1,16 +1,14 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useAppDispatch } from 'app/store/storeHooks'; import IAIMantineSelect from 'common/components/IAIMantineSelect'; import { ControlModes, + ControlNetConfig, controlNetControlModeChanged, } from 'features/controlNet/store/controlNetSlice'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; type ParamControlNetControlModeProps = { - controlNetId: string; + controlNet: ControlNetConfig; }; const CONTROL_MODE_DATA = [ @@ -23,23 +21,8 @@ const CONTROL_MODE_DATA = [ export default function ParamControlNetControlMode( props: ParamControlNetControlModeProps ) { - const { controlNetId } = props; + const { controlMode, isEnabled, controlNetId } = props.controlNet; const dispatch = useAppDispatch(); - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { controlMode, isEnabled } = - controlNet.controlNets[controlNetId]; - return { controlMode, isEnabled }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { controlMode, isEnabled } = useAppSelector(selector); const handleControlModeChange = useCallback( (controlMode: ControlModes) => { diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetModel.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetModel.tsx index 8392bdd2e3..5d7db854d8 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetModel.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetModel.tsx @@ -5,7 +5,10 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; import IAIMantineSelectItemWithTooltip from 'common/components/IAIMantineSelectItemWithTooltip'; -import { controlNetModelChanged } from 'features/controlNet/store/controlNetSlice'; +import { + ControlNetConfig, + controlNetModelChanged, +} from 'features/controlNet/store/controlNetSlice'; import { MODEL_TYPE_MAP } from 'features/parameters/types/constants'; import { modelIdToControlNetModelParam } from 'features/parameters/util/modelIdToControlNetModelParam'; import { selectIsBusy } from 'features/system/store/systemSelectors'; @@ -14,30 +17,24 @@ import { memo, useCallback, useMemo } from 'react'; import { useGetControlNetModelsQuery } from 'services/api/endpoints/models'; type ParamControlNetModelProps = { - controlNetId: string; + controlNet: ControlNetConfig; }; +const selector = createSelector( + stateSelector, + ({ generation }) => { + const { model } = generation; + return { mainModel: model }; + }, + defaultSelectorOptions +); + const ParamControlNetModel = (props: ParamControlNetModelProps) => { - const { controlNetId } = props; + const { controlNetId, model: controlNetModel, isEnabled } = props.controlNet; const dispatch = useAppDispatch(); const isBusy = useAppSelector(selectIsBusy); - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ generation, controlNet }) => { - const { model } = generation; - const controlNetModel = controlNet.controlNets[controlNetId]?.model; - const isEnabled = controlNet.controlNets[controlNetId]?.isEnabled; - return { mainModel: model, controlNetModel, isEnabled }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { mainModel, controlNetModel, isEnabled } = useAppSelector(selector); + const { mainModel } = useAppSelector(selector); const { data: controlNetModels } = useGetControlNetModelsQuery(); diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetProcessorSelect.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetProcessorSelect.tsx index 83c66363ac..190b1bc012 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetProcessorSelect.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetProcessorSelect.tsx @@ -1,7 +1,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIMantineSearchableSelect, { IAISelectDataType, @@ -9,13 +8,16 @@ import IAIMantineSearchableSelect, { import { configSelector } from 'features/system/store/configSelectors'; import { selectIsBusy } from 'features/system/store/systemSelectors'; import { map } from 'lodash-es'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback } from 'react'; import { CONTROLNET_PROCESSORS } from '../../store/constants'; -import { controlNetProcessorTypeChanged } from '../../store/controlNetSlice'; +import { + ControlNetConfig, + controlNetProcessorTypeChanged, +} from '../../store/controlNetSlice'; import { ControlNetProcessorType } from '../../store/types'; type ParamControlNetProcessorSelectProps = { - controlNetId: string; + controlNet: ControlNetConfig; }; const selector = createSelector( @@ -52,23 +54,9 @@ const ParamControlNetProcessorSelect = ( props: ParamControlNetProcessorSelectProps ) => { const dispatch = useAppDispatch(); - const { controlNetId } = props; - const processorNodeSelector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { isEnabled, processorNode } = - controlNet.controlNets[controlNetId]; - return { isEnabled, processorNode }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); + const { controlNetId, isEnabled, processorNode } = props.controlNet; const isBusy = useAppSelector(selectIsBusy); const controlNetProcessors = useAppSelector(selector); - const { isEnabled, processorNode } = useAppSelector(processorNodeSelector); const handleProcessorTypeChanged = useCallback( (v: string | null) => { diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx index ee04b8077f..72f15fb178 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx @@ -1,16 +1,14 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useAppDispatch } from 'app/store/storeHooks'; import IAIMantineSelect from 'common/components/IAIMantineSelect'; import { + ControlNetConfig, ResizeModes, controlNetResizeModeChanged, } from 'features/controlNet/store/controlNetSlice'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; type ParamControlNetResizeModeProps = { - controlNetId: string; + controlNet: ControlNetConfig; }; const RESIZE_MODE_DATA = [ @@ -22,23 +20,8 @@ const RESIZE_MODE_DATA = [ export default function ParamControlNetResizeMode( props: ParamControlNetResizeModeProps ) { - const { controlNetId } = props; + const { resizeMode, isEnabled, controlNetId } = props.controlNet; const dispatch = useAppDispatch(); - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { resizeMode, isEnabled } = - controlNet.controlNets[controlNetId]; - return { resizeMode, isEnabled }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { resizeMode, isEnabled } = useAppSelector(selector); const handleResizeModeChange = useCallback( (resizeMode: ResizeModes) => { diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetWeight.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetWeight.tsx index 8643fd7dad..c08283e1f9 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetWeight.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetWeight.tsx @@ -1,32 +1,18 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useAppDispatch } from 'app/store/storeHooks'; import IAISlider from 'common/components/IAISlider'; -import { controlNetWeightChanged } from 'features/controlNet/store/controlNetSlice'; -import { memo, useCallback, useMemo } from 'react'; +import { + ControlNetConfig, + controlNetWeightChanged, +} from 'features/controlNet/store/controlNetSlice'; +import { memo, useCallback } from 'react'; type ParamControlNetWeightProps = { - controlNetId: string; + controlNet: ControlNetConfig; }; const ParamControlNetWeight = (props: ParamControlNetWeightProps) => { - const { controlNetId } = props; + const { weight, isEnabled, controlNetId } = props.controlNet; const dispatch = useAppDispatch(); - const selector = useMemo( - () => - createSelector( - stateSelector, - ({ controlNet }) => { - const { weight, isEnabled } = controlNet.controlNets[controlNetId]; - return { weight, isEnabled }; - }, - defaultSelectorOptions - ), - [controlNetId] - ); - - const { weight, isEnabled } = useAppSelector(selector); const handleWeightChanged = useCallback( (weight: number) => { dispatch(controlNetWeightChanged({ controlNetId, weight })); diff --git a/invokeai/frontend/web/src/features/controlNet/store/constants.ts b/invokeai/frontend/web/src/features/controlNet/store/constants.ts index 00f5377e00..f8f9c38619 100644 --- a/invokeai/frontend/web/src/features/controlNet/store/constants.ts +++ b/invokeai/frontend/web/src/features/controlNet/store/constants.ts @@ -4,7 +4,7 @@ import { } from './types'; type ControlNetProcessorsDict = Record< - string, + ControlNetProcessorType, { type: ControlNetProcessorType | 'none'; label: string; diff --git a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts index 0df907d463..8f391521d6 100644 --- a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts +++ b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts @@ -96,8 +96,11 @@ export const controlNetSlice = createSlice({ }> ) => { const { sourceControlNetId, newControlNetId } = action.payload; - - const newControlnet = cloneDeep(state.controlNets[sourceControlNetId]); + const oldControlNet = state.controlNets[sourceControlNetId]; + if (!oldControlNet) { + return; + } + const newControlnet = cloneDeep(oldControlNet); newControlnet.controlNetId = newControlNetId; state.controlNets[newControlNetId] = newControlnet; }, @@ -124,8 +127,11 @@ export const controlNetSlice = createSlice({ action: PayloadAction<{ controlNetId: string }> ) => { const { controlNetId } = action.payload; - state.controlNets[controlNetId].isEnabled = - !state.controlNets[controlNetId].isEnabled; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + cn.isEnabled = !cn.isEnabled; }, controlNetImageChanged: ( state, @@ -135,12 +141,14 @@ export const controlNetSlice = createSlice({ }> ) => { const { controlNetId, controlImage } = action.payload; - state.controlNets[controlNetId].controlImage = controlImage; - state.controlNets[controlNetId].processedControlImage = null; - if ( - controlImage !== null && - state.controlNets[controlNetId].processorType !== 'none' - ) { + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.controlImage = controlImage; + cn.processedControlImage = null; + if (controlImage !== null && cn.processorType !== 'none') { state.pendingControlImages.push(controlNetId); } }, @@ -152,8 +160,12 @@ export const controlNetSlice = createSlice({ }> ) => { const { controlNetId, processedControlImage } = action.payload; - state.controlNets[controlNetId].processedControlImage = - processedControlImage; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.processedControlImage = processedControlImage; state.pendingControlImages = state.pendingControlImages.filter( (id) => id !== controlNetId ); @@ -166,10 +178,15 @@ export const controlNetSlice = createSlice({ }> ) => { const { controlNetId, model } = action.payload; - state.controlNets[controlNetId].model = model; - state.controlNets[controlNetId].processedControlImage = null; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } - if (state.controlNets[controlNetId].shouldAutoConfig) { + cn.model = model; + cn.processedControlImage = null; + + if (cn.shouldAutoConfig) { let processorType: ControlNetProcessorType | undefined = undefined; for (const modelSubstring in CONTROLNET_MODEL_DEFAULT_PROCESSORS) { @@ -180,14 +197,13 @@ export const controlNetSlice = createSlice({ } if (processorType) { - state.controlNets[controlNetId].processorType = processorType; - state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[ - processorType - ].default as RequiredControlNetProcessorNode; + cn.processorType = processorType; + cn.processorNode = CONTROLNET_PROCESSORS[processorType] + .default as RequiredControlNetProcessorNode; } else { - state.controlNets[controlNetId].processorType = 'none'; - state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS - .none.default as RequiredControlNetProcessorNode; + cn.processorType = 'none'; + cn.processorNode = CONTROLNET_PROCESSORS.none + .default as RequiredControlNetProcessorNode; } } }, @@ -196,28 +212,48 @@ export const controlNetSlice = createSlice({ action: PayloadAction<{ controlNetId: string; weight: number }> ) => { const { controlNetId, weight } = action.payload; - state.controlNets[controlNetId].weight = weight; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.weight = weight; }, controlNetBeginStepPctChanged: ( state, action: PayloadAction<{ controlNetId: string; beginStepPct: number }> ) => { const { controlNetId, beginStepPct } = action.payload; - state.controlNets[controlNetId].beginStepPct = beginStepPct; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.beginStepPct = beginStepPct; }, controlNetEndStepPctChanged: ( state, action: PayloadAction<{ controlNetId: string; endStepPct: number }> ) => { const { controlNetId, endStepPct } = action.payload; - state.controlNets[controlNetId].endStepPct = endStepPct; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.endStepPct = endStepPct; }, controlNetControlModeChanged: ( state, action: PayloadAction<{ controlNetId: string; controlMode: ControlModes }> ) => { const { controlNetId, controlMode } = action.payload; - state.controlNets[controlNetId].controlMode = controlMode; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.controlMode = controlMode; }, controlNetResizeModeChanged: ( state, @@ -227,7 +263,12 @@ export const controlNetSlice = createSlice({ }> ) => { const { controlNetId, resizeMode } = action.payload; - state.controlNets[controlNetId].resizeMode = resizeMode; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.resizeMode = resizeMode; }, controlNetProcessorParamsChanged: ( state, @@ -240,12 +281,17 @@ export const controlNetSlice = createSlice({ }> ) => { const { controlNetId, changes } = action.payload; - const processorNode = state.controlNets[controlNetId].processorNode; - state.controlNets[controlNetId].processorNode = { + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + const processorNode = cn.processorNode; + cn.processorNode = { ...processorNode, ...changes, }; - state.controlNets[controlNetId].shouldAutoConfig = false; + cn.shouldAutoConfig = false; }, controlNetProcessorTypeChanged: ( state, @@ -255,12 +301,16 @@ export const controlNetSlice = createSlice({ }> ) => { const { controlNetId, processorType } = action.payload; - state.controlNets[controlNetId].processedControlImage = null; - state.controlNets[controlNetId].processorType = processorType; - state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[ - processorType - ].default as RequiredControlNetProcessorNode; - state.controlNets[controlNetId].shouldAutoConfig = false; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + cn.processedControlImage = null; + cn.processorType = processorType; + cn.processorNode = CONTROLNET_PROCESSORS[processorType] + .default as RequiredControlNetProcessorNode; + cn.shouldAutoConfig = false; }, controlNetAutoConfigToggled: ( state, @@ -269,37 +319,36 @@ export const controlNetSlice = createSlice({ }> ) => { const { controlNetId } = action.payload; - const newShouldAutoConfig = - !state.controlNets[controlNetId].shouldAutoConfig; + const cn = state.controlNets[controlNetId]; + if (!cn) { + return; + } + + const newShouldAutoConfig = !cn.shouldAutoConfig; if (newShouldAutoConfig) { // manage the processor for the user let processorType: ControlNetProcessorType | undefined = undefined; for (const modelSubstring in CONTROLNET_MODEL_DEFAULT_PROCESSORS) { - if ( - state.controlNets[controlNetId].model?.model_name.includes( - modelSubstring - ) - ) { + if (cn.model?.model_name.includes(modelSubstring)) { processorType = CONTROLNET_MODEL_DEFAULT_PROCESSORS[modelSubstring]; break; } } if (processorType) { - state.controlNets[controlNetId].processorType = processorType; - state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[ - processorType - ].default as RequiredControlNetProcessorNode; + cn.processorType = processorType; + cn.processorNode = CONTROLNET_PROCESSORS[processorType] + .default as RequiredControlNetProcessorNode; } else { - state.controlNets[controlNetId].processorType = 'none'; - state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS - .none.default as RequiredControlNetProcessorNode; + cn.processorType = 'none'; + cn.processorNode = CONTROLNET_PROCESSORS.none + .default as RequiredControlNetProcessorNode; } } - state.controlNets[controlNetId].shouldAutoConfig = newShouldAutoConfig; + cn.shouldAutoConfig = newShouldAutoConfig; }, controlNetReset: () => { return { ...initialControlNetState }; @@ -307,9 +356,11 @@ export const controlNetSlice = createSlice({ }, extraReducers: (builder) => { builder.addCase(controlNetImageProcessed, (state, action) => { - if ( - state.controlNets[action.payload.controlNetId].controlImage !== null - ) { + const cn = state.controlNets[action.payload.controlNetId]; + if (!cn) { + return; + } + if (cn.controlImage !== null) { state.pendingControlImages.push(action.payload.controlNetId); } }); diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageButton.tsx similarity index 100% rename from invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageButton.tsx rename to invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageButton.tsx diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx similarity index 70% rename from invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx rename to invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx index 0e72ea96ad..0d8ecfbae6 100644 --- a/invokeai/frontend/web/src/features/imageDeletion/components/DeleteImageModal.tsx +++ b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx @@ -15,30 +15,42 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIButton from 'common/components/IAIButton'; import IAISwitch from 'common/components/IAISwitch'; import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice'; - import { stateSelector } from 'app/store/store'; +import { some } from 'lodash-es'; import { ChangeEvent, memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { imageDeletionConfirmed } from '../store/actions'; -import { selectImageUsage } from '../store/imageDeletionSelectors'; -import { - imageToDeleteCleared, - isModalOpenChanged, -} from '../store/imageDeletionSlice'; +import { getImageUsage, selectImageUsage } from '../store/selectors'; +import { imageDeletionCanceled, isModalOpenChanged } from '../store/slice'; import ImageUsageMessage from './ImageUsageMessage'; +import { ImageUsage } from '../store/types'; const selector = createSelector( [stateSelector, selectImageUsage], - ({ system, config, imageDeletion }, imageUsage) => { + (state, imagesUsage) => { + const { system, config, deleteImageModal } = state; const { shouldConfirmOnDelete } = system; const { canRestoreDeletedImagesFromBin } = config; - const { imageToDelete, isModalOpen } = imageDeletion; + const { imagesToDelete, isModalOpen } = deleteImageModal; + + const allImageUsage = (imagesToDelete ?? []).map(({ image_name }) => + getImageUsage(state, image_name) + ); + + const imageUsageSummary: ImageUsage = { + isInitialImage: some(allImageUsage, (i) => i.isInitialImage), + isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage), + isNodesImage: some(allImageUsage, (i) => i.isNodesImage), + isControlNetImage: some(allImageUsage, (i) => i.isControlNetImage), + }; + return { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin, - imageToDelete, - imageUsage, + imagesToDelete, + imagesUsage, isModalOpen, + imageUsageSummary, }; }, defaultSelectorOptions @@ -51,9 +63,10 @@ const DeleteImageModal = () => { const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin, - imageToDelete, - imageUsage, + imagesToDelete, + imagesUsage, isModalOpen, + imageUsageSummary, } = useAppSelector(selector); const handleChangeShouldConfirmOnDelete = useCallback( @@ -63,17 +76,19 @@ const DeleteImageModal = () => { ); const handleClose = useCallback(() => { - dispatch(imageToDeleteCleared()); + dispatch(imageDeletionCanceled()); dispatch(isModalOpenChanged(false)); }, [dispatch]); const handleDelete = useCallback(() => { - if (!imageToDelete || !imageUsage) { + if (!imagesToDelete.length || !imagesUsage.length) { return; } - dispatch(imageToDeleteCleared()); - dispatch(imageDeletionConfirmed({ imageDTO: imageToDelete, imageUsage })); - }, [dispatch, imageToDelete, imageUsage]); + dispatch(imageDeletionCanceled()); + dispatch( + imageDeletionConfirmed({ imageDTOs: imagesToDelete, imagesUsage }) + ); + }, [dispatch, imagesToDelete, imagesUsage]); const cancelRef = useRef(null); @@ -92,7 +107,7 @@ const DeleteImageModal = () => { - + {canRestoreDeletedImagesFromBin diff --git a/invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx similarity index 100% rename from invokeai/frontend/web/src/features/imageDeletion/components/ImageUsageMessage.tsx rename to invokeai/frontend/web/src/features/deleteImageModal/components/ImageUsageMessage.tsx diff --git a/invokeai/frontend/web/src/features/imageDeletion/store/actions.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/actions.ts similarity index 65% rename from invokeai/frontend/web/src/features/imageDeletion/store/actions.ts rename to invokeai/frontend/web/src/features/deleteImageModal/store/actions.ts index c67d7d944d..def27c9954 100644 --- a/invokeai/frontend/web/src/features/imageDeletion/store/actions.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/actions.ts @@ -3,6 +3,6 @@ import { ImageDTO } from 'services/api/types'; import { ImageUsage } from './types'; export const imageDeletionConfirmed = createAction<{ - imageDTO: ImageDTO; - imageUsage: ImageUsage; -}>('imageDeletion/imageDeletionConfirmed'); + imageDTOs: ImageDTO[]; + imagesUsage: ImageUsage[]; +}>('deleteImageModal/imageDeletionConfirmed'); diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/initialState.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/initialState.ts new file mode 100644 index 0000000000..198d4ca51f --- /dev/null +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/initialState.ts @@ -0,0 +1,6 @@ +import { DeleteImageState } from './types'; + +export const initialDeleteImageState: DeleteImageState = { + imagesToDelete: [], + isModalOpen: false, +}; diff --git a/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSelectors.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts similarity index 84% rename from invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSelectors.ts rename to invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts index bd8e117496..310521f32a 100644 --- a/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSelectors.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts @@ -39,17 +39,17 @@ export const getImageUsage = (state: RootState, image_name: string) => { export const selectImageUsage = createSelector( [(state: RootState) => state], (state) => { - const { imageToDelete } = state.imageDeletion; + const { imagesToDelete } = state.deleteImageModal; - if (!imageToDelete) { - return; + if (!imagesToDelete.length) { + return []; } - const { image_name } = imageToDelete; + const imagesUsage = imagesToDelete.map((i) => + getImageUsage(state, i.image_name) + ); - const imageUsage = getImageUsage(state, image_name); - - return imageUsage; + return imagesUsage; }, defaultSelectorOptions ); diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/slice.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/slice.ts new file mode 100644 index 0000000000..6569009666 --- /dev/null +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/slice.ts @@ -0,0 +1,28 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { ImageDTO } from 'services/api/types'; +import { initialDeleteImageState } from './initialState'; + +const deleteImageModal = createSlice({ + name: 'deleteImageModal', + initialState: initialDeleteImageState, + reducers: { + isModalOpenChanged: (state, action: PayloadAction) => { + state.isModalOpen = action.payload; + }, + imagesToDeleteSelected: (state, action: PayloadAction) => { + state.imagesToDelete = action.payload; + }, + imageDeletionCanceled: (state) => { + state.imagesToDelete = []; + state.isModalOpen = false; + }, + }, +}); + +export const { + isModalOpenChanged, + imagesToDeleteSelected, + imageDeletionCanceled, +} = deleteImageModal.actions; + +export default deleteImageModal.reducer; diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts new file mode 100644 index 0000000000..2beaa8ca2e --- /dev/null +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/types.ts @@ -0,0 +1,13 @@ +import { ImageDTO } from 'services/api/types'; + +export type DeleteImageState = { + imagesToDelete: ImageDTO[]; + isModalOpen: boolean; +}; + +export type ImageUsage = { + isInitialImage: boolean; + isCanvasImage: boolean; + isNodesImage: boolean; + isControlNetImage: boolean; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx index ad0e5ab80d..96d17b548e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx @@ -11,11 +11,14 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; const selector = createSelector( [stateSelector], - ({ gallery }) => { - const { autoAddBoardId } = gallery; + ({ gallery, system }) => { + const { autoAddBoardId, autoAssignBoardOnClick } = gallery; + const { isProcessing } = system; return { autoAddBoardId, + autoAssignBoardOnClick, + isProcessing, }; }, defaultSelectorOptions @@ -23,7 +26,8 @@ const selector = createSelector( const BoardAutoAddSelect = () => { const dispatch = useAppDispatch(); - const { autoAddBoardId } = useAppSelector(selector); + const { autoAddBoardId, autoAssignBoardOnClick, isProcessing } = + useAppSelector(selector); const inputRef = useRef(null); const { boards, hasBoards } = useListAllBoardsQuery(undefined, { selectFromResult: ({ data }) => { @@ -52,7 +56,7 @@ const BoardAutoAddSelect = () => { return; } - dispatch(autoAddBoardIdChanged(v === 'none' ? undefined : v)); + dispatch(autoAddBoardIdChanged(v)); }, [dispatch] ); @@ -67,7 +71,7 @@ const BoardAutoAddSelect = () => { data={boards} nothingFound="No matching Boards" itemComponent={IAIMantineSelectItemWithTooltip} - disabled={!hasBoards} + disabled={!hasBoards || autoAssignBoardOnClick || isProcessing} filter={(value, item: SelectItem) => item.label?.toLowerCase().includes(value.toLowerCase().trim()) || item.value.toLowerCase().includes(value.toLowerCase().trim()) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx index 35fcbd87f7..0667c05435 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -11,10 +11,11 @@ import { BoardDTO } from 'services/api/types'; import { menuListMotionProps } from 'theme/components/menu'; import GalleryBoardContextMenuItems from './GalleryBoardContextMenuItems'; import NoBoardContextMenuItems from './NoBoardContextMenuItems'; +import { BoardId } from 'features/gallery/store/types'; type Props = { board?: BoardDTO; - board_id?: string; + board_id: BoardId; children: ContextMenuProps['children']; setBoardToDelete?: (board?: BoardDTO) => void; }; @@ -25,14 +26,17 @@ const BoardContextMenu = memo( const selector = useMemo( () => - createSelector(stateSelector, ({ gallery }) => { + createSelector(stateSelector, ({ gallery, system }) => { const isAutoAdd = gallery.autoAddBoardId === board_id; - return { isAutoAdd }; + const isProcessing = system.isProcessing; + const autoAssignBoardOnClick = gallery.autoAssignBoardOnClick; + return { isAutoAdd, isProcessing, autoAssignBoardOnClick }; }), [board_id] ); - const { isAutoAdd } = useAppSelector(selector); + const { isAutoAdd, isProcessing, autoAssignBoardOnClick } = + useAppSelector(selector); const boardName = useBoardName(board_id); const handleSetAutoAdd = useCallback(() => { @@ -59,7 +63,7 @@ const BoardContextMenu = memo( } - isDisabled={isAutoAdd} + isDisabled={isAutoAdd || isProcessing || autoAssignBoardOnClick} onClick={handleSetAutoAdd} > Auto-add to this Board diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BatchBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BatchBoard.tsx deleted file mode 100644 index a7a3040cce..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BatchBoard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd'; -import { stateSelector } from 'app/store/store'; -import { useAppSelector } from 'app/store/storeHooks'; -import { boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { useCallback } from 'react'; -import { FaLayerGroup } from 'react-icons/fa'; -import { useDispatch } from 'react-redux'; -import GenericBoard from './GenericBoard'; - -const selector = createSelector(stateSelector, (state) => { - return { - count: state.gallery.batchImageNames.length, - }; -}); - -const BatchBoard = ({ isSelected }: { isSelected: boolean }) => { - const dispatch = useDispatch(); - const { count } = useAppSelector(selector); - - const handleBatchBoardClick = useCallback(() => { - dispatch(boardIdSelected('batch')); - }, [dispatch]); - - const droppableData: AddToBatchDropData = { - id: 'batch-board', - actionType: 'ADD_TO_BATCH', - }; - - return ( - - ); -}; - -export default BatchBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index 512fced67c..cb3474f6bd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -15,10 +15,9 @@ import NoBoardBoard from './NoBoardBoard'; const selector = createSelector( [stateSelector], - ({ boards, gallery }) => { - const { searchText } = boards; - const { selectedBoardId } = gallery; - return { selectedBoardId, searchText }; + ({ gallery }) => { + const { selectedBoardId, boardSearchText } = gallery; + return { selectedBoardId, boardSearchText }; }, defaultSelectorOptions ); @@ -29,11 +28,11 @@ type Props = { const BoardsList = (props: Props) => { const { isOpen } = props; - const { selectedBoardId, searchText } = useAppSelector(selector); + const { selectedBoardId, boardSearchText } = useAppSelector(selector); const { data: boards } = useListAllBoardsQuery(); - const filteredBoards = searchText + const filteredBoards = boardSearchText ? boards?.filter((board) => - board.board_name.toLowerCase().includes(searchText.toLowerCase()) + board.board_name.toLowerCase().includes(boardSearchText.toLowerCase()) ) : boards; const [boardToDelete, setBoardToDelete] = useState(); @@ -75,7 +74,7 @@ const BoardsList = (props: Props) => { }} > - + {filteredBoards && filteredBoards.map((board) => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx index 800ffc651f..d7db96a938 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx @@ -9,7 +9,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { setBoardSearchText } from 'features/gallery/store/boardSlice'; +import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; import { ChangeEvent, KeyboardEvent, @@ -21,27 +21,27 @@ import { const selector = createSelector( [stateSelector], - ({ boards }) => { - const { searchText } = boards; - return { searchText }; + ({ gallery }) => { + const { boardSearchText } = gallery; + return { boardSearchText }; }, defaultSelectorOptions ); const BoardsSearch = () => { const dispatch = useAppDispatch(); - const { searchText } = useAppSelector(selector); + const { boardSearchText } = useAppSelector(selector); const inputRef = useRef(null); const handleBoardSearch = useCallback( (searchTerm: string) => { - dispatch(setBoardSearchText(searchTerm)); + dispatch(boardSearchTextChanged(searchTerm)); }, [dispatch] ); const clearBoardSearch = useCallback(() => { - dispatch(setBoardSearchText('')); + dispatch(boardSearchTextChanged('')); }, [dispatch]); const handleKeydown = useCallback( @@ -74,11 +74,11 @@ const BoardsSearch = () => { - {searchText && searchText.length && ( + {boardSearchText && boardSearchText.length && ( createSelector( stateSelector, - ({ gallery }) => { + ({ gallery, system }) => { const isSelectedForAutoAdd = board.board_id === gallery.autoAddBoardId; + const autoAssignBoardOnClick = gallery.autoAssignBoardOnClick; + const isProcessing = system.isProcessing; - return { isSelectedForAutoAdd }; + return { + isSelectedForAutoAdd, + autoAssignBoardOnClick, + isProcessing, + }; }, defaultSelectorOptions ), [board.board_id] ); - const { isSelectedForAutoAdd } = useAppSelector(selector); + const { isSelectedForAutoAdd, autoAssignBoardOnClick, isProcessing } = + useAppSelector(selector); const [isHovered, setIsHovered] = useState(false); const handleMouseOver = useCallback(() => { setIsHovered(true); @@ -57,6 +72,18 @@ const GalleryBoard = memo( const handleMouseOut = useCallback(() => { setIsHovered(false); }, []); + + const { data: imagesTotal } = useGetBoardImagesTotalQuery(board.board_id); + const { data: assetsTotal } = useGetBoardAssetsTotalQuery(board.board_id); + const tooltip = useMemo(() => { + if (!imagesTotal || !assetsTotal) { + return undefined; + } + return `${imagesTotal} image${ + imagesTotal > 1 ? 's' : '' + }, ${assetsTotal} asset${assetsTotal > 1 ? 's' : ''}`; + }, [assetsTotal, imagesTotal]); + const { currentData: coverImage } = useGetImageDTOQuery( board.cover_image_name ?? skipToken ); @@ -66,15 +93,18 @@ const GalleryBoard = memo( const handleSelectBoard = useCallback(() => { dispatch(boardIdSelected(board_id)); - }, [board_id, dispatch]); + if (autoAssignBoardOnClick && !isProcessing) { + dispatch(autoAddBoardIdChanged(board_id)); + } + }, [board_id, autoAssignBoardOnClick, isProcessing, dispatch]); const [updateBoard, { isLoading: isUpdateBoardLoading }] = useUpdateBoardMutation(); - const droppableData: MoveBoardDropData = useMemo( + const droppableData: AddToBoardDropData = useMemo( () => ({ id: board_id, - actionType: 'MOVE_BOARD', + actionType: 'ADD_TO_BOARD', context: { boardId: board_id }, }), [board_id] @@ -135,60 +165,61 @@ const GalleryBoard = memo( setBoardToDelete={setBoardToDelete} > {(ref) => ( - - {coverImage?.thumbnail_url ? ( - - ) : ( - - + + {coverImage?.thumbnail_url ? ( + - - )} - {/* + + + )} + {/* */} - {isSelectedForAutoAdd && } - - - } + + - - + - - + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + noOfLines={1} + /> + + + - Move} - /> - + Move} + /> + + )} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index ee1d8f6bea..f1341b1146 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -1,50 +1,60 @@ import { Box, Flex, Image, Text } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; -import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; +import { RemoveFromBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import InvokeAILogoImage from 'assets/images/logo.png'; import IAIDroppable from 'common/components/IAIDroppable'; import SelectionOverlay from 'common/components/SelectionOverlay'; -import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { + boardIdSelected, + autoAddBoardIdChanged, +} from 'features/gallery/store/gallerySlice'; import { memo, useCallback, useMemo, useState } from 'react'; import { useBoardName } from 'services/api/hooks/useBoardName'; import AutoAddIcon from '../AutoAddIcon'; import BoardContextMenu from '../BoardContextMenu'; + interface Props { isSelected: boolean; } const selector = createSelector( stateSelector, - ({ gallery }) => { - const { autoAddBoardId } = gallery; - return { autoAddBoardId }; + ({ gallery, system }) => { + const { autoAddBoardId, autoAssignBoardOnClick } = gallery; + const { isProcessing } = system; + return { autoAddBoardId, autoAssignBoardOnClick, isProcessing }; }, defaultSelectorOptions ); const NoBoardBoard = memo(({ isSelected }: Props) => { const dispatch = useAppDispatch(); - const { autoAddBoardId } = useAppSelector(selector); - const boardName = useBoardName(undefined); + const { autoAddBoardId, autoAssignBoardOnClick, isProcessing } = + useAppSelector(selector); + const boardName = useBoardName('none'); const handleSelectBoard = useCallback(() => { - dispatch(boardIdSelected(undefined)); - }, [dispatch]); + dispatch(boardIdSelected('none')); + if (autoAssignBoardOnClick && !isProcessing) { + dispatch(autoAddBoardIdChanged('none')); + } + }, [dispatch, autoAssignBoardOnClick, isProcessing]); const [isHovered, setIsHovered] = useState(false); + const handleMouseOver = useCallback(() => { setIsHovered(true); }, []); + const handleMouseOut = useCallback(() => { setIsHovered(false); }, []); - const droppableData: MoveBoardDropData = useMemo( + const droppableData: RemoveFromBoardDropData = useMemo( () => ({ id: 'no_board', - actionType: 'MOVE_BOARD', - context: { boardId: undefined }, + actionType: 'REMOVE_FROM_BOARD', }), [] ); @@ -64,7 +74,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { h: 'full', }} > - + {(ref) => ( { alignItems: 'center', }} > - {/* */} invoke-ai-logo { }} /> - {/* - - {totalImages}/{totalAssets} - - */} - {!autoAddBoardId && } + {autoAddBoardId === 'none' && } void; }; -const DeleteImageModal = (props: Props) => { +const DeleteBoardModal = (props: Props) => { const { boardToDelete, setBoardToDelete } = props; const { t } = useTranslation(); const canRestoreDeletedImagesFromBin = useAppSelector( @@ -49,13 +49,10 @@ const DeleteImageModal = (props: Props) => { ); const imageUsageSummary: ImageUsage = { - isInitialImage: some(allImageUsage, (usage) => usage.isInitialImage), - isCanvasImage: some(allImageUsage, (usage) => usage.isCanvasImage), - isNodesImage: some(allImageUsage, (usage) => usage.isNodesImage), - isControlNetImage: some( - allImageUsage, - (usage) => usage.isControlNetImage - ), + isInitialImage: some(allImageUsage, (i) => i.isInitialImage), + isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage), + isNodesImage: some(allImageUsage, (i) => i.isNodesImage), + isControlNetImage: some(allImageUsage, (i) => i.isControlNetImage), }; return { imageUsageSummary }; }), @@ -176,4 +173,4 @@ const DeleteImageModal = (props: Props) => { ); }; -export default memo(DeleteImageModal); +export default memo(DeleteBoardModal); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/UpdateImageBoardModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/UpdateImageBoardModal.tsx deleted file mode 100644 index 49eb1502f3..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/UpdateImageBoardModal.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Box, - Flex, - Spinner, - Text, -} from '@chakra-ui/react'; -import IAIButton from 'common/components/IAIButton'; - -import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; -import { memo, useContext, useRef, useState } from 'react'; -import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; -import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext'; - -const UpdateImageBoardModal = () => { - // const boards = useSelector(selectBoardsAll); - const { data: boards, isFetching } = useListAllBoardsQuery(); - const { isOpen, onClose, handleAddToBoard, image } = useContext( - AddImageToBoardContext - ); - const [selectedBoard, setSelectedBoard] = useState(); - - const cancelRef = useRef(null); - - const currentBoard = boards?.find( - (board) => board.board_id === image?.board_id - ); - - return ( - - - - - {currentBoard ? 'Move Image to Board' : 'Add Image to Board'} - - - - - - {currentBoard && ( - - Moving this image from{' '} - {currentBoard.board_name} to - - )} - {isFetching ? ( - - ) : ( - setSelectedBoard(v)} - value={selectedBoard} - data={(boards ?? []).map((board) => ({ - label: board.board_name, - value: board.board_id, - }))} - /> - )} - - - - - Cancel - { - if (selectedBoard) { - handleAddToBoard(selectedBoard); - } - }} - ml={3} - > - {currentBoard ? 'Move' : 'Add'} - - - - - - ); -}; - -export default memo(UpdateImageBoardModal); diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx index 7d25d6bc05..d62027769b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx @@ -9,16 +9,14 @@ import { MenuButton, MenuList, } from '@chakra-ui/react'; -// import { runESRGAN, runFacetool } from 'app/socketio/actions'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; - import { skipToken } from '@reduxjs/toolkit/dist/query'; import { useAppToaster } from 'app/components/Toaster'; import { upscaleRequested } from 'app/store/middleware/listenerMiddleware/listeners/upscaleRequested'; import { stateSelector } from 'app/store/store'; -import { DeleteImageButton } from 'features/imageDeletion/components/DeleteImageButton'; -import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; +import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton'; +import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import ParamUpscalePopover from 'features/parameters/components/Parameters/Upscale/ParamUpscaleSettings'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; @@ -109,13 +107,13 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { ); const { currentData: imageDTO } = useGetImageDTOQuery( - lastSelectedImage ?? skipToken + lastSelectedImage?.image_name ?? skipToken ); const { currentData: metadataData } = useGetImageMetadataQuery( debounceState.isPending() ? skipToken - : debouncedMetadataQueryArg ?? skipToken + : debouncedMetadataQueryArg?.image_name ?? skipToken ); const metadata = metadataData?.metadata; @@ -173,7 +171,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { if (!imageDTO) { return; } - dispatch(imageToDeleteSelected(imageDTO)); + dispatch(imagesToDeleteSelected([imageDTO])); }, [dispatch, imageDTO]); useHotkeys( diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx index fd7eaef46a..f78ee286ef 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx @@ -32,7 +32,7 @@ export const imagesSelector = createSelector( return { shouldShowImageDetails, shouldHidePreview, - imageName: lastSelectedImage, + imageName: lastSelectedImage?.image_name, progressImage, shouldShowProgressInViewer, shouldAntialiasProgressImage, @@ -57,8 +57,6 @@ const CurrentImagePreview = () => { const { handlePrevImage, handleNextImage, - prevImageId, - nextImageId, isOnLastImage, handleLoadMoreImages, areMoreImagesAvailable, @@ -70,7 +68,7 @@ const CurrentImagePreview = () => { () => { handlePrevImage(); }, - [prevImageId] + [handlePrevImage] ); useHotkeys( @@ -85,11 +83,11 @@ const CurrentImagePreview = () => { } }, [ - nextImageId, isOnLastImage, areMoreImagesAvailable, handleLoadMoreImages, isFetching, + handleNextImage, ] ); diff --git a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx index 21a580d9a9..5c32cc788e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx @@ -5,25 +5,35 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIIconButton from 'common/components/IAIIconButton'; import IAIPopover from 'common/components/IAIPopover'; -import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox'; import IAISlider from 'common/components/IAISlider'; +import IAISwitch from 'common/components/IAISwitch'; import { + autoAssignBoardOnClickChanged, setGalleryImageMinimumWidth, shouldAutoSwitchChanged, + shouldShowDeleteButtonChanged, } from 'features/gallery/store/gallerySlice'; -import { ChangeEvent } from 'react'; +import { ChangeEvent, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { FaWrench } from 'react-icons/fa'; import BoardAutoAddSelect from './Boards/BoardAutoAddSelect'; +import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox'; const selector = createSelector( [stateSelector], (state) => { - const { galleryImageMinimumWidth, shouldAutoSwitch } = state.gallery; + const { + galleryImageMinimumWidth, + shouldAutoSwitch, + autoAssignBoardOnClick, + shouldShowDeleteButton, + } = state.gallery; return { galleryImageMinimumWidth, shouldAutoSwitch, + autoAssignBoardOnClick, + shouldShowDeleteButton, }; }, defaultSelectorOptions @@ -33,12 +43,37 @@ const GallerySettingsPopover = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const { galleryImageMinimumWidth, shouldAutoSwitch } = - useAppSelector(selector); + const { + galleryImageMinimumWidth, + shouldAutoSwitch, + autoAssignBoardOnClick, + shouldShowDeleteButton, + } = useAppSelector(selector); - const handleChangeGalleryImageMinimumWidth = (v: number) => { - dispatch(setGalleryImageMinimumWidth(v)); - }; + const handleChangeGalleryImageMinimumWidth = useCallback( + (v: number) => { + dispatch(setGalleryImageMinimumWidth(v)); + }, + [dispatch] + ); + + const handleResetGalleryImageMinimumWidth = useCallback(() => { + dispatch(setGalleryImageMinimumWidth(64)); + }, [dispatch]); + + const handleChangeAutoSwitch = useCallback( + (e: ChangeEvent) => { + dispatch(shouldAutoSwitchChanged(e.target.checked)); + }, + [dispatch] + ); + + const handleChangeShowDeleteButton = useCallback( + (e: ChangeEvent) => { + dispatch(shouldShowDeleteButtonChanged(e.target.checked)); + }, + [dispatch] + ); return ( { /> } > - + { hideTooltip={true} label={t('gallery.galleryImageSize')} withReset - handleReset={() => dispatch(setGalleryImageMinimumWidth(64))} + handleReset={handleResetGalleryImageMinimumWidth} /> - + + ) => - dispatch(shouldAutoSwitchChanged(e.target.checked)) + dispatch(autoAssignBoardOnClickChanged(e.target.checked)) } /> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx index 565012a36f..ce8b4450a6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx @@ -4,28 +4,29 @@ import { MouseEvent, memo, useCallback } from 'react'; import { ImageDTO } from 'services/api/types'; import { menuListMotionProps } from 'theme/components/menu'; import SingleSelectionMenuItems from './SingleSelectionMenuItems'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useAppSelector } from 'app/store/storeHooks'; +import MultipleSelectionMenuItems from './MultipleSelectionMenuItems'; type Props = { imageDTO: ImageDTO | undefined; children: ContextMenuProps['children']; }; +const selector = createSelector( + [stateSelector], + ({ gallery }) => { + const selectionCount = gallery.selection.length; + + return { selectionCount }; + }, + defaultSelectorOptions +); + const ImageContextMenu = ({ imageDTO, children }: Props) => { - // const selector = useMemo( - // () => - // createSelector( - // [stateSelector], - // ({ gallery }) => { - // const selectionCount = gallery.selection.length; - - // return { selectionCount }; - // }, - // defaultSelectorOptions - // ), - // [] - // ); - - // const { selectionCount } = useAppSelector(selector); + const { selectionCount } = useAppSelector(selector); const skipEvent = useCallback((e: MouseEvent) => { e.preventDefault(); @@ -38,8 +39,24 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => { bg: 'transparent', _hover: { bg: 'transparent' }, }} - renderMenu={() => - imageDTO ? ( + renderMenu={() => { + if (!imageDTO) { + return null; + } + + if (selectionCount > 1) { + return ( + + + + ); + } + + return ( { > - ) : null - } + ); + }} > {children} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx index 62d2cb06f4..079fc43a4a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx @@ -1,30 +1,30 @@ import { MenuItem } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + imagesToChangeSelected, + isModalOpenChanged, +} from 'features/changeBoardModal/store/slice'; +import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { useCallback } from 'react'; -import { FaFolder, FaFolderPlus, FaTrash } from 'react-icons/fa'; +import { FaFolder, FaTrash } from 'react-icons/fa'; const MultipleSelectionMenuItems = () => { - const handleAddSelectionToBoard = useCallback(() => { - // TODO: add selection to board - }, []); + const dispatch = useAppDispatch(); + const selection = useAppSelector((state) => state.gallery.selection); + + const handleChangeBoard = useCallback(() => { + dispatch(imagesToChangeSelected(selection)); + dispatch(isModalOpenChanged(true)); + }, [dispatch, selection]); const handleDeleteSelection = useCallback(() => { - // TODO: delete all selected images - }, []); - - const handleAddSelectionToBatch = useCallback(() => { - // TODO: add selection to batch - }, []); + dispatch(imagesToDeleteSelected(selection)); + }, [dispatch, selection]); return ( <> - } onClickCapture={handleAddSelectionToBoard}> - Move Selection to Board - - } - onClickCapture={handleAddSelectionToBatch} - > - Add Selection to Batch + } onClickCapture={handleChangeBoard}> + Change Board { const { imageDTO } = props; - const selector = useMemo( - () => - createSelector( - [stateSelector], - ({ gallery }) => { - const isInBatch = gallery.batchImageNames.includes( - imageDTO.image_name - ); - - return { isInBatch }; - }, - defaultSelectorOptions - ), - [imageDTO.image_name] - ); - - const { isInBatch } = useAppSelector(selector); const dispatch = useAppDispatch(); const { t } = useTranslation(); const toaster = useAppToaster(); const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled; - const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled; - - const { onClickAddToBoard } = useContext(AddImageToBoardContext); const [debouncedMetadataQueryArg, debounceState] = useDebounce( imageDTO.image_name, @@ -92,14 +68,12 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { if (!imageDTO) { return; } - dispatch(imageToDeleteSelected(imageDTO)); + dispatch(imagesToDeleteSelected([imageDTO])); }, [dispatch, imageDTO]); const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); - const [removeFromBoard] = useRemoveImageFromBoardMutation(); - // Recall parameters handlers const handleRecallPrompt = useCallback(() => { recallBothPrompts( @@ -144,20 +118,10 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { recallAllParameters(metadata); }, [metadata, recallAllParameters]); - const handleAddToBoard = useCallback(() => { - onClickAddToBoard(imageDTO); - }, [imageDTO, onClickAddToBoard]); - - const handleRemoveFromBoard = useCallback(() => { - if (!imageDTO.board_id) { - return; - } - removeFromBoard({ imageDTO }); - }, [imageDTO, removeFromBoard]); - - const handleAddToBatch = useCallback(() => { - dispatch(imagesAddedToBatch([imageDTO.image_name])); - }, [dispatch, imageDTO.image_name]); + const handleChangeBoard = useCallback(() => { + dispatch(imagesToChangeSelected([imageDTO])); + dispatch(isModalOpenChanged(true)); + }, [dispatch, imageDTO]); const handleCopyImage = useCallback(() => { copyImageToClipboard(imageDTO.image_url); @@ -229,23 +193,9 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { {t('parameters.sendToUnifiedCanvas')} )} - {isBatchEnabled && ( - } - isDisabled={isInBatch} - onClickCapture={handleAddToBatch} - > - Add to Batch - - )} - } onClickCapture={handleAddToBoard}> - {imageDTO.board_id ? 'Change Board' : 'Add to Board'} + } onClickCapture={handleChangeBoard}> + Change Board - {imageDTO.board_id && ( - } onClickCapture={handleRemoveFromBoard}> - Remove from Board - - )} } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index 5b2072bfc4..f2ff2ad30b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -20,16 +20,14 @@ import BoardsList from './Boards/BoardsList/BoardsList'; import GalleryBoardName from './GalleryBoardName'; import GalleryPinButton from './GalleryPinButton'; import GallerySettingsPopover from './GallerySettingsPopover'; -import BatchImageGrid from './ImageGrid/BatchImageGrid'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; const selector = createSelector( [stateSelector], (state) => { - const { selectedBoardId, galleryView } = state.gallery; + const { galleryView } = state.gallery; return { - selectedBoardId, galleryView, }; }, @@ -39,7 +37,7 @@ const selector = createSelector( const ImageGalleryContent = () => { const resizeObserverRef = useRef(null); const galleryGridRef = useRef(null); - const { selectedBoardId, galleryView } = useAppSelector(selector); + const { galleryView } = useAppSelector(selector); const dispatch = useAppDispatch(); const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } = useDisclosure(); @@ -130,12 +128,7 @@ const ImageGalleryContent = () => { - - {selectedBoardId === 'batch' ? ( - - ) : ( - - )} + ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImage.tsx deleted file mode 100644 index 528e8cc06f..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImage.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Box } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; -import { stateSelector } from 'app/store/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import IAIDndImage from 'common/components/IAIDndImage'; -import IAIErrorLoadingImageFallback from 'common/components/IAIErrorLoadingImageFallback'; -import IAIFillSkeleton from 'common/components/IAIFillSkeleton'; -import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; -import { imagesRemovedFromBatch } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; - -const makeSelector = (image_name: string) => - createSelector( - [stateSelector], - (state) => ({ - selectionCount: state.gallery.selection.length, - selection: state.gallery.selection, - isSelected: state.gallery.selection.includes(image_name), - }), - defaultSelectorOptions - ); - -type BatchImageProps = { - imageName: string; -}; - -const BatchImage = (props: BatchImageProps) => { - const dispatch = useAppDispatch(); - const { imageName } = props; - const { - currentData: imageDTO, - isLoading, - isError, - } = useGetImageDTOQuery(imageName); - const selector = useMemo(() => makeSelector(imageName), [imageName]); - - const { isSelected, selectionCount, selection } = useAppSelector(selector); - - const handleClickRemove = useCallback(() => { - dispatch(imagesRemovedFromBatch([imageName])); - }, [dispatch, imageName]); - - // const handleClick = useCallback( - // (e: MouseEvent) => { - // if (e.shiftKey) { - // dispatch(imageRangeEndSelected(imageName)); - // } else if (e.ctrlKey || e.metaKey) { - // dispatch(imageSelectionToggled(imageName)); - // } else { - // dispatch(imageSelected(imageName)); - // } - // }, - // [dispatch, imageName] - // ); - - const draggableData = useMemo(() => { - if (selectionCount > 1) { - return { - id: 'batch', - payloadType: 'IMAGE_NAMES', - payload: { image_names: selection }, - }; - } - - if (imageDTO) { - return { - id: 'batch', - payloadType: 'IMAGE_DTO', - payload: { imageDTO }, - }; - } - }, [imageDTO, selection, selectionCount]); - - if (isLoading) { - return ; - } - - if (isError || !imageDTO) { - return ; - } - - return ( - - - {(ref) => ( - - - - )} - - - ); -}; - -export default memo(BatchImage); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImageGrid.tsx deleted file mode 100644 index feaa47403d..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/BatchImageGrid.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Box } from '@chakra-ui/react'; -import { useAppSelector } from 'app/store/storeHooks'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; - -import { memo, useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FaImage } from 'react-icons/fa'; - -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { VirtuosoGrid } from 'react-virtuoso'; -import BatchImage from './BatchImage'; -import ItemContainer from './ImageGridItemContainer'; -import ListContainer from './ImageGridListContainer'; - -const selector = createSelector( - [stateSelector], - (state) => { - return { - imageNames: state.gallery.batchImageNames, - }; - }, - defaultSelectorOptions -); - -const BatchImageGrid = () => { - const { t } = useTranslation(); - const rootRef = useRef(null); - const [scroller, setScroller] = useState(null); - const [initialize, osInstance] = useOverlayScrollbars({ - defer: true, - options: { - scrollbars: { - visibility: 'auto', - autoHide: 'leave', - autoHideDelay: 1300, - theme: 'os-theme-dark', - }, - overflow: { x: 'hidden' }, - }, - }); - - const { imageNames } = useAppSelector(selector); - - useEffect(() => { - const { current: root } = rootRef; - if (scroller && root) { - initialize({ - target: root, - elements: { - viewport: scroller, - }, - }); - } - return () => osInstance()?.destroy(); - }, [scroller, initialize, osInstance]); - - if (imageNames.length) { - return ( - - ( - - )} - /> - - ); - } - - return ( - - ); -}; - -export default memo(BatchImageGrid); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 6a5d28a9ba..c9eee5f1f5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,27 +1,18 @@ import { Box, Flex } from '@chakra-ui/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; -import { stateSelector } from 'app/store/store'; +import { + ImageDTOsDraggableData, + ImageDraggableData, + TypesafeDraggableData, +} from 'app/components/ImageDnd/typesafeDnd'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIDndImage from 'common/components/IAIDndImage'; import IAIFillSkeleton from 'common/components/IAIFillSkeleton'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; +import { useMultiselect } from 'features/gallery/hooks/useMultiselect.ts'; +import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { MouseEvent, memo, useCallback, useMemo } from 'react'; +import { FaTrash } from 'react-icons/fa'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -export const makeSelector = (image_name: string) => - createSelector( - [stateSelector], - ({ gallery }) => ({ - isSelected: gallery.selection.includes(image_name), - selectionCount: gallery.selection.length, - selection: gallery.selection, - }), - defaultSelectorOptions - ); - interface HoverableImageProps { imageName: string; } @@ -30,22 +21,12 @@ const GalleryImage = (props: HoverableImageProps) => { const dispatch = useAppDispatch(); const { imageName } = props; const { currentData: imageDTO } = useGetImageDTOQuery(imageName); - const localSelector = useMemo(() => makeSelector(imageName), [imageName]); + const shouldShowDeleteButton = useAppSelector( + (state) => state.gallery.shouldShowDeleteButton + ); - const { isSelected, selectionCount, selection } = - useAppSelector(localSelector); - - const handleClick = useCallback(() => { - // disable multiselect for now - // if (e.shiftKey) { - // dispatch(imageRangeEndSelected(imageName)); - // } else if (e.ctrlKey || e.metaKey) { - // dispatch(imageSelectionToggled(imageName)); - // } else { - // dispatch(imageSelected(imageName)); - // } - dispatch(imageSelected(imageName)); - }, [dispatch, imageName]); + const { handleClick, isSelected, selection, selectionCount } = + useMultiselect(imageDTO); const handleDelete = useCallback( (e: MouseEvent) => { @@ -53,26 +34,28 @@ const GalleryImage = (props: HoverableImageProps) => { if (!imageDTO) { return; } - dispatch(imageToDeleteSelected(imageDTO)); + dispatch(imagesToDeleteSelected([imageDTO])); }, [dispatch, imageDTO] ); const draggableData = useMemo(() => { if (selectionCount > 1) { - return { + const data: ImageDTOsDraggableData = { id: 'gallery-image', - payloadType: 'IMAGE_NAMES', - payload: { image_names: selection }, + payloadType: 'IMAGE_DTOS', + payload: { imageDTOs: selection }, }; + return data; } if (imageDTO) { - return { + const data: ImageDraggableData = { id: 'gallery-image', payloadType: 'IMAGE_DTO', payload: { imageDTO }, }; + return data; } }, [imageDTO, selection, selectionCount]); @@ -103,9 +86,9 @@ const GalleryImage = (props: HoverableImageProps) => { isUploadDisabled={true} thumbnail={true} withHoverOverlay - // resetIcon={} - // resetTooltip="Delete image" - // withResetIcon // removed bc it's too easy to accidentally delete images + resetIcon={} + resetTooltip="Delete image" + withResetIcon={shouldShowDeleteButton} // removed bc it's too easy to accidentally delete images /> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx index df574c860b..c0821c2226 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataActions.tsx @@ -1,6 +1,6 @@ import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { useCallback } from 'react'; -import { UnsafeImageMetadata } from 'services/api/endpoints/images'; +import { UnsafeImageMetadata } from 'services/api/types'; import ImageMetadataItem from './ImageMetadataItem'; type Props = { diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts.ts b/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts.ts new file mode 100644 index 0000000000..b59a2f3d6f --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts.ts @@ -0,0 +1,93 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { uniq } from 'lodash-es'; +import { MouseEvent, useCallback, useMemo } from 'react'; +import { useListImagesQuery } from 'services/api/endpoints/images'; +import { ImageDTO } from 'services/api/types'; +import { selectionChanged } from '../store/gallerySlice'; +import { imagesSelectors } from 'services/api/util'; + +const selector = createSelector( + [stateSelector, selectListImagesBaseQueryArgs], + ({ gallery }, queryArgs) => { + const selection = gallery.selection; + + return { + queryArgs, + selection, + }; + }, + defaultSelectorOptions +); + +export const useMultiselect = (imageDTO?: ImageDTO) => { + const dispatch = useAppDispatch(); + const { queryArgs, selection } = useAppSelector(selector); + + const { imageDTOs } = useListImagesQuery(queryArgs, { + selectFromResult: (result) => ({ + imageDTOs: result.data ? imagesSelectors.selectAll(result.data) : [], + }), + }); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (!imageDTO) { + return; + } + if (e.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 + ); + 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(uniq(selection.concat(imagesToSelect)))); + } + } else if (e.ctrlKey || e.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) + ) + ); + } else { + dispatch(selectionChanged(uniq(selection.concat(imageDTO)))); + } + } else { + dispatch(selectionChanged([imageDTO])); + } + }, + [dispatch, imageDTO, imageDTOs, selection] + ); + + const isSelected = useMemo( + () => + imageDTO + ? selection.some((i) => i.image_name === imageDTO.image_name) + : false, + [imageDTO, selection] + ); + + const selectionCount = useMemo(() => selection.length, [selection.length]); + + return { + selection, + selectionCount, + isSelected, + handleClick, + }; +}; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts b/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts index f2572a23b5..670dd7ee9f 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts @@ -4,14 +4,15 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { imageSelected } from 'features/gallery/store/gallerySlice'; import { clamp, isEqual } from 'lodash-es'; import { useCallback } from 'react'; +import { boardsApi } from 'services/api/endpoints/boards'; import { - ListImagesArgs, - imagesAdapter, imagesApi, useLazyListImagesQuery, } from 'services/api/endpoints/images'; import { selectListImagesBaseQueryArgs } from '../store/gallerySelectors'; import { IMAGE_LIMIT } from '../store/types'; +import { ListImagesArgs } from 'services/api/types'; +import { imagesAdapter } from 'services/api/util'; export const nextPrevImageButtonsSelector = createSelector( [stateSelector, selectListImagesBaseQueryArgs], @@ -19,12 +20,21 @@ export const nextPrevImageButtonsSelector = createSelector( const { data, status } = imagesApi.endpoints.listImages.select(baseQueryArgs)(state); + const { data: total } = + state.gallery.galleryView === 'images' + ? boardsApi.endpoints.getBoardImagesTotal.select( + baseQueryArgs.board_id ?? 'none' + )(state) + : boardsApi.endpoints.getBoardAssetsTotal.select( + baseQueryArgs.board_id ?? 'none' + )(state); + const lastSelectedImage = state.gallery.selection[state.gallery.selection.length - 1]; const isFetching = status === 'pending'; - if (!data || !lastSelectedImage || data.total === 0) { + if (!data || !lastSelectedImage || total === 0) { return { isFetching, queryArgs: baseQueryArgs, @@ -44,30 +54,30 @@ export const nextPrevImageButtonsSelector = createSelector( const images = selectors.selectAll(data); const currentImageIndex = images.findIndex( - (i) => i.image_name === lastSelectedImage + (i) => i.image_name === lastSelectedImage.image_name ); const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1); - const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1); const nextImageId = images[nextImageIndex]?.image_name; const prevImageId = images[prevImageIndex]?.image_name; - const nextImage = selectors.selectById(data, nextImageId); - const prevImage = selectors.selectById(data, prevImageId); + const nextImage = nextImageId + ? selectors.selectById(data, nextImageId) + : undefined; + const prevImage = prevImageId + ? selectors.selectById(data, prevImageId) + : undefined; const imagesLength = images.length; return { - isOnFirstImage: currentImageIndex === 0, - isOnLastImage: - !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, - areMoreImagesAvailable: (data?.total ?? 0) > imagesLength, + loadedImagesCount: images.length, + currentImageIndex, + areMoreImagesAvailable: (total ?? 0) > imagesLength, isFetching: status === 'pending', nextImage, prevImage, - nextImageId, - prevImageId, queryArgs, }; }, @@ -82,22 +92,22 @@ export const useNextPrevImage = () => { const dispatch = useAppDispatch(); const { - isOnFirstImage, - isOnLastImage, - nextImageId, - prevImageId, + nextImage, + prevImage, areMoreImagesAvailable, isFetching, queryArgs, + loadedImagesCount, + currentImageIndex, } = useAppSelector(nextPrevImageButtonsSelector); const handlePrevImage = useCallback(() => { - prevImageId && dispatch(imageSelected(prevImageId)); - }, [dispatch, prevImageId]); + prevImage && dispatch(imageSelected(prevImage)); + }, [dispatch, prevImage]); const handleNextImage = useCallback(() => { - nextImageId && dispatch(imageSelected(nextImageId)); - }, [dispatch, nextImageId]); + nextImage && dispatch(imageSelected(nextImage)); + }, [dispatch, nextImage]); const [listImages] = useLazyListImagesQuery(); @@ -108,10 +118,12 @@ export const useNextPrevImage = () => { return { handlePrevImage, handleNextImage, - isOnFirstImage, - isOnLastImage, - nextImageId, - prevImageId, + isOnFirstImage: currentImageIndex === 0, + isOnLastImage: + currentImageIndex !== undefined && + currentImageIndex === loadedImagesCount - 1, + nextImage, + prevImage, areMoreImagesAvailable, handleLoadMoreImages, isFetching, diff --git a/invokeai/frontend/web/src/features/gallery/store/actions.ts b/invokeai/frontend/web/src/features/gallery/store/actions.ts index 0e1b1ef2a0..9368fe6cf6 100644 --- a/invokeai/frontend/web/src/features/gallery/store/actions.ts +++ b/invokeai/frontend/web/src/features/gallery/store/actions.ts @@ -1,5 +1,5 @@ import { createAction } from '@reduxjs/toolkit'; -import { ImageUsage } from 'app/contexts/AddImageToBoardContext'; +import { ImageUsage } from 'features/deleteImageModal/store/types'; import { BoardDTO } from 'services/api/types'; export type RequestedBoardImagesDeletionArg = { diff --git a/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts b/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts deleted file mode 100644 index ad43498e51..0000000000 --- a/invokeai/frontend/web/src/features/gallery/store/boardSlice.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PayloadAction, createSlice } from '@reduxjs/toolkit'; - -type BoardsState = { - searchText: string; - updateBoardModalOpen: boolean; -}; - -export const initialBoardsState: BoardsState = { - updateBoardModalOpen: false, - searchText: '', -}; - -const boardsSlice = createSlice({ - name: 'boards', - initialState: initialBoardsState, - reducers: { - setBoardSearchText: (state, action: PayloadAction) => { - state.searchText = action.payload; - }, - setUpdateBoardModalOpen: (state, action: PayloadAction) => { - state.updateBoardModalOpen = action.payload; - }, - }, -}); - -export const { setBoardSearchText, setUpdateBoardModalOpen } = - boardsSlice.actions; - -export default boardsSlice.reducer; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index b589550157..47e29456a0 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from 'app/store/store'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { ListImagesArgs } from 'services/api/endpoints/images'; +import { ListImagesArgs } from 'services/api/types'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES, @@ -24,7 +24,7 @@ export const selectListImagesBaseQueryArgs = createSelector( galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; const listImagesBaseQueryArgs: ListImagesArgs = { - board_id: selectedBoardId ?? 'none', + board_id: selectedBoardId, categories, offset: 0, limit: INITIAL_IMAGE_LIMIT, diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 5eabe5de26..3b0dd233f1 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -1,122 +1,67 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice, isAnyOf } from '@reduxjs/toolkit'; -import { uniq } from 'lodash-es'; import { boardsApi } from 'services/api/endpoints/boards'; +import { imagesApi } from 'services/api/endpoints/images'; +import { ImageDTO } from 'services/api/types'; import { BoardId, GalleryState, GalleryView } from './types'; export const initialGalleryState: GalleryState = { selection: [], shouldAutoSwitch: true, - autoAddBoardId: undefined, + autoAssignBoardOnClick: true, + autoAddBoardId: 'none', galleryImageMinimumWidth: 96, - selectedBoardId: undefined, + selectedBoardId: 'none', galleryView: 'images', - batchImageNames: [], - isBatchEnabled: false, + shouldShowDeleteButton: false, + boardSearchText: '', }; export const gallerySlice = createSlice({ name: 'gallery', initialState: initialGalleryState, reducers: { - imageRangeEndSelected: () => { - // TODO - }, - // imageRangeEndSelected: (state, action: PayloadAction) => { - // const rangeEndImageName = action.payload; - // const lastSelectedImage = state.selection[state.selection.length - 1]; - // const filteredImages = selectFilteredImagesLocal(state); - // const lastClickedIndex = filteredImages.findIndex( - // (n) => n.image_name === lastSelectedImage - // ); - // const currentClickedIndex = filteredImages.findIndex( - // (n) => n.image_name === rangeEndImageName - // ); - // if (lastClickedIndex > -1 && currentClickedIndex > -1) { - // // We have a valid range! - // const start = Math.min(lastClickedIndex, currentClickedIndex); - // const end = Math.max(lastClickedIndex, currentClickedIndex); - // const imagesToSelect = filteredImages - // .slice(start, end + 1) - // .map((i) => i.image_name); - // state.selection = uniq(state.selection.concat(imagesToSelect)); - // } - // }, - imageSelectionToggled: () => { - // TODO - }, - // imageSelectionToggled: (state, action: PayloadAction) => { - // TODO: multiselect - // if ( - // state.selection.includes(action.payload) && - // state.selection.length > 1 - // ) { - // state.selection = state.selection.filter( - // (imageName) => imageName !== action.payload - // ); - // } else { - // state.selection = uniq(state.selection.concat(action.payload)); - // } - imageSelected: (state, action: PayloadAction) => { + imageSelected: (state, action: PayloadAction) => { state.selection = action.payload ? [action.payload] : []; }, + selectionChanged: (state, action: PayloadAction) => { + state.selection = action.payload; + }, shouldAutoSwitchChanged: (state, action: PayloadAction) => { state.shouldAutoSwitch = action.payload; }, setGalleryImageMinimumWidth: (state, action: PayloadAction) => { state.galleryImageMinimumWidth = action.payload; }, + autoAssignBoardOnClickChanged: (state, action: PayloadAction) => { + state.autoAssignBoardOnClick = action.payload; + }, boardIdSelected: (state, action: PayloadAction) => { state.selectedBoardId = action.payload; state.galleryView = 'images'; }, - isBatchEnabledChanged: (state, action: PayloadAction) => { - state.isBatchEnabled = action.payload; - }, - imagesAddedToBatch: (state, action: PayloadAction) => { - state.batchImageNames = uniq( - state.batchImageNames.concat(action.payload) - ); - }, - imagesRemovedFromBatch: (state, action: PayloadAction) => { - state.batchImageNames = state.batchImageNames.filter( - (imageName) => !action.payload.includes(imageName) - ); - - const newSelection = state.selection.filter( - (imageName) => !action.payload.includes(imageName) - ); - - if (newSelection.length) { - state.selection = newSelection; - return; - } - - state.selection = [state.batchImageNames[0]] ?? []; - }, - batchReset: (state) => { - state.batchImageNames = []; - state.selection = []; - }, - autoAddBoardIdChanged: ( - state, - action: PayloadAction - ) => { + autoAddBoardIdChanged: (state, action: PayloadAction) => { state.autoAddBoardId = action.payload; }, galleryViewChanged: (state, action: PayloadAction) => { state.galleryView = action.payload; }, + shouldShowDeleteButtonChanged: (state, action: PayloadAction) => { + state.shouldShowDeleteButton = action.payload; + }, + boardSearchTextChanged: (state, action: PayloadAction) => { + state.boardSearchText = action.payload; + }, }, extraReducers: (builder) => { builder.addMatcher(isAnyBoardDeleted, (state, action) => { const deletedBoardId = action.meta.arg.originalArgs; if (deletedBoardId === state.selectedBoardId) { - state.selectedBoardId = undefined; + state.selectedBoardId = 'none'; state.galleryView = 'images'; } if (deletedBoardId === state.autoAddBoardId) { - state.autoAddBoardId = undefined; + state.autoAddBoardId = 'none'; } }); builder.addMatcher( @@ -128,7 +73,7 @@ export const gallerySlice = createSlice({ } if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) { - state.autoAddBoardId = undefined; + state.autoAddBoardId = 'none'; } } ); @@ -136,22 +81,21 @@ export const gallerySlice = createSlice({ }); export const { - imageRangeEndSelected, - imageSelectionToggled, imageSelected, shouldAutoSwitchChanged, + autoAssignBoardOnClickChanged, setGalleryImageMinimumWidth, boardIdSelected, - isBatchEnabledChanged, - imagesAddedToBatch, - imagesRemovedFromBatch, autoAddBoardIdChanged, galleryViewChanged, + selectionChanged, + shouldShowDeleteButtonChanged, + boardSearchTextChanged, } = gallerySlice.actions; export default gallerySlice.reducer; const isAnyBoardDeleted = isAnyOf( - boardsApi.endpoints.deleteBoard.matchFulfilled, - boardsApi.endpoints.deleteBoardAndImages.matchFulfilled + imagesApi.endpoints.deleteBoard.matchFulfilled, + imagesApi.endpoints.deleteBoardAndImages.matchFulfilled ); diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index d19a6fded3..6860f6ea7b 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -1,4 +1,4 @@ -import { ImageCategory } from 'services/api/types'; +import { ImageCategory, ImageDTO } from 'services/api/types'; export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; export const ASSETS_CATEGORIES: ImageCategory[] = [ @@ -11,16 +11,16 @@ export const INITIAL_IMAGE_LIMIT = 100; export const IMAGE_LIMIT = 20; export type GalleryView = 'images' | 'assets'; -// export type BoardId = 'no_board' | (string & Record); -export type BoardId = string | undefined; +export type BoardId = 'none' | (string & Record); export type GalleryState = { - selection: string[]; + selection: ImageDTO[]; shouldAutoSwitch: boolean; - autoAddBoardId: string | undefined; + autoAssignBoardOnClick: boolean; + autoAddBoardId: BoardId; galleryImageMinimumWidth: number; selectedBoardId: BoardId; galleryView: GalleryView; - batchImageNames: string[]; - isBatchEnabled: boolean; + shouldShowDeleteButton: boolean; + boardSearchText: string; }; diff --git a/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts b/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts deleted file mode 100644 index 0bfd9a537d..0000000000 --- a/invokeai/frontend/web/src/features/imageDeletion/store/imageDeletionSlice.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import { ImageDTO } from 'services/api/types'; - -type DeleteImageState = { - imageToDelete: ImageDTO | null; - isModalOpen: boolean; -}; - -export const initialDeleteImageState: DeleteImageState = { - imageToDelete: null, - isModalOpen: false, -}; - -const imageDeletion = createSlice({ - name: 'imageDeletion', - initialState: initialDeleteImageState, - reducers: { - isModalOpenChanged: (state, action: PayloadAction) => { - state.isModalOpen = action.payload; - }, - imageToDeleteSelected: (state, action: PayloadAction) => { - state.imageToDelete = action.payload; - }, - imageToDeleteCleared: (state) => { - state.imageToDelete = null; - state.isModalOpen = false; - }, - }, -}); - -export const { - isModalOpenChanged, - imageToDeleteSelected, - imageToDeleteCleared, -} = imageDeletion.actions; - -export default imageDeletion.reducer; diff --git a/invokeai/frontend/web/src/features/imageDeletion/store/types.ts b/invokeai/frontend/web/src/features/imageDeletion/store/types.ts deleted file mode 100644 index b3f4dc9c8d..0000000000 --- a/invokeai/frontend/web/src/features/imageDeletion/store/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type ImageUsage = { - isInitialImage: boolean; - isCanvasImage: boolean; - isNodesImage: boolean; - isControlNetImage: boolean; -}; diff --git a/invokeai/frontend/web/src/features/lora/store/loraSlice.ts b/invokeai/frontend/web/src/features/lora/store/loraSlice.ts index f0067a85a2..10a1671933 100644 --- a/invokeai/frontend/web/src/features/lora/store/loraSlice.ts +++ b/invokeai/frontend/web/src/features/lora/store/loraSlice.ts @@ -39,11 +39,19 @@ export const loraSlice = createSlice({ action: PayloadAction<{ id: string; weight: number }> ) => { const { id, weight } = action.payload; - state.loras[id].weight = weight; + const lora = state.loras[id]; + if (!lora) { + return; + } + lora.weight = weight; }, loraWeightReset: (state, action: PayloadAction) => { const id = action.payload; - state.loras[id].weight = defaultLoRAConfig.weight; + const lora = state.loras[id]; + if (!lora) { + return; + } + lora.weight = defaultLoRAConfig.weight; }, }, }); diff --git a/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx b/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx index 669110fa54..d4a4f8d31f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx @@ -170,15 +170,17 @@ const NodeSearch = () => { // } if (key === 'Enter') { - let selectedNodeType: AnyInvocationType; + let selectedNodeType: AnyInvocationType | undefined; if (searchText.length > 0) { - selectedNodeType = filteredNodes[focusedIndex].item.type; + selectedNodeType = filteredNodes[focusedIndex]?.item.type; } else { - selectedNodeType = nodes[focusedIndex].type; + selectedNodeType = nodes[focusedIndex]?.type; } - addNode(selectedNodeType); + if (selectedNodeType) { + addNode(selectedNodeType); + } setShowNodeList(false); } diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 436396fb38..2e41081e95 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -79,9 +79,12 @@ const nodesSlice = createSlice({ ) => { const { nodeId, fieldName, value } = action.payload; const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId); - + const input = state.nodes?.[nodeIndex]?.data?.inputs[fieldName]; + if (!input) { + return; + } if (nodeIndex > -1) { - state.nodes[nodeIndex].data.inputs[fieldName].value = value; + input.value = value; } }, imageCollectionFieldValueChanged: ( @@ -99,16 +102,19 @@ const nodesSlice = createSlice({ return; } - const currentValue = cloneDeep( - state.nodes[nodeIndex].data.inputs[fieldName].value - ); - - if (!currentValue) { - state.nodes[nodeIndex].data.inputs[fieldName].value = value; + const input = state.nodes?.[nodeIndex]?.data?.inputs[fieldName]; + if (!input) { return; } - state.nodes[nodeIndex].data.inputs[fieldName].value = uniqBy( + const currentValue = cloneDeep(input.value); + + if (!currentValue) { + input.value = value; + return; + } + + input.value = uniqBy( (currentValue as ImageField[]).concat(value), 'image_name' ); diff --git a/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts b/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts index 83692533f7..de7d798c69 100644 --- a/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts +++ b/invokeai/frontend/web/src/features/nodes/util/fieldTemplateBuilders.ts @@ -29,6 +29,8 @@ import { VaeInputFieldTemplate, VaeModelInputFieldTemplate, } from '../types/types'; +import { logger } from 'app/logging/logger'; +import { parseify } from 'common/util/serialize'; export type BaseFieldProperties = 'name' | 'title' | 'description'; @@ -50,7 +52,13 @@ export type BuildInputFieldArg = { */ export const refObjectToFieldType = ( refObject: OpenAPIV3.ReferenceObject -): keyof typeof FIELD_TYPE_MAP => refObject.$ref.split('/').slice(-1)[0]; +): keyof typeof FIELD_TYPE_MAP => { + const name = refObject.$ref.split('/').slice(-1)[0]; + if (!name) { + return 'UNKNOWN FIELD TYPE'; + } + return name; +}; const buildIntegerInputFieldTemplate = ({ schemaObject, @@ -428,7 +436,7 @@ export const getFieldType = ( let rawFieldType = ''; if (typeHints && name in typeHints) { - rawFieldType = typeHints[name]; + rawFieldType = typeHints[name] ?? 'UNKNOWN FIELD TYPE'; } else if (!schemaObject.type) { // if schemaObject has no type, then it should have one of allOf, anyOf, oneOf if (schemaObject.allOf) { @@ -568,10 +576,23 @@ export const buildOutputFieldTemplates = ( // extract output schema name from ref const outputSchemaName = refObject.$ref.split('/').slice(-1)[0]; + if (!outputSchemaName) { + logger('nodes').error( + { refObject: parseify(refObject) }, + 'No output schema name found in ref object' + ); + throw 'No output schema name found in ref object'; + } + // get the output schema itself // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const outputSchema = openAPI.components!.schemas![outputSchemaName]; + if (!outputSchema) { + logger('nodes').error({ outputSchemaName }, 'Output schema not found'); + throw 'Output schema not found'; + } + if (isSchemaObject(outputSchema)) { const outputFields = reduce( outputSchema.properties as OpenAPIV3.SchemaObject, diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse.tsx index 418ed9278f..c4d2d35f8f 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse.tsx @@ -16,7 +16,10 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { map } from 'lodash-es'; import { Fragment, memo, useCallback } from 'react'; import { FaPlus } from 'react-icons/fa'; -import { useGetControlNetModelsQuery } from 'services/api/endpoints/models'; +import { + controlNetModelsAdapter, + useGetControlNetModelsQuery, +} from 'services/api/endpoints/models'; import { v4 as uuidv4 } from 'uuid'; const selector = createSelector( @@ -42,7 +45,9 @@ const ParamControlNetCollapse = () => { const dispatch = useAppDispatch(); const { firstModel } = useGetControlNetModelsQuery(undefined, { selectFromResult: (result) => { - const firstModel = result.data?.entities[result.data?.ids[0]]; + const firstModel = result.data + ? controlNetModelsAdapter.getSelectors().selectAll(result.data)[0] + : undefined; return { firstModel, }; @@ -95,7 +100,7 @@ const ParamControlNetCollapse = () => { {controlNetsArray.map((c, i) => ( {i > 0 && } - + ))} diff --git a/invokeai/frontend/web/src/features/parameters/hooks/useRecallParameters.ts b/invokeai/frontend/web/src/features/parameters/hooks/useRecallParameters.ts index cb2361524d..907107e95e 100644 --- a/invokeai/frontend/web/src/features/parameters/hooks/useRecallParameters.ts +++ b/invokeai/frontend/web/src/features/parameters/hooks/useRecallParameters.ts @@ -12,7 +12,7 @@ import { } from 'features/sdxl/store/sdxlSlice'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { UnsafeImageMetadata } from 'services/api/endpoints/images'; +import { UnsafeImageMetadata } from 'services/api/types'; import { ImageDTO } from 'services/api/types'; import { initialImageSelected, modelSelected } from '../store/actions'; import { diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddCheckpoint.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddCheckpoint.tsx index fd5106b289..5f82483cd3 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddCheckpoint.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddCheckpoint.tsx @@ -28,9 +28,7 @@ export default function AdvancedAddCheckpoint( const advancedAddCheckpointForm = useForm({ initialValues: { - model_name: model_path - ? model_path.split('\\').splice(-1)[0].split('.')[0] - : '', + model_name: model_path?.split('\\').splice(-1)[0]?.split('.')[0] ?? '', base_model: 'sd-1', model_type: 'main', path: model_path ? model_path : '', diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddDiffusers.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddDiffusers.tsx index 376631bd1f..ec2d3f037a 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddDiffusers.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/AddModelsPanel/AdvancedAddDiffusers.tsx @@ -25,7 +25,7 @@ export default function AdvancedAddDiffusers(props: AdvancedAddDiffusersProps) { const advancedAddDiffusersForm = useForm({ initialValues: { - model_name: model_path ? model_path.split('\\').splice(-1)[0] : '', + model_name: model_path?.split('\\').splice(-1)[0] ?? '', base_model: 'sd-1', model_type: 'main', path: model_path ? model_path : '', diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/MergeModelsPanel.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/MergeModelsPanel.tsx index 4ad8fbaba6..6837a2e853 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/MergeModelsPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/MergeModelsPanel.tsx @@ -59,10 +59,10 @@ export default function MergeModelsPanel() { }, [sd1DiffusersModels, sd2DiffusersModels]); const [modelOne, setModelOne] = useState( - Object.keys(modelsMap[baseModel as keyof typeof modelsMap])[0] + Object.keys(modelsMap[baseModel as keyof typeof modelsMap])?.[0] ?? null ); const [modelTwo, setModelTwo] = useState( - Object.keys(modelsMap[baseModel as keyof typeof modelsMap])[1] + Object.keys(modelsMap[baseModel as keyof typeof modelsMap])?.[1] ?? null ); const [modelThree, setModelThree] = useState(null); @@ -106,8 +106,9 @@ export default function MergeModelsPanel() { let modelsToMerge: (string | null)[] = [modelOne, modelTwo, modelThree]; modelsToMerge = modelsToMerge.filter((model) => model !== null); modelsToMerge.forEach((model) => { - if (model) { - models_names.push(model?.split('/')[2]); + const n = model?.split('/')?.[2]; + if (n) { + models_names.push(n); } }); diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelConvert.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelConvert.tsx index 1aec7d5c05..045745e206 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelConvert.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelConvert.tsx @@ -47,13 +47,11 @@ export default function ModelConvert(props: ModelConvertProps) { }; const modelConvertHandler = () => { - const responseBody = { + const queryArg = { base_model: model.base_model, model_name: model.model_name, - params: { - convert_dest_directory: - saveLocation === 'Custom' ? customSaveLocation : undefined, - }, + convert_dest_directory: + saveLocation === 'Custom' ? customSaveLocation : undefined, }; if (saveLocation === 'Custom' && customSaveLocation === '') { @@ -74,14 +72,14 @@ export default function ModelConvert(props: ModelConvertProps) { title: `${t('modelManager.convertingModelBegin')}: ${ model.model_name }`, - status: 'success', + status: 'info', }) ) ); - convertModel(responseBody) + convertModel(queryArg) .unwrap() - .then((_) => { + .then(() => { dispatch( addToast( makeToast({ @@ -91,7 +89,7 @@ export default function ModelConvert(props: ModelConvertProps) { ) ); }) - .catch((_) => { + .catch(() => { dispatch( addToast( makeToast({ diff --git a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts index fa152e9ce5..5427fa9d3b 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts @@ -2,12 +2,12 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from 'app/store/store'; import { isEqual } from 'lodash-es'; -import { tabMap } from './tabMap'; +import { InvokeTabName, tabMap } from './tabMap'; import { UIState } from './uiTypes'; export const activeTabNameSelector = createSelector( (state: RootState) => state.ui, - (ui: UIState) => tabMap[ui.activeTab], + (ui: UIState) => tabMap[ui.activeTab] as InvokeTabName, { memoizeOptions: { equalityCheck: isEqual, diff --git a/invokeai/frontend/web/src/services/api/client.ts b/invokeai/frontend/web/src/services/api/client.ts index dd4caa460e..87deda7d36 100644 --- a/invokeai/frontend/web/src/services/api/client.ts +++ b/invokeai/frontend/web/src/services/api/client.ts @@ -16,6 +16,11 @@ export const $authToken = atom(); */ export const $baseUrl = atom(); +/** + * The optional project-id header. + */ +export const $projectId = atom(); + /** * Autogenerated, type-safe fetch client for the API. Used when RTK Query is not an option. * Dynamically updates when the token or base url changes. @@ -24,9 +29,12 @@ export const $baseUrl = atom(); * @example * const { get, post, del } = $client.get(); */ -export const $client = computed([$authToken, $baseUrl], (authToken, baseUrl) => +export const $client = computed([$authToken, $baseUrl, $projectId], (authToken, baseUrl, projectId) => createClient({ - headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + headers: { + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + ...(projectId ? { "project-id": projectId } : {}) + }, // do not include `api/v1` in the base url for this client baseUrl: `${baseUrl ?? ''}`, }) diff --git a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts deleted file mode 100644 index 2dc292321e..0000000000 --- a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { api } from '..'; - -export const boardImagesApi = api.injectEndpoints({ - endpoints: (build) => ({ - /** - * Board Images Queries - */ - // listBoardImages: build.query< - // OffsetPaginatedResults_ImageDTO_, - // ListBoardImagesArg - // >({ - // query: ({ board_id, offset, limit }) => ({ - // url: `board_images/${board_id}`, - // method: 'GET', - // }), - // providesTags: (result, error, arg) => { - // // any list of boardimages - // const tags: ApiFullTagDescription[] = [ - // { type: 'BoardImage', id: `${arg.board_id}_${LIST_TAG}` }, - // ]; - // if (result) { - // // and individual tags for each boardimage - // tags.push( - // ...result.items.map(({ board_id, image_name }) => ({ - // type: 'BoardImage' as const, - // id: `${board_id}_${image_name}`, - // })) - // ); - // } - // return tags; - // }, - // }), - }), -}); - -// export const { useListBoardImagesQuery } = boardImagesApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/boards.ts b/invokeai/frontend/web/src/services/api/endpoints/boards.ts index 73b894b492..9d9fa11da8 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boards.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boards.ts @@ -1,28 +1,16 @@ -import { Update } from '@reduxjs/toolkit'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES, } from 'features/gallery/store/types'; import { BoardDTO, - ImageDTO, + ListBoardsArg, OffsetPaginatedResults_BoardDTO_, + OffsetPaginatedResults_ImageDTO_, + UpdateBoardArg, } from 'services/api/types'; import { ApiFullTagDescription, LIST_TAG, api } from '..'; -import { paths } from '../schema'; -import { getListImagesUrl, imagesAdapter, imagesApi } from './images'; - -type ListBoardsArg = NonNullable< - paths['/api/v1/boards/']['get']['parameters']['query'] ->; - -type UpdateBoardArg = - paths['/api/v1/boards/{board_id}']['patch']['parameters']['path'] & { - changes: paths['/api/v1/boards/{board_id}']['patch']['requestBody']['content']['application/json']; - }; - -type DeleteBoardResult = - paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json']; +import { getListImagesUrl } from '../util'; export const boardsApi = api.injectEndpoints({ endpoints: (build) => ({ @@ -82,6 +70,44 @@ export const boardsApi = api.injectEndpoints({ keepUnusedDataFor: 0, }), + getBoardImagesTotal: build.query({ + query: (board_id) => ({ + url: getListImagesUrl({ + board_id: board_id ?? 'none', + categories: IMAGE_CATEGORIES, + is_intermediate: false, + limit: 0, + offset: 0, + }), + method: 'GET', + }), + providesTags: (result, error, arg) => [ + { type: 'BoardImagesTotal', id: arg ?? 'none' }, + ], + transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { + return response.total; + }, + }), + + getBoardAssetsTotal: build.query({ + query: (board_id) => ({ + url: getListImagesUrl({ + board_id: board_id ?? 'none', + categories: ASSETS_CATEGORIES, + is_intermediate: false, + limit: 0, + offset: 0, + }), + method: 'GET', + }), + providesTags: (result, error, arg) => [ + { type: 'BoardAssetsTotal', id: arg ?? 'none' }, + ], + transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { + return response.total; + }, + }), + /** * Boards Mutations */ @@ -105,176 +131,15 @@ export const boardsApi = api.injectEndpoints({ { type: 'Board', id: arg.board_id }, ], }), - - deleteBoard: build.mutation({ - query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), - invalidatesTags: (result, error, board_id) => [ - { type: 'Board', id: LIST_TAG }, - // invalidate the 'No Board' cache - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: 'none', - categories: IMAGE_CATEGORIES, - }), - }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: 'none', - categories: ASSETS_CATEGORIES, - }), - }, - { type: 'BoardImagesTotal', id: 'none' }, - { type: 'BoardAssetsTotal', id: 'none' }, - ], - async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { - /** - * Cache changes for deleteBoard: - * - Update every image in the 'getImageDTO' cache that has the board_id - * - Update every image in the 'All Images' cache that has the board_id - * - Update every image in the 'All Assets' cache that has the board_id - * - Invalidate the 'No Board' cache: - * Ideally we'd be able to insert all deleted images into the cache, but we don't - * have access to the deleted images DTOs - only the names, and a network request - * for all of a board's DTOs could be very large. Instead, we invalidate the 'No Board' - * cache. - */ - - try { - const { data } = await queryFulfilled; - const { deleted_board_images } = data; - - // update getImageDTO caches - deleted_board_images.forEach((image_id) => { - dispatch( - imagesApi.util.updateQueryData( - 'getImageDTO', - image_id, - (draft) => { - draft.board_id = undefined; - } - ) - ); - }); - - // update 'All Images' & 'All Assets' caches - const queryArgsToUpdate = [ - { - categories: IMAGE_CATEGORIES, - }, - { - categories: ASSETS_CATEGORIES, - }, - ]; - - const updates: Update[] = deleted_board_images.map( - (image_name) => ({ - id: image_name, - changes: { board_id: undefined }, - }) - ); - - queryArgsToUpdate.forEach((queryArgs) => { - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - queryArgs, - (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.updateMany(draft, updates); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; - } - ) - ); - }); - } catch { - //no-op - } - }, - }), - - deleteBoardAndImages: build.mutation({ - query: (board_id) => ({ - url: `boards/${board_id}`, - method: 'DELETE', - params: { include_images: true }, - }), - invalidatesTags: (result, error, board_id) => [ - { type: 'Board', id: LIST_TAG }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: 'none', - categories: IMAGE_CATEGORIES, - }), - }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: 'none', - categories: ASSETS_CATEGORIES, - }), - }, - { type: 'BoardImagesTotal', id: 'none' }, - { type: 'BoardAssetsTotal', id: 'none' }, - ], - async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { - /** - * Cache changes for deleteBoardAndImages: - * - ~~Remove every image in the 'getImageDTO' cache that has the board_id~~ - * This isn't actually possible, you cannot remove cache entries with RTK Query. - * Instead, we rely on the UI to remove all components that use the deleted images. - * - Remove every image in the 'All Images' cache that has the board_id - * - Remove every image in the 'All Assets' cache that has the board_id - */ - - try { - const { data } = await queryFulfilled; - const { deleted_images } = data; - - // update 'All Images' & 'All Assets' caches - const queryArgsToUpdate = [ - { - categories: IMAGE_CATEGORIES, - }, - { - categories: ASSETS_CATEGORIES, - }, - ]; - - queryArgsToUpdate.forEach((queryArgs) => { - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - queryArgs, - (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.removeMany( - draft, - deleted_images - ); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; - } - ) - ); - }); - } catch { - //no-op - } - }, - }), }), }); export const { useListBoardsQuery, useListAllBoardsQuery, + useGetBoardImagesTotalQuery, + useGetBoardAssetsTotalQuery, useCreateBoardMutation, useUpdateBoardMutation, - useDeleteBoardMutation, - useDeleteBoardAndImagesMutation, useListAllImageNamesForBoardQuery, } = boardsApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index e8740a418b..e093c1c33a 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -1,93 +1,37 @@ -import { EntityState, createEntityAdapter } from '@reduxjs/toolkit'; +import { EntityState, Update } from '@reduxjs/toolkit'; import { PatchCollection } from '@reduxjs/toolkit/dist/query/core/buildThunks'; -import { dateComparator } from 'common/util/dateComparator'; import { ASSETS_CATEGORIES, BoardId, IMAGE_CATEGORIES, } from 'features/gallery/store/types'; -import queryString from 'query-string'; -import { ApiFullTagDescription, api } from '..'; -import { components, paths } from '../schema'; +import { keyBy } from 'lodash'; +import { ApiFullTagDescription, LIST_TAG, api } from '..'; +import { components } from '../schema'; import { + DeleteBoardResult, ImageCategory, ImageDTO, + ListImagesArgs, OffsetPaginatedResults_ImageDTO_, PostUploadAction, + UnsafeImageMetadata, } from '../types'; - -const getIsImageInDateRange = ( - data: ImageCache | undefined, - imageDTO: ImageDTO -) => { - if (!data) { - return false; - } - const cacheImageDTOS = imagesSelectors.selectAll(data); - - if (cacheImageDTOS.length > 1) { - // Images are sorted by `created_at` DESC - // check if the image is newer than the oldest image in the cache - const createdDate = new Date(imageDTO.created_at); - const oldestDate = new Date( - cacheImageDTOS[cacheImageDTOS.length - 1].created_at - ); - return createdDate >= oldestDate; - } else if ([0, 1].includes(cacheImageDTOS.length)) { - // if there are only 1 or 0 images in the cache, we consider the image to be in the date range - return true; - } - return false; -}; - -const getCategories = (imageDTO: ImageDTO) => { - if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) { - return IMAGE_CATEGORIES; - } - return ASSETS_CATEGORIES; -}; - -export type ListImagesArgs = NonNullable< - paths['/api/v1/images/']['get']['parameters']['query'] ->; - -/** - * This is an unsafe type; the object inside is not guaranteed to be valid. - */ -export type UnsafeImageMetadata = { - metadata: components['schemas']['CoreMetadata']; - graph: NonNullable; -}; - -export type ImageCache = EntityState & { total: number }; - -// The adapter is not actually the data store - it just provides helper functions to interact -// with some other store of data. We will use the RTK Query cache as that store. -export const imagesAdapter = createEntityAdapter({ - selectId: (image) => image.image_name, - sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at), -}); - -// We want to also store the images total in the cache. When we initialize the cache state, -// we will provide this type arg so the adapter knows we want the total. -export type AdditionalImagesAdapterState = { total: number }; - -// Create selectors for the adapter. -export const imagesSelectors = imagesAdapter.getSelectors(); - -// Helper to create the url for the listImages endpoint. Also we use it to create the cache key. -export const getListImagesUrl = (queryArgs: ListImagesArgs) => - `images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`; +import { + getCategories, + getIsImageInDateRange, + getListImagesUrl, + imagesAdapter, + imagesSelectors, +} from '../util'; +import { boardsApi } from './boards'; export const imagesApi = api.injectEndpoints({ endpoints: (build) => ({ /** * Image Queries */ - listImages: build.query< - EntityState & { total: number }, - ListImagesArgs - >({ + listImages: build.query, ListImagesArgs>({ query: (queryArgs) => ({ // Use the helper to create the URL. url: getListImagesUrl(queryArgs), @@ -110,23 +54,17 @@ export const imagesApi = api.injectEndpoints({ return cacheKey; }, transformResponse(response: OffsetPaginatedResults_ImageDTO_) { - const { total, items: images } = response; - // Use the adapter to convert the response to the right shape, and adding the new total. + const { items: images } = response; + // Use the adapter to convert the response to the right shape. // The trick is to just provide an empty state and add the images array to it. This returns // a properly shaped EntityState. - return imagesAdapter.addMany( - imagesAdapter.getInitialState({ - total, - }), - images - ); + return imagesAdapter.addMany(imagesAdapter.getInitialState(), images); }, merge: (cache, response) => { // Here we actually update the cache. `response` here is the output of `transformResponse` // above. In a similar vein to `transformResponse`, we can use the imagesAdapter to get - // things in the right shape. Also update the total image count. + // things in the right shape. imagesAdapter.addMany(cache, imagesSelectors.selectAll(response)); - cache.total = response.total; }, forceRefetch({ currentArg, previousArg }) { // Refetch when the offset changes (which means we are on a new page). @@ -161,69 +99,26 @@ export const imagesApi = api.injectEndpoints({ }, }), getImageDTO: build.query({ - query: (image_name) => ({ url: `images/${image_name}` }), - providesTags: (result, error, arg) => { - const tags: ApiFullTagDescription[] = [{ type: 'Image', id: arg }]; - if (result?.board_id) { - tags.push({ type: 'Board', id: result.board_id }); - } - return tags; - }, + query: (image_name) => ({ url: `images/i/${image_name}` }), + providesTags: (result, error, image_name) => [ + { type: 'Image', id: image_name }, + ], keepUnusedDataFor: 86400, // 24 hours }), getImageMetadata: build.query({ - query: (image_name) => ({ url: `images/${image_name}/metadata` }), - providesTags: (result, error, arg) => { - const tags: ApiFullTagDescription[] = [ - { type: 'ImageMetadata', id: arg }, - ]; - return tags; - }, + query: (image_name) => ({ url: `images/i/${image_name}/metadata` }), + providesTags: (result, error, image_name) => [ + { type: 'ImageMetadata', id: image_name }, + ], keepUnusedDataFor: 86400, // 24 hours }), - getBoardImagesTotal: build.query({ - query: (board_id) => ({ - url: getListImagesUrl({ - board_id: board_id ?? 'none', - categories: IMAGE_CATEGORIES, - is_intermediate: false, - limit: 0, - offset: 0, - }), - method: 'GET', - }), - providesTags: (result, error, arg) => [ - { type: 'BoardImagesTotal', id: arg ?? 'none' }, - ], - transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { - return response.total; - }, - }), - getBoardAssetsTotal: build.query({ - query: (board_id) => ({ - url: getListImagesUrl({ - board_id: board_id ?? 'none', - categories: ASSETS_CATEGORIES, - is_intermediate: false, - limit: 0, - offset: 0, - }), - method: 'GET', - }), - providesTags: (result, error, arg) => [ - { type: 'BoardAssetsTotal', id: arg ?? 'none' }, - ], - transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { - return response.total; - }, - }), clearIntermediates: build.mutation({ query: () => ({ url: `images/clear-intermediates`, method: 'POST' }), invalidatesTags: ['IntermediatesCount'], }), deleteImage: build.mutation({ query: ({ image_name }) => ({ - url: `images/${image_name}`, + url: `images/i/${image_name}`, method: 'DELETE', }), invalidatesTags: (result, error, { board_id }) => [ @@ -240,33 +135,77 @@ export const imagesApi = api.injectEndpoints({ const { image_name, board_id } = imageDTO; - // Store patches so we can undo if the query fails - const patches: PatchCollection[] = []; + const queryArg = { + board_id: board_id ?? 'none', + categories: getCategories(imageDTO), + }; - // determine `categories`, i.e. do we update "All Images" or "All Assets" - // $cache = [board_id|no_board]/[images|assets] - const categories = getCategories(imageDTO); - - // *remove* from $cache - patches.push( - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - { board_id: board_id ?? 'none', categories }, - (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.removeOne(draft, image_name); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; - } - ) - ) + const patch = dispatch( + imagesApi.util.updateQueryData('listImages', queryArg, (draft) => { + imagesAdapter.removeOne(draft, image_name); + }) ); try { await queryFulfilled; } catch { - patches.forEach((patchResult) => patchResult.undo()); + patch.undo(); + } + }, + }), + deleteImages: build.mutation< + components['schemas']['DeleteImagesFromListResult'], + { imageDTOs: ImageDTO[] } + >({ + query: ({ imageDTOs }) => { + const image_names = imageDTOs.map((imageDTO) => imageDTO.image_name); + return { + url: `images/delete`, + method: 'POST', + body: { + image_names, + }, + }; + }, + invalidatesTags: (result, error, imageDTOs) => [], + async onQueryStarted({ imageDTOs }, { dispatch, queryFulfilled }) { + /** + * Cache changes for `deleteImages`: + * - *remove* the deleted images from their boards + * + * Unfortunately we cannot do an optimistic update here due to how immer handles patching + * arrays. You have to undo *all* patches, else the entity adapter's `ids` array is borked. + * So we have to wait for the query to complete before updating the cache. + */ + try { + const { data } = await queryFulfilled; + + // convert to an object so we can access the successfully delete image DTOs by name + const groupedImageDTOs = keyBy(imageDTOs, 'image_name'); + + data.deleted_images.forEach((image_name) => { + const imageDTO = groupedImageDTOs[image_name]; + + // should never be undefined + if (imageDTO) { + const queryArg = { + board_id: imageDTO.board_id ?? 'none', + categories: getCategories(imageDTO), + }; + // remove all deleted images from their boards + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + queryArg, + (draft) => { + imagesAdapter.removeOne(draft, image_name); + } + ) + ); + } + }); + } catch { + // } }, }), @@ -278,7 +217,7 @@ export const imagesApi = api.injectEndpoints({ { imageDTO: ImageDTO; is_intermediate: boolean } >({ query: ({ imageDTO, is_intermediate }) => ({ - url: `images/${imageDTO.image_name}`, + url: `images/i/${imageDTO.image_name}`, method: 'PATCH', body: { is_intermediate }, }), @@ -329,20 +268,13 @@ export const imagesApi = api.injectEndpoints({ 'listImages', { board_id: imageDTO.board_id ?? 'none', categories }, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.removeOne( - draft, - imageDTO.image_name - ); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.removeOne(draft, imageDTO.image_name); } ) ) ); } else { // ELSE (it is being changed to a non-intermediate): - console.log(imageDTO); const queryArgs = { board_id: imageDTO.board_id ?? 'none', categories, @@ -352,6 +284,16 @@ export const imagesApi = api.injectEndpoints({ getState() ); + const { data: total } = IMAGE_CATEGORIES.includes( + imageDTO.image_category + ) + ? boardsApi.endpoints.getBoardImagesTotal.select( + imageDTO.board_id ?? 'none' + )(getState()) + : boardsApi.endpoints.getBoardAssetsTotal.select( + imageDTO.board_id ?? 'none' + )(getState()); + // IF it eligible for insertion into existing $cache // "eligible" means either: // - The cache is fully populated, with all images in the db cached @@ -359,8 +301,7 @@ export const imagesApi = api.injectEndpoints({ // - The image's `created_at` is within the range of the cached images const isCacheFullyPopulated = - currentCache.data && - currentCache.data.ids.length >= currentCache.data.total; + currentCache.data && currentCache.data.ids.length >= (total ?? 0); const isInDateRange = getIsImageInDateRange( currentCache.data, @@ -375,10 +316,7 @@ export const imagesApi = api.injectEndpoints({ 'listImages', queryArgs, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.upsertOne(draft, imageDTO); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.upsertOne(draft, imageDTO); } ) ) @@ -401,7 +339,7 @@ export const imagesApi = api.injectEndpoints({ { imageDTO: ImageDTO; session_id: string } >({ query: ({ imageDTO, session_id }) => ({ - url: `images/${imageDTO.image_name}`, + url: `images/i/${imageDTO.image_name}`, method: 'PATCH', body: { session_id }, }), @@ -464,14 +402,14 @@ export const imagesApi = api.injectEndpoints({ const formData = new FormData(); formData.append('file', file); return { - url: `images/`, + url: `images/upload`, method: 'POST', body: formData, params: { image_category, is_intermediate, session_id, - board_id, + board_id: board_id === 'none' ? undefined : board_id, crop_visible, }, }; @@ -524,10 +462,7 @@ export const imagesApi = api.injectEndpoints({ categories, }, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.addOne(draft, imageDTO); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.addOne(draft, imageDTO); } ) ); @@ -543,6 +478,158 @@ export const imagesApi = api.injectEndpoints({ } }, }), + + deleteBoard: build.mutation({ + query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), + invalidatesTags: (result, error, board_id) => [ + { type: 'Board', id: LIST_TAG }, + // invalidate the 'No Board' cache + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: IMAGE_CATEGORIES, + }), + }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: ASSETS_CATEGORIES, + }), + }, + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: 'none' }, + ], + async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { + /** + * Cache changes for deleteBoard: + * - Update every image in the 'getImageDTO' cache that has the board_id + * - Update every image in the 'All Images' cache that has the board_id + * - Update every image in the 'All Assets' cache that has the board_id + * - Invalidate the 'No Board' cache: + * Ideally we'd be able to insert all deleted images into the cache, but we don't + * have access to the deleted images DTOs - only the names, and a network request + * for all of a board's DTOs could be very large. Instead, we invalidate the 'No Board' + * cache. + */ + + try { + const { data } = await queryFulfilled; + const { deleted_board_images } = data; + + // update getImageDTO caches + deleted_board_images.forEach((image_id) => { + dispatch( + imagesApi.util.updateQueryData( + 'getImageDTO', + image_id, + (draft) => { + draft.board_id = undefined; + } + ) + ); + }); + + // update 'All Images' & 'All Assets' caches + const queryArgsToUpdate = [ + { + categories: IMAGE_CATEGORIES, + }, + { + categories: ASSETS_CATEGORIES, + }, + ]; + + const updates: Update[] = deleted_board_images.map( + (image_name) => ({ + id: image_name, + changes: { board_id: undefined }, + }) + ); + + queryArgsToUpdate.forEach((queryArgs) => { + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + queryArgs, + (draft) => { + imagesAdapter.updateMany(draft, updates); + } + ) + ); + }); + } catch { + //no-op + } + }, + }), + + deleteBoardAndImages: build.mutation({ + query: (board_id) => ({ + url: `boards/${board_id}`, + method: 'DELETE', + params: { include_images: true }, + }), + invalidatesTags: (result, error, board_id) => [ + { type: 'Board', id: LIST_TAG }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: IMAGE_CATEGORIES, + }), + }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: ASSETS_CATEGORIES, + }), + }, + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: 'none' }, + ], + async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { + /** + * Cache changes for deleteBoardAndImages: + * - ~~Remove every image in the 'getImageDTO' cache that has the board_id~~ + * This isn't actually possible, you cannot remove cache entries with RTK Query. + * Instead, we rely on the UI to remove all components that use the deleted images. + * - Remove every image in the 'All Images' cache that has the board_id + * - Remove every image in the 'All Assets' cache that has the board_id + */ + + try { + const { data } = await queryFulfilled; + const { deleted_images } = data; + + // update 'All Images' & 'All Assets' caches + const queryArgsToUpdate = [ + { + categories: IMAGE_CATEGORIES, + }, + { + categories: ASSETS_CATEGORIES, + }, + ]; + + queryArgsToUpdate.forEach((queryArgs) => { + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + queryArgs, + (draft) => { + imagesAdapter.removeMany(draft, deleted_images); + } + ) + ); + }); + } catch { + //no-op + } + }, + }), addImageToBoard: build.mutation< void, { board_id: BoardId; imageDTO: ImageDTO } @@ -556,10 +643,13 @@ export const imagesApi = api.injectEndpoints({ }; }, invalidatesTags: (result, error, { board_id, imageDTO }) => [ + // refresh the board itself { type: 'Board', id: board_id }, + // update old board totals { type: 'BoardImagesTotal', id: board_id }, - { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' }, { type: 'BoardAssetsTotal', id: board_id }, + // update new board totals + { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' }, { type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' }, ], async onQueryStarted( @@ -589,7 +679,7 @@ export const imagesApi = api.injectEndpoints({ 'getImageDTO', imageDTO.image_name, (draft) => { - Object.assign(draft, { board_id }); + draft.board_id = board_id; } ) ) @@ -606,13 +696,7 @@ export const imagesApi = api.injectEndpoints({ categories, }, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.removeOne( - draft, - imageDTO.image_name - ); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.removeOne(draft, imageDTO.image_name); } ) ) @@ -630,9 +714,18 @@ export const imagesApi = api.injectEndpoints({ // OR // - The image's `created_at` is within the range of the cached images + const { data: total } = IMAGE_CATEGORIES.includes( + imageDTO.image_category + ) + ? boardsApi.endpoints.getBoardImagesTotal.select( + imageDTO.board_id ?? 'none' + )(getState()) + : boardsApi.endpoints.getBoardAssetsTotal.select( + imageDTO.board_id ?? 'none' + )(getState()); + const isCacheFullyPopulated = - currentCache.data && - currentCache.data.ids.length >= currentCache.data.total; + currentCache.data && currentCache.data.ids.length >= (total ?? 0); const isInDateRange = getIsImageInDateRange( currentCache.data, @@ -647,10 +740,7 @@ export const imagesApi = api.injectEndpoints({ 'listImages', queryArgs, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.addOne(draft, imageDTO); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.addOne(draft, imageDTO); } ) ) @@ -667,20 +757,26 @@ export const imagesApi = api.injectEndpoints({ }), removeImageFromBoard: build.mutation({ query: ({ imageDTO }) => { - const { board_id, image_name } = imageDTO; + const { image_name } = imageDTO; return { url: `board_images/`, method: 'DELETE', - body: { board_id, image_name }, + body: { image_name }, }; }, - invalidatesTags: (result, error, { imageDTO }) => [ - { type: 'Board', id: imageDTO.board_id }, - { type: 'BoardImagesTotal', id: imageDTO.board_id }, - { type: 'BoardImagesTotal', id: 'none' }, - { type: 'BoardAssetsTotal', id: imageDTO.board_id }, - { type: 'BoardAssetsTotal', id: 'none' }, - ], + invalidatesTags: (result, error, { imageDTO }) => { + const { board_id } = imageDTO; + return [ + // invalidate the image's old board + { type: 'Board', id: board_id ?? 'none' }, + // update old board totals + { type: 'BoardImagesTotal', id: board_id ?? 'none' }, + { type: 'BoardAssetsTotal', id: board_id ?? 'none' }, + // update the no_board totals + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: 'none' }, + ]; + }, async onQueryStarted( { imageDTO }, { dispatch, queryFulfilled, getState } @@ -704,7 +800,7 @@ export const imagesApi = api.injectEndpoints({ 'getImageDTO', imageDTO.image_name, (draft) => { - Object.assign(draft, { board_id: undefined }); + draft.board_id = undefined; } ) ) @@ -720,13 +816,7 @@ export const imagesApi = api.injectEndpoints({ categories, }, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.removeOne( - draft, - imageDTO.image_name - ); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.removeOne(draft, imageDTO.image_name); } ) ) @@ -744,9 +834,18 @@ export const imagesApi = api.injectEndpoints({ // OR // - The image's `created_at` is within the range of the cached images + const { data: total } = IMAGE_CATEGORIES.includes( + imageDTO.image_category + ) + ? boardsApi.endpoints.getBoardImagesTotal.select( + imageDTO.board_id ?? 'none' + )(getState()) + : boardsApi.endpoints.getBoardAssetsTotal.select( + imageDTO.board_id ?? 'none' + )(getState()); + const isCacheFullyPopulated = - currentCache.data && - currentCache.data.ids.length >= currentCache.data.total; + currentCache.data && currentCache.data.ids.length >= (total ?? 0); const isInDateRange = getIsImageInDateRange( currentCache.data, @@ -761,10 +860,7 @@ export const imagesApi = api.injectEndpoints({ 'listImages', queryArgs, (draft) => { - const oldTotal = draft.total; - const newState = imagesAdapter.upsertOne(draft, imageDTO); - const delta = newState.total - oldTotal; - draft.total = draft.total + delta; + imagesAdapter.upsertOne(draft, imageDTO); } ) ) @@ -778,6 +874,255 @@ export const imagesApi = api.injectEndpoints({ } }, }), + addImagesToBoard: build.mutation< + components['schemas']['AddImagesToBoardResult'], + { + board_id: string; + imageDTOs: ImageDTO[]; + } + >({ + query: ({ board_id, imageDTOs }) => ({ + url: `board_images/batch`, + method: 'POST', + body: { + image_names: imageDTOs.map((i) => i.image_name), + board_id, + }, + }), + invalidatesTags: (result, error, { board_id }) => [ + // update the destination board + { type: 'Board', id: board_id ?? 'none' }, + // update old board totals + { type: 'BoardImagesTotal', id: board_id ?? 'none' }, + { type: 'BoardAssetsTotal', id: board_id ?? 'none' }, + // update the no_board totals + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: 'none' }, + ], + async onQueryStarted( + { board_id, imageDTOs }, + { dispatch, queryFulfilled, getState } + ) { + try { + const { data } = await queryFulfilled; + const { added_image_names } = data; + + /** + * Cache changes for addImagesToBoard: + * - *update* getImageDTO for each image + * - *add* to board_id/[images|assets] + * - *remove* from [old_board_id|no_board]/[images|assets] + */ + + added_image_names.forEach((image_name) => { + dispatch( + imagesApi.util.updateQueryData( + 'getImageDTO', + image_name, + (draft) => { + draft.board_id = board_id; + } + ) + ); + + const imageDTO = imageDTOs.find((i) => i.image_name === image_name); + + if (!imageDTO) { + return; + } + + const categories = getCategories(imageDTO); + const old_board_id = imageDTO.board_id; + + // remove from the old board + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { board_id: old_board_id ?? 'none', categories }, + (draft) => { + imagesAdapter.removeOne(draft, imageDTO.image_name); + } + ) + ); + + const queryArgs = { + board_id, + categories, + }; + + const currentCache = imagesApi.endpoints.listImages.select( + queryArgs + )(getState()); + + const { data: total } = IMAGE_CATEGORIES.includes( + imageDTO.image_category + ) + ? boardsApi.endpoints.getBoardImagesTotal.select( + imageDTO.board_id ?? 'none' + )(getState()) + : boardsApi.endpoints.getBoardAssetsTotal.select( + imageDTO.board_id ?? 'none' + )(getState()); + + const isCacheFullyPopulated = + currentCache.data && currentCache.data.ids.length >= (total ?? 0); + + const isInDateRange = getIsImageInDateRange( + currentCache.data, + imageDTO + ); + + if (isCacheFullyPopulated || isInDateRange) { + // *upsert* to $cache + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + queryArgs, + (draft) => { + imagesAdapter.upsertOne(draft, { + ...imageDTO, + board_id, + }); + } + ) + ); + } + }); + } catch { + // no-op + } + }, + }), + removeImagesFromBoard: build.mutation< + components['schemas']['RemoveImagesFromBoardResult'], + { + imageDTOs: ImageDTO[]; + } + >({ + query: ({ imageDTOs }) => ({ + url: `board_images/batch/delete`, + method: 'POST', + body: { + image_names: imageDTOs.map((i) => i.image_name), + }, + }), + invalidatesTags: (result, error, { imageDTOs }) => { + const touchedBoardIds: string[] = []; + const tags: ApiFullTagDescription[] = [ + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: 'none' }, + ]; + + 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)) { + return; + } + + tags.push({ type: 'Board', id: board_id }); + tags.push({ type: 'BoardImagesTotal', id: board_id }); + tags.push({ type: 'BoardAssetsTotal', id: board_id }); + }); + + return tags; + }, + async onQueryStarted( + { imageDTOs }, + { dispatch, queryFulfilled, getState } + ) { + try { + const { data } = await queryFulfilled; + const { removed_image_names } = data; + + /** + * Cache changes for removeImagesFromBoard: + * - *update* getImageDTO for each image + * - *remove* from old_board_id/[images|assets] + * - *add* to no_board/[images|assets] + */ + + removed_image_names.forEach((image_name) => { + dispatch( + imagesApi.util.updateQueryData( + 'getImageDTO', + image_name, + (draft) => { + draft.board_id = undefined; + } + ) + ); + + const imageDTO = imageDTOs.find((i) => i.image_name === image_name); + + if (!imageDTO) { + return; + } + + const categories = getCategories(imageDTO); + + // remove from the old board + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { board_id: imageDTO.board_id ?? 'none', categories }, + (draft) => { + imagesAdapter.removeOne(draft, imageDTO.image_name); + } + ) + ); + + // add to `no_board` + const queryArgs = { + board_id: 'none', + categories, + }; + + const currentCache = imagesApi.endpoints.listImages.select( + queryArgs + )(getState()); + + const { data: total } = IMAGE_CATEGORIES.includes( + imageDTO.image_category + ) + ? boardsApi.endpoints.getBoardImagesTotal.select( + imageDTO.board_id ?? 'none' + )(getState()) + : boardsApi.endpoints.getBoardAssetsTotal.select( + imageDTO.board_id ?? 'none' + )(getState()); + + const isCacheFullyPopulated = + currentCache.data && currentCache.data.ids.length >= (total ?? 0); + + const isInDateRange = getIsImageInDateRange( + currentCache.data, + imageDTO + ); + + if (isCacheFullyPopulated || isInDateRange) { + // *upsert* to $cache + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + queryArgs, + (draft) => { + imagesAdapter.upsertOne(draft, { + ...imageDTO, + board_id: undefined, + }); + } + ) + ); + } + }); + } catch { + // no-op + } + }, + }), }), }); @@ -788,10 +1133,15 @@ export const { useGetImageDTOQuery, useGetImageMetadataQuery, useDeleteImageMutation, - useGetBoardImagesTotalQuery, - useGetBoardAssetsTotalQuery, + useDeleteImagesMutation, useUploadImageMutation, + useClearIntermediatesMutation, + useAddImagesToBoardMutation, + useRemoveImagesFromBoardMutation, useAddImageToBoardMutation, useRemoveImageFromBoardMutation, - useClearIntermediatesMutation, + useChangeImageIsIntermediateMutation, + useChangeImageSessionIdMutation, + useDeleteBoardAndImagesMutation, + useDeleteBoardMutation, } = imagesApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index a7b1323f36..33eb1fbdc2 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -5,7 +5,6 @@ import { BaseModelType, CheckpointModelConfig, ControlNetModelConfig, - ConvertModelConfig, DiffusersModelConfig, ImportModelConfig, LoRAModelConfig, @@ -83,7 +82,7 @@ type DeleteLoRAModelResponse = void; type ConvertMainModelArg = { base_model: BaseModelType; model_name: string; - params: ConvertModelConfig; + convert_dest_directory?: string; }; type ConvertMainModelResponse = @@ -122,7 +121,7 @@ type CheckpointConfigsResponse = type SearchFolderArg = operations['search_for_models']['parameters']['query']; -const mainModelsAdapter = createEntityAdapter({ +export const mainModelsAdapter = createEntityAdapter({ sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); @@ -132,15 +131,15 @@ const onnxModelsAdapter = createEntityAdapter({ const loraModelsAdapter = createEntityAdapter({ sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); -const controlNetModelsAdapter = +export const controlNetModelsAdapter = createEntityAdapter({ sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); -const textualInversionModelsAdapter = +export const textualInversionModelsAdapter = createEntityAdapter({ sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); -const vaeModelsAdapter = createEntityAdapter({ +export const vaeModelsAdapter = createEntityAdapter({ sortComparer: (a, b) => a.model_name.localeCompare(b.model_name), }); @@ -320,11 +319,11 @@ export const modelsApi = api.injectEndpoints({ ConvertMainModelResponse, ConvertMainModelArg >({ - query: ({ base_model, model_name, params }) => { + query: ({ base_model, model_name, convert_dest_directory }) => { return { url: `models/convert/${base_model}/main/${model_name}`, method: 'PUT', - params: params, + params: { convert_dest_directory }, }; }, invalidatesTags: [ diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts index 748f2c8f6e..ce0cff7b8a 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts @@ -1,7 +1,7 @@ import { BoardId } from 'features/gallery/store/types'; import { useListAllBoardsQuery } from '../endpoints/boards'; -export const useBoardName = (board_id: BoardId | null | undefined) => { +export const useBoardName = (board_id: BoardId) => { const { boardName } = useListAllBoardsQuery(undefined, { selectFromResult: ({ data }) => { const selectedBoard = data?.find((b) => b.board_id === board_id); diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts index dd144ffe00..a350979b89 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery, -} from '../endpoints/images'; +} from '../endpoints/boards'; export const useBoardTotal = (board_id: BoardId) => { const galleryView = useAppSelector((state) => state.gallery.galleryView); diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 0a0391898c..a9de7130c9 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -6,7 +6,7 @@ import { createApi, fetchBaseQuery, } from '@reduxjs/toolkit/query/react'; -import { $authToken, $baseUrl } from 'services/api/client'; +import { $authToken, $baseUrl, $projectId } from 'services/api/client'; export const tagTypes = [ 'Board', @@ -30,6 +30,7 @@ const dynamicBaseQuery: BaseQueryFn< > = async (args, api, extraOptions) => { const baseUrl = $baseUrl.get(); const authToken = $authToken.get(); + const projectId = $projectId.get(); const rawBaseQuery = fetchBaseQuery({ baseUrl: `${baseUrl ?? ''}/api/v1`, @@ -37,6 +38,9 @@ const dynamicBaseQuery: BaseQueryFn< if (authToken) { headers.set('Authorization', `Bearer ${authToken}`); } + if (projectId) { + headers.set("project-id", projectId) + } return headers; }, diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts index 80f0933f37..6574ec4909 100644 --- a/invokeai/frontend/web/src/services/api/schema.d.ts +++ b/invokeai/frontend/web/src/services/api/schema.d.ts @@ -135,19 +135,14 @@ export type paths = { */ put: operations["merge_models"]; }; - "/api/v1/images/": { - /** - * List Image Dtos - * @description Gets a list of image DTOs - */ - get: operations["list_image_dtos"]; + "/api/v1/images/upload": { /** * Upload Image * @description Uploads an image */ post: operations["upload_image"]; }; - "/api/v1/images/{image_name}": { + "/api/v1/images/i/{image_name}": { /** * Get Image Dto * @description Gets an image's DTO @@ -171,34 +166,45 @@ export type paths = { */ post: operations["clear_intermediates"]; }; - "/api/v1/images/{image_name}/metadata": { + "/api/v1/images/i/{image_name}/metadata": { /** * Get Image Metadata * @description Gets an image's metadata */ get: operations["get_image_metadata"]; }; - "/api/v1/images/{image_name}/full": { + "/api/v1/images/i/{image_name}/full": { /** * Get Image Full * @description Gets a full-resolution image file */ get: operations["get_image_full"]; }; - "/api/v1/images/{image_name}/thumbnail": { + "/api/v1/images/i/{image_name}/thumbnail": { /** * Get Image Thumbnail * @description Gets a thumbnail image file */ get: operations["get_image_thumbnail"]; }; - "/api/v1/images/{image_name}/urls": { + "/api/v1/images/i/{image_name}/urls": { /** * Get Image Urls * @description Gets an image and thumbnail URL */ get: operations["get_image_urls"]; }; + "/api/v1/images/": { + /** + * List Image Dtos + * @description Gets a list of image DTOs + */ + get: operations["list_image_dtos"]; + }; + "/api/v1/images/delete": { + /** Delete Images From List */ + post: operations["delete_images_from_list"]; + }; "/api/v1/boards/": { /** * List Boards @@ -237,15 +243,29 @@ export type paths = { }; "/api/v1/board_images/": { /** - * Create Board Image + * Add Image To Board * @description Creates a board_image */ - post: operations["create_board_image"]; + post: operations["add_image_to_board"]; /** - * Remove Board Image - * @description Deletes a board_image + * Remove Image From Board + * @description Removes an image from its board, if it had one */ - delete: operations["remove_board_image"]; + delete: operations["remove_image_from_board"]; + }; + "/api/v1/board_images/batch": { + /** + * Add Images To Board + * @description Adds a list of images to a board + */ + post: operations["add_images_to_board"]; + }; + "/api/v1/board_images/batch/delete": { + /** + * Remove Images From Board + * @description Removes a list of images from their board, if they had one + */ + post: operations["remove_images_from_board"]; }; "/api/v1/app/version": { /** Get Version */ @@ -273,6 +293,19 @@ export type webhooks = Record; export type components = { schemas: { + /** AddImagesToBoardResult */ + AddImagesToBoardResult: { + /** + * Board Id + * @description The id of the board the images were added to + */ + board_id: string; + /** + * Added Image Names + * @description The image names that were added to the board + */ + added_image_names: (string)[]; + }; /** * AddInvocation * @description Adds two numbers @@ -405,8 +438,8 @@ export type components = { */ image_count: number; }; - /** Body_create_board_image */ - Body_create_board_image: { + /** Body_add_image_to_board */ + Body_add_image_to_board: { /** * Board Id * @description The id of the board to add to @@ -418,6 +451,27 @@ export type components = { */ image_name: string; }; + /** Body_add_images_to_board */ + Body_add_images_to_board: { + /** + * Board Id + * @description The id of the board to add to + */ + board_id: string; + /** + * Image Names + * @description The names of the images to add + */ + image_names: (string)[]; + }; + /** Body_delete_images_from_list */ + Body_delete_images_from_list: { + /** + * Image Names + * @description The list of names of images to delete + */ + image_names: (string)[]; + }; /** Body_import_model */ Body_import_model: { /** @@ -465,19 +519,22 @@ export type components = { */ merge_dest_directory?: string; }; - /** Body_remove_board_image */ - Body_remove_board_image: { - /** - * Board Id - * @description The id of the board - */ - board_id: string; + /** Body_remove_image_from_board */ + Body_remove_image_from_board: { /** * Image Name * @description The name of the image to remove */ image_name: string; }; + /** Body_remove_images_from_board */ + Body_remove_images_from_board: { + /** + * Image Names + * @description The names of the images to remove + */ + image_names: (string)[]; + }; /** Body_upload_image */ Body_upload_image: { /** @@ -1157,6 +1214,11 @@ export type components = { */ deleted_images: (string)[]; }; + /** DeleteImagesFromListResult */ + DeleteImagesFromListResult: { + /** Deleted Images */ + deleted_images: (string)[]; + }; /** * DivideInvocation * @description Divides two numbers @@ -4627,6 +4689,14 @@ export type components = { */ step?: number; }; + /** RemoveImagesFromBoardResult */ + RemoveImagesFromBoardResult: { + /** + * Removed Image Names + * @description The image names that were removed from their board + */ + removed_image_names: (string)[]; + }; /** * ResizeLatentsInvocation * @description Resizes latents to explicit width/height (in pixels). Provided dimensions are floor-divided by 8. @@ -5891,18 +5961,6 @@ export type components = { */ image?: components["schemas"]["ImageField"]; }; - /** - * ControlNetModelFormat - * @description An enumeration. - * @enum {string} - */ - ControlNetModelFormat: "checkpoint" | "diffusers"; - /** - * StableDiffusionXLModelFormat - * @description An enumeration. - * @enum {string} - */ - StableDiffusionXLModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusionOnnxModelFormat * @description An enumeration. @@ -5921,6 +5979,18 @@ export type components = { * @enum {string} */ StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; + /** + * StableDiffusionXLModelFormat + * @description An enumeration. + * @enum {string} + */ + StableDiffusionXLModelFormat: "checkpoint" | "diffusers"; + /** + * ControlNetModelFormat + * @description An enumeration. + * @enum {string} + */ + ControlNetModelFormat: "checkpoint" | "diffusers"; }; responses: never; parameters: never; @@ -6547,42 +6617,6 @@ export type operations = { }; }; }; - /** - * List Image Dtos - * @description Gets a list of image DTOs - */ - list_image_dtos: { - parameters: { - query?: { - /** @description The origin of images to list. */ - image_origin?: components["schemas"]["ResourceOrigin"]; - /** @description The categories of image to include. */ - categories?: (components["schemas"]["ImageCategory"])[]; - /** @description Whether to list intermediate images. */ - is_intermediate?: boolean; - /** @description The board id to filter by. Use 'none' to find images without a board. */ - board_id?: string; - /** @description The page offset */ - offset?: number; - /** @description The number of images per page */ - limit?: number; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"]; - }; - }; - /** @description Validation Error */ - 422: { - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; /** * Upload Image * @description Uploads an image @@ -6829,6 +6863,64 @@ export type operations = { }; }; }; + /** + * List Image Dtos + * @description Gets a list of image DTOs + */ + list_image_dtos: { + parameters: { + query?: { + /** @description The origin of images to list. */ + image_origin?: components["schemas"]["ResourceOrigin"]; + /** @description The categories of image to include. */ + categories?: (components["schemas"]["ImageCategory"])[]; + /** @description Whether to list intermediate images. */ + is_intermediate?: boolean; + /** @description The board id to filter by. Use 'none' to find images without a board. */ + board_id?: string; + /** @description The page offset */ + offset?: number; + /** @description The number of images per page */ + limit?: number; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Delete Images From List */ + delete_images_from_list: { + requestBody: { + content: { + "application/json": components["schemas"]["Body_delete_images_from_list"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["DeleteImagesFromListResult"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; /** * List Boards * @description Gets a list of boards @@ -6999,13 +7091,13 @@ export type operations = { }; }; /** - * Create Board Image + * Add Image To Board * @description Creates a board_image */ - create_board_image: { + add_image_to_board: { requestBody: { content: { - "application/json": components["schemas"]["Body_create_board_image"]; + "application/json": components["schemas"]["Body_add_image_to_board"]; }; }; responses: { @@ -7024,13 +7116,13 @@ export type operations = { }; }; /** - * Remove Board Image - * @description Deletes a board_image + * Remove Image From Board + * @description Removes an image from its board, if it had one */ - remove_board_image: { + remove_image_from_board: { requestBody: { content: { - "application/json": components["schemas"]["Body_remove_board_image"]; + "application/json": components["schemas"]["Body_remove_image_from_board"]; }; }; responses: { @@ -7048,6 +7140,56 @@ export type operations = { }; }; }; + /** + * Add Images To Board + * @description Adds a list of images to a board + */ + add_images_to_board: { + requestBody: { + content: { + "application/json": components["schemas"]["Body_add_images_to_board"]; + }; + }; + responses: { + /** @description Images were added to board successfully */ + 201: { + content: { + "application/json": components["schemas"]["AddImagesToBoardResult"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** + * Remove Images From Board + * @description Removes a list of images from their board, if they had one + */ + remove_images_from_board: { + requestBody: { + content: { + "application/json": components["schemas"]["Body_remove_images_from_board"]; + }; + }; + responses: { + /** @description Images were removed from board successfully */ + 201: { + content: { + "application/json": components["schemas"]["RemoveImagesFromBoardResult"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; /** Get Version */ app_version: { responses: { diff --git a/invokeai/frontend/web/src/services/api/types.d.ts b/invokeai/frontend/web/src/services/api/types.ts similarity index 89% rename from invokeai/frontend/web/src/services/api/types.d.ts rename to invokeai/frontend/web/src/services/api/types.ts index 2ee508fe48..ca9dbb3aeb 100644 --- a/invokeai/frontend/web/src/services/api/types.d.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -1,13 +1,40 @@ import { UseToastOptions } from '@chakra-ui/react'; +import { EntityState } from '@reduxjs/toolkit'; import { O } from 'ts-toolbelt'; -import { components } from './schema'; +import { components, paths } from './schema'; -type schemas = components['schemas']; +export type ImageCache = EntityState; + +export type ListImagesArgs = NonNullable< + paths['/api/v1/images/']['get']['parameters']['query'] +>; + +export type DeleteBoardResult = + paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json']; + +export type ListBoardsArg = NonNullable< + paths['/api/v1/boards/']['get']['parameters']['query'] +>; + +export type UpdateBoardArg = + paths['/api/v1/boards/{board_id}']['patch']['parameters']['path'] & { + changes: paths['/api/v1/boards/{board_id}']['patch']['requestBody']['content']['application/json']; + }; + +/** + * This is an unsafe type; the object inside is not guaranteed to be valid. + */ +export type UnsafeImageMetadata = { + metadata: components['schemas']['CoreMetadata']; + graph: NonNullable; +}; /** * Marks the `type` property as required. Use for nodes. */ -type TypeReq = O.Required; +type TypeReq = O.Required; + +// Extracted types from API schema // App Info export type AppVersion = components['schemas']['AppVersion']; @@ -72,7 +99,6 @@ export type AnyModelConfig = | OnnxModelConfig; export type MergeModelConfig = components['schemas']['Body_merge_models']; -export type ConvertModelConfig = components['schemas']['Body_convert_model']; export type ImportModelConfig = components['schemas']['Body_import_model']; // Graphs diff --git a/invokeai/frontend/web/src/services/api/util.ts b/invokeai/frontend/web/src/services/api/util.ts new file mode 100644 index 0000000000..20c9baedbb --- /dev/null +++ b/invokeai/frontend/web/src/services/api/util.ts @@ -0,0 +1,56 @@ +import { + ASSETS_CATEGORIES, + IMAGE_CATEGORIES, +} from 'features/gallery/store/types'; +import { ImageCache, ImageDTO, ListImagesArgs } from './types'; +import { createEntityAdapter } from '@reduxjs/toolkit'; +import { dateComparator } from 'common/util/dateComparator'; +import queryString from 'query-string'; + +export const getIsImageInDateRange = ( + data: ImageCache | undefined, + imageDTO: ImageDTO +) => { + if (!data) { + return false; + } + const cacheImageDTOS = imagesSelectors.selectAll(data); + + if (cacheImageDTOS.length > 1) { + // Images are sorted by `created_at` DESC + // check if the image is newer than the oldest image in the cache + const createdDate = new Date(imageDTO.created_at); + const oldestImage = cacheImageDTOS[cacheImageDTOS.length - 1]; + if (!oldestImage) { + // satisfy TS gods, we already confirmed the array has more than one image + return false; + } + const oldestDate = new Date(oldestImage.created_at); + return createdDate >= oldestDate; + } else if ([0, 1].includes(cacheImageDTOS.length)) { + // if there are only 1 or 0 images in the cache, we consider the image to be in the date range + return true; + } + return false; +}; + +export const getCategories = (imageDTO: ImageDTO) => { + if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) { + return IMAGE_CATEGORIES; + } + return ASSETS_CATEGORIES; +}; + +// The adapter is not actually the data store - it just provides helper functions to interact +// with some other store of data. We will use the RTK Query cache as that store. +export const imagesAdapter = createEntityAdapter({ + selectId: (image) => image.image_name, + sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at), +}); + +// Create selectors for the adapter. +export const imagesSelectors = imagesAdapter.getSelectors(); + +// Helper to create the url for the listImages endpoint. Also we use it to create the cache key. +export const getListImagesUrl = (queryArgs: ListImagesArgs) => + `images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`; diff --git a/invokeai/frontend/web/src/theme/util/generateColorPalette.ts b/invokeai/frontend/web/src/theme/util/generateColorPalette.ts index 6d90a070c0..63a5c06219 100644 --- a/invokeai/frontend/web/src/theme/util/generateColorPalette.ts +++ b/invokeai/frontend/web/src/theme/util/generateColorPalette.ts @@ -22,7 +22,7 @@ export function generateColorPalette( ]; const p = colorSteps.reduce((palette, step, index) => { - const A = alpha ? lightnessSteps[index] / 100 : 1; + const A = alpha ? (lightnessSteps[index] as number) / 100 : 1; // Lightness should be 50% for alpha colors const L = alpha ? 50 : lightnessSteps[colorSteps.length - 1 - index]; diff --git a/invokeai/frontend/web/tsconfig.json b/invokeai/frontend/web/tsconfig.json index e722e2f9a8..c43d5dd86d 100644 --- a/invokeai/frontend/web/tsconfig.json +++ b/invokeai/frontend/web/tsconfig.json @@ -13,6 +13,8 @@ "moduleResolution": "Node", // TODO: Disabled for IDE performance issues with our translation JSON // "resolveJsonModule": true, + "noUncheckedIndexedAccess": true, + "strictNullChecks": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", diff --git a/shell.nix b/shell.nix deleted file mode 100644 index 3de8c91b38..0000000000 --- a/shell.nix +++ /dev/null @@ -1,162 +0,0 @@ -{ pkgs ? import {} - , lib ? pkgs.lib - , stdenv ? pkgs.stdenv - , fetchurl ? pkgs.fetchurl - , runCommand ? pkgs.runCommand - , makeWrapper ? pkgs.makeWrapper - , mkShell ? pkgs.mkShell - , buildFHSUserEnv ? pkgs.buildFHSUserEnv - , frameworks ? pkgs.darwin.apple_sdk.frameworks -}: - -# Setup InvokeAI environment using nix -# Simple usage: -# nix-shell -# python3 scripts/preload_models.py -# python3 scripts/invoke.py -h - -let - conda-shell = { url, sha256, installPath, packages, shellHook }: - let - src = fetchurl { inherit url sha256; }; - libPath = lib.makeLibraryPath ([] ++ lib.optionals (stdenv.isLinux) [ pkgs.zlib ]); - condaArch = if stdenv.system == "aarch64-darwin" then "osx-arm64" else ""; - installer = - if stdenv.isDarwin then - runCommand "conda-install" { - nativeBuildInputs = [ makeWrapper ]; - } '' - mkdir -p $out/bin - cp ${src} $out/bin/miniconda-installer.sh - chmod +x $out/bin/miniconda-installer.sh - makeWrapper \ - $out/bin/miniconda-installer.sh \ - $out/bin/conda-install \ - --add-flags "-p ${installPath}" \ - --add-flags "-b" - '' - else if stdenv.isLinux then - runCommand "conda-install" { - nativeBuildInputs = [ makeWrapper ]; - buildInputs = [ pkgs.zlib ]; - } - # on line 10, we have 'unset LD_LIBRARY_PATH' - # we have to comment it out however in a way that the number of bytes in the - # file does not change. So we replace the 'u' in the line with a '#' - # The reason is that the binary payload is encoded as number - # of bytes from the top of the installer script - # and unsetting the library path prevents the zlib library from being discovered - '' - mkdir -p $out/bin - sed 's/unset LD_LIBRARY_PATH/#nset LD_LIBRARY_PATH/' ${src} > $out/bin/miniconda-installer.sh - chmod +x $out/bin/miniconda-installer.sh - makeWrapper \ - $out/bin/miniconda-installer.sh \ - $out/bin/conda-install \ - --add-flags "-p ${installPath}" \ - --add-flags "-b" \ - --prefix "LD_LIBRARY_PATH" : "${libPath}" - '' - else {}; - - hook = '' - export CONDA_SUBDIR=${condaArch} - '' + shellHook; - - fhs = buildFHSUserEnv { - name = "conda-shell"; - targetPkgs = pkgs: [ stdenv.cc pkgs.git installer ] ++ packages; - profile = hook; - runScript = "bash"; - }; - - shell = mkShell { - shellHook = if stdenv.isDarwin then hook else "conda-shell; exit"; - packages = if stdenv.isDarwin then [ pkgs.git installer ] ++ packages else [ fhs ]; - }; - in shell; - - packages = with pkgs; [ - cmake - protobuf - libiconv - rustc - cargo - rustPlatform.bindgenHook - ]; - - env = { - aarch64-darwin = { - envFile = "environment-mac.yml"; - condaPath = (builtins.toString ./.) + "/.conda"; - ptrSize = "8"; - }; - x86_64-linux = { - envFile = "environment.yml"; - condaPath = (builtins.toString ./.) + "/.conda"; - ptrSize = "8"; - }; - }; - - envFile = env.${stdenv.system}.envFile; - installPath = env.${stdenv.system}.condaPath; - ptrSize = env.${stdenv.system}.ptrSize; - shellHook = '' - conda-install - - # tmpdir is too small in nix - export TMPDIR="${installPath}/tmp" - - # Add conda to PATH - export PATH="${installPath}/bin:$PATH" - - # Allows `conda activate` to work properly - source ${installPath}/etc/profile.d/conda.sh - - # Paths for gcc if compiling some C sources with pip - export NIX_CFLAGS_COMPILE="-I${installPath}/include -I$TMPDIR/include" - export NIX_CFLAGS_LINK="-L${installPath}/lib $BINDGEN_EXTRA_CLANG_ARGS" - - export PIP_EXISTS_ACTION=w - - # rust-onig fails (think it writes config.h to wrong location) - mkdir -p "$TMPDIR/include" - cat <<'EOF' > "$TMPDIR/include/config.h" - #define HAVE_PROTOTYPES 1 - #define STDC_HEADERS 1 - #define HAVE_STRING_H 1 - #define HAVE_STDARG_H 1 - #define HAVE_STDLIB_H 1 - #define HAVE_LIMITS_H 1 - #define HAVE_INTTYPES_H 1 - #define SIZEOF_INT 4 - #define SIZEOF_SHORT 2 - #define SIZEOF_LONG ${ptrSize} - #define SIZEOF_VOIDP ${ptrSize} - #define SIZEOF_LONG_LONG 8 - EOF - - conda env create -f "${envFile}" || conda env update --prune -f "${envFile}" - conda activate invokeai - ''; - - version = "4.12.0"; - conda = { - aarch64-darwin = { - shell = conda-shell { - inherit shellHook installPath; - url = "https://repo.anaconda.com/miniconda/Miniconda3-py39_${version}-MacOSX-arm64.sh"; - sha256 = "4bd112168cc33f8a4a60d3ef7e72b52a85972d588cd065be803eb21d73b625ef"; - packages = [ frameworks.Security ] ++ packages; - }; - }; - x86_64-linux = { - shell = conda-shell { - inherit shellHook installPath; - url = "https://repo.continuum.io/miniconda/Miniconda3-py39_${version}-Linux-x86_64.sh"; - sha256 = "78f39f9bae971ec1ae7969f0516017f2413f17796670f7040725dd83fcff5689"; - packages = with pkgs; [ libGL glib ] ++ packages; - }; - }; - }; -in conda.${stdenv.system}.shell