diff --git a/.github/workflows/style-checks.yml b/.github/workflows/style-checks.yml
index 0bb19e95e5..d29b489418 100644
--- a/.github/workflows/style-checks.yml
+++ b/.github/workflows/style-checks.yml
@@ -6,7 +6,6 @@ on:
pull_request:
push:
branches: main
- tags: "*"
jobs:
black:
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/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/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 9f02a29f10..96d17b548e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx
@@ -56,7 +56,7 @@ const BoardAutoAddSelect = () => {
return;
}
- dispatch(autoAddBoardIdChanged(v === 'none' ? undefined : v));
+ dispatch(autoAddBoardIdChanged(v));
},
[dispatch]
);
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 2774288612..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;
};
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 && (
{
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
);
@@ -84,10 +101,10 @@ const GalleryBoard = memo(
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]
@@ -148,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 118b2108f7..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,6 +1,6 @@
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';
@@ -15,6 +15,7 @@ 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;
}
@@ -33,26 +34,27 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
const dispatch = useAppDispatch();
const { autoAddBoardId, autoAssignBoardOnClick, isProcessing } =
useAppSelector(selector);
- const boardName = useBoardName(undefined);
+ const boardName = useBoardName('none');
const handleSelectBoard = useCallback(() => {
- dispatch(boardIdSelected(undefined));
+ dispatch(boardIdSelected('none'));
if (autoAssignBoardOnClick && !isProcessing) {
- dispatch(autoAddBoardIdChanged(undefined));
+ 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',
}),
[]
);
@@ -72,7 +74,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
h: 'full',
}}
>
-
+
{(ref) => (
{
alignItems: 'center',
}}
>
- {/* */}
{
}}
/>
- {/*
-
- {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 796cc542e7..5c32cc788e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx
@@ -5,17 +5,19 @@ 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],
@@ -24,12 +26,14 @@ const selector = createSelector(
galleryImageMinimumWidth,
shouldAutoSwitch,
autoAssignBoardOnClick,
+ shouldShowDeleteButton,
} = state.gallery;
return {
galleryImageMinimumWidth,
shouldAutoSwitch,
autoAssignBoardOnClick,
+ shouldShowDeleteButton,
};
},
defaultSelectorOptions
@@ -39,12 +43,37 @@ const GallerySettingsPopover = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
- const { galleryImageMinimumWidth, shouldAutoSwitch, autoAssignBoardOnClick } =
- 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))
- }
+ onChange={handleChangeAutoSwitch}
+ />
+
['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
)}
- {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 9c65e818f4..3b0dd233f1 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -1,66 +1,32 @@
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;
},
@@ -74,53 +40,28 @@ export const gallerySlice = createSlice({
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(
@@ -132,7 +73,7 @@ export const gallerySlice = createSlice({
}
if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) {
- state.autoAddBoardId = undefined;
+ state.autoAddBoardId = 'none';
}
}
);
@@ -140,23 +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 298b792362..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,17 +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",