Compare commits

...

71 Commits

Author SHA1 Message Date
psychedelicious
358dbdbf84 chore: bump version to v5.2.0 2024-10-17 22:24:51 +11:00
psychedelicious
5ec2d71be0 feat(ui): make debug logger middleware configurable
While troubleshooting an issue with this middleware, I found the inclusion of the nextState and diff to be very noisy. It's now a function that accepts some options to configure the output, and returns the middleware.
2024-10-17 08:04:51 +11:00
Mary Hipp
8f28903c81 remove extra slash in workflow share link 2024-10-17 08:02:27 +11:00
Mary Hipp
a071f2788a fix(ui): upload tooltip should only show plural if multiple upload is an option 2024-10-16 12:00:11 -04:00
Mary Hipp
d9a257ef8a fix(ui): add error handling to upload button 2024-10-16 09:32:35 -04:00
psychedelicious
23fada3eea feat(ui): simpler dnd indicator for right panel tabs
We can use the drop overlay component directly for this, without needing to add it as a `noop` dnd target.

Other changes:
- The `label` prop is now used to conditionally render the label - every drop target provides its own label, so this doesn't break anything.
- Add `withBackdrop` prop to control whether we apply the dimmed drop target effect.
2024-10-16 18:35:55 +11:00
psychedelicious
2917e59c38 Revert "feat(ui): add layers tab as droppable destination to improve UX for dragging from gallery to layers tabs"
This reverts commit 535c1287bbc8d2c2099f5ff659f62e3076a0dbee.
2024-10-16 18:35:55 +11:00
Mary Hipp
c691855a67 feat(ui): add layers tab as droppable destination to improve UX for dragging from gallery to layers tabs 2024-10-16 18:35:55 +11:00
Mary Hipp
a00347379b feat(ui): move layers/gallery tab state into redux so it persists across sessions/refreshes, make gallery the default 2024-10-16 18:35:55 +11:00
psychedelicious
ad1a8fbb8d fix(ui): ts 2024-10-16 18:33:40 +11:00
psychedelicious
f03b77e882 fix(ui): race condition with toast closing
Instead of providing a duration to the upload action, we close the toast imperatively in the `imageUploaded` listener using a timeout. 3s after the last upload toast, we close it.

This handles the case when we are uploading multiple images and don't want the toast to close til it's all finished.
2024-10-16 18:33:40 +11:00
psychedelicious
2b000cb006 fix(ui): erroneous board selection when uploading multiple images 2024-10-16 18:33:40 +11:00
psychedelicious
af636f08b8 feat(ui): add maxImageUploadCount config setting 2024-10-16 18:33:40 +11:00
psychedelicious
f8150f46a5 feat(ui): only switch boards on first upload of an image 2024-10-16 18:33:40 +11:00
psychedelicious
b613be0f5d feat(ui): updated useFullscreenDropzone
- Hack around toast durations so it closes after last image uploads
- Improved error logging
- Enforce singleton nature of hook
2024-10-16 18:33:40 +11:00
psychedelicious
a833d74913 tidy(ui): clean up imageUploaded listener 2024-10-16 18:33:40 +11:00
psychedelicious
02df055e8a feat(ui): simpler imageUploaded toast handling 2024-10-16 18:33:40 +11:00
psychedelicious
add31ce596 feat(ui): simpler useImageUploadButton
We can always iterate over `files`, no need for any conditional logic here.
2024-10-16 18:33:40 +11:00
Mary Hipp
7d7ad3052e feat(ui): enable multifile upload for fullscreen dropzone 2024-10-16 18:33:40 +11:00
Mary Hipp
3b16dbffb2 feat(ui): allow multiple images to be uploaded via gallery button, remove double add-to-board logic for uploaded images 2024-10-16 18:33:40 +11:00
Mary Hipp
d8b0648766 feat(ui): add upload button for gallery 2024-10-16 18:33:40 +11:00
psychedelicious
ae64ee224f chore: bump version to v5.2.0rc2 2024-10-16 10:59:28 +11:00
psychedelicious
1251dfd7f6 feat(ui): better warnings when transforming 2024-10-15 19:47:50 -04:00
psychedelicious
804ee3a7fb docs(ui): update docstrings for startTransform 2024-10-15 19:47:50 -04:00
psychedelicious
fc5f9047c2 fix(ui): fit to bbox just flashes transform handles
Need to `await` the startTransform call so it can acquire the lock on concurrent transformation operations.
2024-10-15 19:47:50 -04:00
psychedelicious
0b208220e5 chore(ui): lint 2024-10-16 09:30:16 +11:00
Thomas Bolteau
916b9f7741 translationBot(ui): update translation (French)
Currently translated at 100.0% (1493 of 1493 strings)

translationBot(ui): update translation (English)

Currently translated at 99.9% (1492 of 1493 strings)

translationBot(ui): update translation (French)

Currently translated at 61.7% (922 of 1493 strings)

Co-authored-by: Thomas Bolteau <thomas.bolteau50@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/en/
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/fr/
Translation: InvokeAI/Web UI
2024-10-16 09:30:16 +11:00
gallegonovato
0947a006cc translationBot(ui): update translation (Spanish)
Currently translated at 17.9% (268 of 1493 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/es/
Translation: InvokeAI/Web UI
2024-10-16 09:30:16 +11:00
Riccardo Giovanetti
2c2df6423e translationBot(ui): update translation (Italian)
Currently translated at 98.7% (1476 of 1494 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.8% (1476 of 1493 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.8% (1474 of 1491 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2024-10-16 09:30:16 +11:00
Mary Hipp
c3df9d38c0 prettier 2024-10-15 15:58:11 -04:00
Mary Hipp
3790c254f5 only show starter bundles if feature is enabled and no models installed, update getting started text for local vs non-local 2024-10-15 15:58:11 -04:00
psychedelicious
abf46eaacd feat(api): compare name/base/type when checking if starter model is installed 2024-10-15 15:58:11 -04:00
psychedelicious
166548246d feat(ui): disable starter bundle button when all installed 2024-10-15 15:58:11 -04:00
psychedelicious
985dcd9862 chore(ui): lint 2024-10-15 15:58:11 -04:00
psychedelicious
b1df592506 tidy(ui): starter models logic
- More comprehensive duplicate model logic
- De-dupe starter models, which may share dependencies
- Fix issue w/ duplicate keys in list component
- Add translations
- Add toast when installing starter model, matching bundle toast
2024-10-15 15:58:11 -04:00
psychedelicious
a09a0eff69 chore(ui): lint 2024-10-15 15:58:11 -04:00
psychedelicious
e73bd09d93 feat(ui): use for..of instead of for loop w/ extra type guards 2024-10-15 15:58:11 -04:00
psychedelicious
6f5477a3f0 feat(ui): compare against source when building models to install 2024-10-15 15:58:11 -04:00
psychedelicious
f78a542401 tidy(ui): use StarterModel type directly 2024-10-15 15:58:11 -04:00
Mary Hipp
8613efb03a update button UI 2024-10-15 15:58:11 -04:00
Mary Hipp
d8347d856d more copy and linting 2024-10-15 15:58:11 -04:00
Mary Hipp
336e6e0c19 only show Add Model button if not adding models 2024-10-15 15:58:11 -04:00
Mary Hipp
5bd87ca89b feat(ui,api): add starter bundles to MM 2024-10-15 15:58:11 -04:00
skunkworxdark
fe87c198eb Update workflow_records_sqlite.py
A where clause was omitted from the count_query during the revert of the optional Category in the commit acfeb4a276
2024-10-15 18:18:36 +11:00
Riku
69a4a88925 fix(ui): display guidance value for flux images in metadata viewer 2024-10-15 18:06:45 +11:00
Riku
6e7491b086 fix(ui): recall guidance value for flux images 2024-10-15 18:06:45 +11:00
Brandon Rising
3da8076a2b fix: Pin onnx versions to builds that don't require rare dlls 2024-10-12 10:36:51 -04:00
Mary Hipp
80360a8abb fix(api): update enum usage to work for python 3.11 2024-10-12 10:21:26 -04:00
Mary Hipp
acfeb4a276 undo changes that made category optional 2024-10-11 17:37:57 -04:00
Mary Hipp
b33dbfc95f prefix share link with window location 2024-10-11 17:25:58 -04:00
Mary Hipp
f9bc29203b ruff 2024-10-11 17:23:34 -04:00
Mary Hipp
cbe7717409 make sure combobox is not searchable 2024-10-11 17:23:34 -04:00
Mary Hipp
d6add93901 lint 2024-10-11 17:23:34 -04:00
Mary Hipp
ea45dce9dc (ui) add board sorting UI to board settings popover 2024-10-11 17:23:34 -04:00
Mary Hipp
8d44363d49 (ui): update boards list queries to only use sort params for list, and make sure archived boards are included in most places we are searching 2024-10-11 17:23:34 -04:00
Mary Hipp
9933cdb6b7 (api) fix missing sort params being drilled down, add case insensitivity to name sorting 2024-10-11 17:23:34 -04:00
Mary Hipp
e3e9d1f27c (ui) break out boards settings from gallery/image settings 2024-10-11 17:23:34 -04:00
psychedelicious
bb59ad438a docs(ui): add comments to ImageContextMenu 2024-10-11 09:36:23 -04:00
psychedelicious
e38f5b1576 fix(ui): safari doesn't have find on iterators 2024-10-11 09:36:23 -04:00
psychedelicious
1bb49b698f perf(ui): efficient gallery image hover state 2024-10-11 09:36:23 -04:00
psychedelicious
fa1fbd89fe tidy(ui): remove extraneous prop extraction 2024-10-11 09:36:23 -04:00
psychedelicious
190ef6732c perf(ui): properly memoize gallery image icon components 2024-10-11 09:36:23 -04:00
psychedelicious
947cd4694b perf(ui): use single event for all image context menus
Image elements register their target ref in a map, which is used to look up the image that was clicked on. Substantial perf improvement.
2024-10-11 09:36:23 -04:00
psychedelicious
ee32d0666d perf(ui): memoize gallery page buttons 2024-10-11 09:36:23 -04:00
psychedelicious
bc8ad9ccbf perf(ui): remove another extraneous useCallback 2024-10-11 09:36:23 -04:00
psychedelicious
e96b290fa9 perf(ui): remove extraneous useCallbacks 2024-10-11 09:36:23 -04:00
psychedelicious
b9f83eae6a perf(ui): do not call upload hook unless upload is needed 2024-10-11 09:36:23 -04:00
psychedelicious
9868e23235 feat(ui): use singleton context menu
This improves render perf for the image component by 10-20%.
2024-10-11 09:36:23 -04:00
psychedelicious
0060cae17c build(ui): set package mode target to ES2015 2024-10-11 07:54:44 -04:00
psychedelicious
56f0845552 tidy(ui): consistent naming for selector builder util 2024-10-11 07:51:55 -04:00
psychedelicious
da3f85dd8b fix(ui): edge case where entity isn't visible until interacting with canvas
To trigger the edge case:
- Have an empty layer and non-empty layer
- Select the non-empty layer
- Refresh the page
- Select to the empty layer without doing any other action
- You may be unable to draw on the layer
- Zoom in/out slightly
- You can now draw on it

The problem was not syncing visibility when a layer is selected, leaving the layer hidden. This indirectly disabled interactions.

The fix is to listen for changes to the layer's selected status and sync visibility when that changes.
2024-10-11 07:51:55 -04:00
77 changed files with 3238 additions and 1286 deletions

View File

@@ -5,9 +5,10 @@ from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.board_records.board_records_common import BoardChanges
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
@@ -115,6 +116,8 @@ async def delete_board(
response_model=Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]],
)
async def list_boards(
order_by: BoardRecordOrderBy = Query(default=BoardRecordOrderBy.CreatedAt, description="The attribute to order by"),
direction: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The direction to order by"),
all: Optional[bool] = Query(default=None, description="Whether to list all boards"),
offset: Optional[int] = Query(default=None, description="The page offset"),
limit: Optional[int] = Query(default=None, description="The number of boards per page"),
@@ -122,9 +125,9 @@ async def list_boards(
) -> Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]]:
"""Gets a list of boards"""
if all:
return ApiDependencies.invoker.services.boards.get_all(include_archived)
return ApiDependencies.invoker.services.boards.get_all(order_by, direction, include_archived)
elif offset is not None and limit is not None:
return ApiDependencies.invoker.services.boards.get_many(offset, limit, include_archived)
return ApiDependencies.invoker.services.boards.get_many(order_by, direction, offset, limit, include_archived)
else:
raise HTTPException(
status_code=400,

View File

@@ -38,7 +38,12 @@ from invokeai.backend.model_manager.load.model_cache.model_cache_base import Cac
from invokeai.backend.model_manager.metadata.fetch.huggingface import HuggingFaceMetadataFetch
from invokeai.backend.model_manager.metadata.metadata_base import ModelMetadataWithFiles, UnknownMetadataException
from invokeai.backend.model_manager.search import ModelSearch
from invokeai.backend.model_manager.starter_models import STARTER_MODELS, StarterModel, StarterModelWithoutDependencies
from invokeai.backend.model_manager.starter_models import (
STARTER_BUNDLES,
STARTER_MODELS,
StarterModel,
StarterModelWithoutDependencies,
)
model_manager_router = APIRouter(prefix="/v2/models", tags=["model_manager"])
@@ -792,22 +797,48 @@ async def convert_model(
return new_config
@model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=list[StarterModel])
async def get_starter_models() -> list[StarterModel]:
class StarterModelResponse(BaseModel):
starter_models: list[StarterModel]
starter_bundles: dict[str, list[StarterModel]]
def get_is_installed(
starter_model: StarterModel | StarterModelWithoutDependencies, installed_models: list[AnyModelConfig]
) -> bool:
for model in installed_models:
if model.source == starter_model.source:
return True
if model.name == starter_model.name and model.base == starter_model.base and model.type == starter_model.type:
return True
return False
@model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=StarterModelResponse)
async def get_starter_models() -> StarterModelResponse:
installed_models = ApiDependencies.invoker.services.model_manager.store.search_by_attr()
installed_model_sources = {m.source for m in installed_models}
starter_models = deepcopy(STARTER_MODELS)
starter_bundles = deepcopy(STARTER_BUNDLES)
for model in starter_models:
if model.source in installed_model_sources:
model.is_installed = True
model.is_installed = get_is_installed(model, installed_models)
# Remove already-installed dependencies
missing_deps: list[StarterModelWithoutDependencies] = []
for dep in model.dependencies or []:
if dep.source not in installed_model_sources:
if not get_is_installed(dep, installed_models):
missing_deps.append(dep)
model.dependencies = missing_deps
return starter_models
for bundle in starter_bundles.values():
for model in bundle:
model.is_installed = get_is_installed(model, installed_models)
# Remove already-installed dependencies
missing_deps: list[StarterModelWithoutDependencies] = []
for dep in model.dependencies or []:
if not get_is_installed(dep, installed_models):
missing_deps.append(dep)
model.dependencies = missing_deps
return StarterModelResponse(starter_models=starter_models, starter_bundles=starter_bundles)
@model_manager_router.get(

View File

@@ -88,7 +88,7 @@ async def list_workflows(
default=WorkflowRecordOrderBy.Name, description="The attribute to order by"
),
direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"),
category: Optional[WorkflowCategory] = Query(default=None, description="The category of workflow to get"),
category: WorkflowCategory = Query(default=WorkflowCategory.User, description="The category of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets a page of workflows"""

View File

@@ -1,7 +1,8 @@
from abc import ABC, abstractmethod
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord, BoardRecordOrderBy
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class BoardRecordStorageBase(ABC):
@@ -39,12 +40,19 @@ class BoardRecordStorageBase(ABC):
@abstractmethod
def get_many(
self, offset: int = 0, limit: int = 10, include_archived: bool = False
self,
order_by: BoardRecordOrderBy,
direction: SQLiteDirection,
offset: int = 0,
limit: int = 10,
include_archived: bool = False,
) -> OffsetPaginatedResults[BoardRecord]:
"""Gets many board records."""
pass
@abstractmethod
def get_all(self, include_archived: bool = False) -> list[BoardRecord]:
def get_all(
self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False
) -> list[BoardRecord]:
"""Gets all board records."""
pass

View File

@@ -1,8 +1,10 @@
from datetime import datetime
from enum import Enum
from typing import Optional, Union
from pydantic import BaseModel, Field
from invokeai.app.util.metaenum import MetaEnum
from invokeai.app.util.misc import get_iso_timestamp
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
@@ -60,6 +62,13 @@ class BoardChanges(BaseModel, extra="forbid"):
archived: Optional[bool] = Field(default=None, description="Whether or not the board is archived")
class BoardRecordOrderBy(str, Enum, metaclass=MetaEnum):
"""The order by options for board records"""
CreatedAt = "created_at"
Name = "board_name"
class BoardRecordNotFoundException(Exception):
"""Raised when an board record is not found."""

View File

@@ -8,10 +8,12 @@ from invokeai.app.services.board_records.board_records_common import (
BoardRecord,
BoardRecordDeleteException,
BoardRecordNotFoundException,
BoardRecordOrderBy,
BoardRecordSaveException,
deserialize_board_record,
)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.util.misc import uuid_string
@@ -144,7 +146,12 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
return self.get(board_id)
def get_many(
self, offset: int = 0, limit: int = 10, include_archived: bool = False
self,
order_by: BoardRecordOrderBy,
direction: SQLiteDirection,
offset: int = 0,
limit: int = 10,
include_archived: bool = False,
) -> OffsetPaginatedResults[BoardRecord]:
try:
self._lock.acquire()
@@ -154,17 +161,16 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
SELECT *
FROM boards
{archived_filter}
ORDER BY created_at DESC
ORDER BY {order_by} {direction}
LIMIT ? OFFSET ?;
"""
# Determine archived filter condition
if include_archived:
archived_filter = ""
else:
archived_filter = "WHERE archived = 0"
archived_filter = "" if include_archived else "WHERE archived = 0"
final_query = base_query.format(archived_filter=archived_filter)
final_query = base_query.format(
archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
)
# Execute query to fetch boards
self._cursor.execute(final_query, (limit, offset))
@@ -198,23 +204,32 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
finally:
self._lock.release()
def get_all(self, include_archived: bool = False) -> list[BoardRecord]:
def get_all(
self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False
) -> list[BoardRecord]:
try:
self._lock.acquire()
base_query = """
SELECT *
FROM boards
{archived_filter}
ORDER BY created_at DESC
"""
if include_archived:
archived_filter = ""
if order_by == BoardRecordOrderBy.Name:
base_query = """
SELECT *
FROM boards
{archived_filter}
ORDER BY LOWER(board_name) {direction}
"""
else:
archived_filter = "WHERE archived = 0"
base_query = """
SELECT *
FROM boards
{archived_filter}
ORDER BY {order_by} {direction}
"""
final_query = base_query.format(archived_filter=archived_filter)
archived_filter = "" if include_archived else "WHERE archived = 0"
final_query = base_query.format(
archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
)
self._cursor.execute(final_query)

View File

@@ -1,8 +1,9 @@
from abc import ABC, abstractmethod
from invokeai.app.services.board_records.board_records_common import BoardChanges
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class BoardServiceABC(ABC):
@@ -43,12 +44,19 @@ class BoardServiceABC(ABC):
@abstractmethod
def get_many(
self, offset: int = 0, limit: int = 10, include_archived: bool = False
self,
order_by: BoardRecordOrderBy,
direction: SQLiteDirection,
offset: int = 0,
limit: int = 10,
include_archived: bool = False,
) -> OffsetPaginatedResults[BoardDTO]:
"""Gets many boards."""
pass
@abstractmethod
def get_all(self, include_archived: bool = False) -> list[BoardDTO]:
def get_all(
self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False
) -> list[BoardDTO]:
"""Gets all boards."""
pass

View File

@@ -1,8 +1,9 @@
from invokeai.app.services.board_records.board_records_common import BoardChanges
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
from invokeai.app.services.boards.boards_base import BoardServiceABC
from invokeai.app.services.boards.boards_common import BoardDTO, board_record_to_dto
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class BoardService(BoardServiceABC):
@@ -47,9 +48,16 @@ class BoardService(BoardServiceABC):
self.__invoker.services.board_records.delete(board_id)
def get_many(
self, offset: int = 0, limit: int = 10, include_archived: bool = False
self,
order_by: BoardRecordOrderBy,
direction: SQLiteDirection,
offset: int = 0,
limit: int = 10,
include_archived: bool = False,
) -> OffsetPaginatedResults[BoardDTO]:
board_records = self.__invoker.services.board_records.get_many(offset, limit, include_archived)
board_records = self.__invoker.services.board_records.get_many(
order_by, direction, offset, limit, include_archived
)
board_dtos = []
for r in board_records.items:
cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id)
@@ -63,8 +71,10 @@ class BoardService(BoardServiceABC):
return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos))
def get_all(self, include_archived: bool = False) -> list[BoardDTO]:
board_records = self.__invoker.services.board_records.get_all(include_archived)
def get_all(
self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False
) -> list[BoardDTO]:
board_records = self.__invoker.services.board_records.get_all(order_by, direction, include_archived)
board_dtos = []
for r in board_records:
cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id)

View File

@@ -41,9 +41,9 @@ class WorkflowRecordsStorageBase(ABC):
self,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
category: WorkflowCategory,
page: int,
per_page: Optional[int],
category: Optional[WorkflowCategory],
query: Optional[str],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""

View File

@@ -127,9 +127,9 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
self,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
category: WorkflowCategory,
page: int = 0,
per_page: Optional[int] = None,
category: Optional[WorkflowCategory] = None,
query: Optional[str] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
try:
@@ -137,7 +137,8 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
# sanitize!
assert order_by in WorkflowRecordOrderBy
assert direction in SQLiteDirection
count_query = "SELECT COUNT(*) FROM workflow_library"
assert category in WorkflowCategory
count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?"
main_query = """
SELECT
workflow_id,
@@ -148,26 +149,16 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
updated_at,
opened_at
FROM workflow_library
WHERE category = ?
"""
main_params: list[int | str] = []
count_params: list[int | str] = []
if category:
assert category in WorkflowCategory
main_query += " WHERE category = ?"
count_query += " WHERE category = ?"
main_params.append(category.value)
count_params.append(category.value)
main_params: list[int | str] = [category.value]
count_params: list[int | str] = [category.value]
stripped_query = query.strip() if query else None
if stripped_query:
wildcard_query = "%" + stripped_query + "%"
if "WHERE" in main_query:
main_query += " AND (name LIKE ? OR description LIKE ?)"
count_query += " AND (name LIKE ? OR description LIKE ?)"
else:
main_query += " WHERE name LIKE ? OR description LIKE ?"
count_query += " WHERE name LIKE ? OR description LIKE ?"
main_query += " AND name LIKE ? OR description LIKE ? "
count_query += " AND name LIKE ? OR description LIKE ?;"
main_params.extend([wildcard_query, wildcard_query])
count_params.extend([wildcard_query, wildcard_query])

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,8 @@
"resetUI": "$t(accessibility.reset) UI",
"toggleRightPanel": "Toggle Right Panel (G)",
"toggleLeftPanel": "Toggle Left Panel (T)",
"uploadImage": "Upload Image"
"uploadImage": "Upload Image",
"uploadImages": "Upload Image(s)"
},
"boards": {
"addBoard": "Add Board",
@@ -285,6 +286,7 @@
"assetsTab": "Files youve uploaded for use in your projects.",
"autoAssignBoardOnClick": "Auto-Assign Board on Click",
"autoSwitchNewImages": "Auto-Switch to New Images",
"boardsSettings": "Boards Settings",
"copy": "Copy",
"currentlyInUse": "This image is currently in use in the following features:",
"drop": "Drop",
@@ -304,6 +306,7 @@
"go": "Go",
"image": "image",
"imagesTab": "Images youve created and saved within Invoke.",
"imagesSettings": "Gallery Images Settings",
"jump": "Jump",
"loading": "Loading",
"newestFirst": "Newest First",
@@ -662,6 +665,7 @@
"cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)",
"createdBy": "Created By",
"generationMode": "Generation Mode",
"guidance": "Guidance",
"height": "Height",
"imageDetails": "Image Details",
"imageDimensions": "Image Dimensions",
@@ -725,6 +729,7 @@
"huggingFaceHelper": "If multiple models are found in this repo, you will be prompted to select one to install.",
"hfToken": "HuggingFace Token",
"imageEncoderModelId": "Image Encoder Model ID",
"includesNModels": "Includes {{n}} models and their dependencies",
"installQueue": "Install Queue",
"inplaceInstall": "In-place install",
"inplaceInstallDesc": "Install models without copying the files. When using the model, it will be loaded from its this location. If disabled, the model file(s) will be copied into the Invoke-managed models directory during installation.",
@@ -778,6 +783,8 @@
"simpleModelPlaceholder": "URL or path to a local file or diffusers folder",
"source": "Source",
"spandrelImageToImage": "Image to Image (Spandrel)",
"starterBundles": "Starter Bundles",
"starterBundleHelpText": "Easily install all models needed to get started with a base model, including a main model, controlnets, IP adapters, and more. Selecting a bundle will skip any models that you already have installed.",
"starterModels": "Starter Models",
"starterModelsInModelManager": "Starter Models can be found in Model Manager",
"syncModels": "Sync Models",
@@ -795,7 +802,13 @@
"vae": "VAE",
"vaePrecision": "VAE Precision",
"variant": "Variant",
"width": "Width"
"width": "Width",
"installingBundle": "Installing Bundle",
"installingModel": "Installing Model",
"installingXModels_one": "Installing {{count}} model",
"installingXModels_other": "Installing {{count}} models",
"skippingXDuplicates_one": ", skipping {{count}} duplicate",
"skippingXDuplicates_other": ", skipping {{count}} duplicates"
},
"models": {
"addLora": "Add LoRA",
@@ -1119,7 +1132,8 @@
"reloadingIn": "Reloading in"
},
"toast": {
"addedToBoard": "Added to board",
"addedToBoard": "Added to board {{name}}'s assets",
"addedToUncategorized": "Added to board $t(boards.uncategorized)'s assets",
"baseModelChanged": "Base Model Changed",
"baseModelChangedCleared_one": "Cleared or disabled {{count}} incompatible submodel",
"baseModelChangedCleared_other": "Cleared or disabled {{count}} incompatible submodels",
@@ -1168,7 +1182,10 @@
"setNodeField": "Set as node field",
"somethingWentWrong": "Something Went Wrong",
"uploadFailed": "Upload failed",
"uploadFailedInvalidUploadDesc": "Must be single PNG or JPEG image",
"imagesWillBeAddedTo": "Uploaded images will be added to board {{boardName}}'s assets.",
"uploadFailedInvalidUploadDesc_withCount_one": "Must be maximum of 1 PNG or JPEG image.",
"uploadFailedInvalidUploadDesc_withCount_other": "Must be maximum of {{count}} PNG or JPEG images.",
"uploadFailedInvalidUploadDesc": "Must be PNG or JPEG images.",
"workflowLoaded": "Workflow Loaded",
"problemRetrievingWorkflow": "Problem Retrieving Workflow",
"workflowDeleted": "Workflow Deleted",
@@ -1749,7 +1766,7 @@
"label": "Canny Edge Detection",
"description": "Generates an edge map from the selected layer using the Canny edge detection algorithm.",
"low_threshold": "Low Threshold",
"high_threshold": "Hight Threshold"
"high_threshold": "High Threshold"
},
"color_map": {
"label": "Color Map",
@@ -1987,8 +2004,12 @@
}
},
"newUserExperience": {
"toGetStartedLocal": "To get started, make sure to download or import models needed to run Invoke. Then, enter a prompt in the box and click <StrongComponent>Invoke</StrongComponent> to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the <StrongComponent>Gallery</StrongComponent> or edit them to the <StrongComponent>Canvas</StrongComponent>.",
"toGetStarted": "To get started, enter a prompt in the box and click <StrongComponent>Invoke</StrongComponent> to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the <StrongComponent>Gallery</StrongComponent> or edit them to the <StrongComponent>Canvas</StrongComponent>.",
"gettingStartedSeries": "Want more guidance? Check out our <LinkComponent>Getting Started Series</LinkComponent> for tips on unlocking the full potential of the Invoke Studio."
"gettingStartedSeries": "Want more guidance? Check out our <LinkComponent>Getting Started Series</LinkComponent> for tips on unlocking the full potential of the Invoke Studio.",
"downloadStarterModels": "Download Starter Models",
"importModels": "Import Models",
"noModelsInstalled": "It looks like you don't have any models installed"
},
"whatsNew": {
"whatsNewInInvoke": "What's New in Invoke",

View File

@@ -224,7 +224,9 @@
"createIssue": "Crear un problema",
"resetUI": "Interfaz de usuario $t(accessibility.reset)",
"mode": "Modo",
"submitSupportTicket": "Enviar Ticket de Soporte"
"submitSupportTicket": "Enviar Ticket de Soporte",
"toggleRightPanel": "Activar o desactivar el panel derecho (G)",
"toggleLeftPanel": "Activar o desactivar el panel izquierdo (T)"
},
"nodes": {
"zoomInNodes": "Acercar",
@@ -273,7 +275,12 @@
"addSharedBoard": "Agregar Panel Compartido",
"boards": "Paneles",
"archiveBoard": "Archivar Panel",
"archived": "Archivado"
"archived": "Archivado",
"selectedForAutoAdd": "Seleccionado para agregar automáticamente",
"unarchiveBoard": "Desarchivar el tablero",
"noBoards": "No hay tableros {{boardType}}",
"shared": "Carpetas compartidas",
"deletedPrivateBoardsCannotbeRestored": "Los tableros eliminados no se pueden restaurar. Al elegir \"Eliminar solo tablero\", las imágenes se colocan en un estado privado y sin categoría para el creador de la imagen."
},
"accordions": {
"compositing": {
@@ -316,5 +323,13 @@
"inviteTeammates": "Invitar compañeros de equipo",
"shareAccess": "Compartir acceso",
"professionalUpsell": "Disponible en la edición profesional de Invoke. Haz clic aquí o visita invoke.com/pricing para obtener más detalles."
},
"controlLayers": {
"layer_one": "Capa",
"layer_many": "Capas",
"layer_other": "Capas",
"layer_withCount_one": "({{count}}) capa",
"layer_withCount_many": "({{count}}) capas",
"layer_withCount_other": "({{count}}) capas"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,8 @@
"reset": "Reimposta",
"none": "Niente",
"new": "Nuovo",
"view": "Vista"
"view": "Vista",
"close": "Chiudi"
},
"gallery": {
"galleryImageSize": "Dimensione dell'immagine",
@@ -157,7 +158,9 @@
"openViewer": "Apri visualizzatore",
"closeViewer": "Chiudi visualizzatore",
"imagesTab": "Immagini create e salvate in Invoke.",
"assetsTab": "File che hai caricato per usarli nei tuoi progetti."
"assetsTab": "File che hai caricato per usarli nei tuoi progetti.",
"boardsSettings": "Impostazioni Bacheche",
"imagesSettings": "Impostazioni Immagini Galleria"
},
"hotkeys": {
"searchHotkeys": "Cerca tasti di scelta rapida",
@@ -766,7 +769,8 @@
"imageSaved": "Immagine salvata",
"imageSavingFailed": "Salvataggio dell'immagine non riuscito",
"layerCopiedToClipboard": "Livello copiato negli appunti",
"imageNotLoadedDesc": "Impossibile trovare l'immagine"
"imageNotLoadedDesc": "Impossibile trovare l'immagine",
"linkCopied": "Collegamento copiato"
},
"accessibility": {
"invokeProgressBar": "Barra di avanzamento generazione",
@@ -922,7 +926,7 @@
"saveToGallery": "Salva nella Galleria",
"noMatchingWorkflows": "Nessun flusso di lavoro corrispondente",
"noWorkflows": "Nessun flusso di lavoro",
"workflowHelpText": "Hai bisogno di aiuto? Consulta la nostra guida <LinkComponent>Introduzione ai flussi di lavoro</LinkComponent>"
"workflowHelpText": "Hai bisogno di aiuto? Consulta la nostra guida <LinkComponent>Introduzione ai flussi di lavoro</LinkComponent>."
},
"boards": {
"autoAddBoard": "Aggiungi automaticamente bacheca",
@@ -1519,7 +1523,8 @@
"parameterSet": "Parametro {{parameter}} impostato",
"parsingFailed": "Analisi non riuscita",
"recallParameter": "Richiama {{label}}",
"canvasV2Metadata": "Tela"
"canvasV2Metadata": "Tela",
"guidance": "Guida"
},
"hrf": {
"enableHrf": "Abilita Correzione Alta Risoluzione",
@@ -1570,7 +1575,12 @@
"defaultWorkflows": "Flussi di lavoro predefiniti",
"uploadAndSaveWorkflow": "Carica nella libreria",
"chooseWorkflowFromLibrary": "Scegli il flusso di lavoro dalla libreria",
"deleteWorkflow2": "Vuoi davvero eliminare questo flusso di lavoro? Questa operazione non può essere annullata."
"deleteWorkflow2": "Vuoi davvero eliminare questo flusso di lavoro? Questa operazione non può essere annullata.",
"edit": "Modifica",
"download": "Scarica",
"copyShareLink": "Copia Condividi Link",
"copyShareLinkForWorkflow": "Copia Condividi Link del Flusso di lavoro",
"delete": "Elimina"
},
"accordions": {
"compositing": {
@@ -1870,7 +1880,11 @@
"fitToBbox": "Adatta al Riquadro",
"transform": "Trasforma",
"apply": "Applica",
"cancel": "Annulla"
"cancel": "Annulla",
"fitMode": "Adattamento",
"fitModeContain": "Contieni",
"fitModeFill": "Riempi",
"fitModeCover": "Copri"
},
"stagingArea": {
"next": "Successiva",
@@ -1905,7 +1919,8 @@
"newRasterLayer": "Nuovo Livello Raster",
"saveCanvasToGallery": "Salva la Tela nella Galleria",
"saveToGalleryGroup": "Salva nella Galleria"
}
},
"newImg2ImgCanvasFromImage": "Nuova Immagine da immagine"
},
"ui": {
"tabs": {

View File

@@ -20,6 +20,7 @@ import {
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
@@ -31,7 +32,6 @@ import { selectLanguage } from 'features/system/store/systemSelectors';
import { AppContent } from 'features/ui/components/AppContent';
import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import { AnimatePresence } from 'framer-motion';
import i18n from 'i18n';
import { size } from 'lodash-es';
import { memo, useCallback, useEffect } from 'react';
@@ -100,11 +100,9 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
>
<input {...dropzone.getInputProps()} />
<AppContent />
<AnimatePresence>
{dropzone.isDragActive && isHandlingUpload && (
<ImageUploadOverlay dropzone={dropzone} setIsHandlingUpload={setIsHandlingUpload} />
)}
</AnimatePresence>
{dropzone.isDragActive && isHandlingUpload && (
<ImageUploadOverlay dropzone={dropzone} setIsHandlingUpload={setIsHandlingUpload} />
)}
</Box>
<DeleteImageModal />
<ChangeBoardModal />
@@ -120,6 +118,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
<GlobalImageHotkeys />
<NewGallerySessionDialog />
<NewCanvasSessionDialog />
<ImageContextMenu />
</ErrorBoundary>
);
};

View File

@@ -12,7 +12,7 @@ import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -140,6 +140,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
case 'generation':
// Go to the canvas tab, open the image viewer, and enable send-to-gallery mode
store.dispatch(setActiveTab('canvas'));
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
store.dispatch(settingsSendToCanvasChanged(false));
$imageViewer.set(true);
break;

View File

@@ -7,12 +7,20 @@ import { diff } from 'jsondiffpatch';
/**
* Super simple logger middleware. Useful for debugging when the redux devtools are awkward.
*/
export const debugLoggerMiddleware: Middleware = (api: MiddlewareAPI) => (next) => (action) => {
const originalState = api.getState();
console.log('REDUX: dispatching', action);
const result = next(action);
const nextState = api.getState();
console.log('REDUX: next state', nextState);
console.log('REDUX: diff', diff(originalState, nextState));
return result;
};
export const getDebugLoggerMiddleware =
(options?: { withDiff?: boolean; withNextState?: boolean }): Middleware =>
(api: MiddlewareAPI) =>
(next) =>
(action) => {
const originalState = api.getState();
console.log('REDUX: dispatching', action);
const result = next(action);
const nextState = api.getState();
if (options?.withNextState) {
console.log('REDUX: next state', nextState);
}
if (options?.withDiff) {
console.log('REDUX: diff', diff(originalState, nextState));
}
return result;
};

View File

@@ -29,13 +29,13 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
const { autoAddBoardId, selectedBoardId } = state.gallery;
// If the deleted board was currently selected, we should reset the selected board to uncategorized
if (deletedBoardId === selectedBoardId) {
if (selectedBoardId !== 'none' && deletedBoardId === selectedBoardId) {
dispatch(boardIdSelected({ boardId: 'none' }));
dispatch(galleryViewChanged('images'));
}
// If the deleted board was selected for auto-add, we should reset the auto-add board to uncategorized
if (deletedBoardId === autoAddBoardId) {
if (autoAddBoardId !== 'none' && deletedBoardId === autoAddBoardId) {
dispatch(autoAddBoardIdChanged('none'));
}
},
@@ -46,11 +46,11 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
matcher: boardsApi.endpoints.updateBoard.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const state = getState();
const { shouldShowArchivedBoards } = state.gallery;
const { shouldShowArchivedBoards, selectedBoardId, autoAddBoardId } = state.gallery;
const wasArchived = action.meta.arg.originalArgs.changes.archived === true;
if (wasArchived && !shouldShowArchivedBoards) {
if (selectedBoardId !== 'none' && autoAddBoardId !== 'none' && wasArchived && !shouldShowArchivedBoards) {
dispatch(autoAddBoardIdChanged('none'));
dispatch(boardIdSelected({ boardId: 'none' }));
dispatch(galleryViewChanged('images'));
@@ -80,7 +80,7 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
// Handle the case where selected board is archived
const selectedBoard = queryResult.data.find((b) => b.board_id === selectedBoardId);
if (!selectedBoard || selectedBoard.archived) {
if (selectedBoardId !== 'none' && (!selectedBoard || selectedBoard.archived)) {
// If we can't find the selected board or it's archived, we should reset the selected board to uncategorized
dispatch(boardIdSelected({ boardId: 'none' }));
dispatch(galleryViewChanged('images'));
@@ -88,7 +88,7 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
// Handle the case where auto-add board is archived
const autoAddBoard = queryResult.data.find((b) => b.board_id === autoAddBoardId);
if (!autoAddBoard || autoAddBoard.archived) {
if (autoAddBoardId !== 'none' && (!autoAddBoard || autoAddBoard.archived)) {
// If we can't find the auto-add board or it's archived, we should reset the selected board to uncategorized
dispatch(autoAddBoardIdChanged('none'));
}
@@ -106,13 +106,13 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
const { selectedBoardId, autoAddBoardId } = state.gallery;
// Handle the case where selected board isn't in the list of boards
if (!boards.find((b) => b.board_id === selectedBoardId)) {
if (selectedBoardId !== 'none' && !boards.find((b) => b.board_id === selectedBoardId)) {
dispatch(boardIdSelected({ boardId: 'none' }));
dispatch(galleryViewChanged('images'));
}
// Handle the case where auto-add board isn't in the list of boards
if (!boards.find((b) => b.board_id === autoAddBoardId)) {
if (autoAddBoardId !== 'none' && !boards.find((b) => b.board_id === autoAddBoardId)) {
dispatch(autoAddBoardIdChanged('none'));
}
},

View File

@@ -1,5 +1,6 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { RootState } from 'app/store/store';
import {
entityRasterized,
entitySelected,
@@ -20,24 +21,39 @@ import { imagesApi } from 'services/api/endpoints/images';
const log = logger('gallery');
/**
* Gets the description for the toast that is shown when an image is uploaded.
* @param boardId The board id of the uploaded image
* @param state The current state of the app
* @returns
*/
const getUploadedToastDescription = (boardId: string, state: RootState) => {
if (boardId === 'none') {
return t('toast.addedToUncategorized');
}
// Attempt to get the board's name for the toast
const queryArgs = selectListBoardsQueryArgs(state);
const { data } = boardsApi.endpoints.listAllBoards.select(queryArgs)(state);
// Fall back to just the board id if we can't find the board for some reason
const board = data?.find((b) => b.board_id === boardId);
return t('toast.addedToBoard', { name: board?.board_name ?? boardId });
};
let lastUploadedToastTimeout: number | null = null;
export const addImageUploadedFulfilledListener = (startAppListening: AppStartListening) => {
startAppListening({
matcher: imagesApi.endpoints.uploadImage.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const imageDTO = action.payload;
const state = getState();
const { autoAddBoardId } = state.gallery;
log.debug({ imageDTO }, 'Image uploaded');
const { postUploadAction } = action.meta.arg.originalArgs;
if (
// No further actions needed for intermediate images,
action.payload.is_intermediate &&
// unless they have an explicit post-upload action
!postUploadAction
) {
if (!postUploadAction) {
return;
}
@@ -48,42 +64,40 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
} as const;
// default action - just upload and alert user
if (postUploadAction?.type === 'TOAST') {
if (!autoAddBoardId || autoAddBoardId === 'none') {
const title = postUploadAction.title || DEFAULT_UPLOADED_TOAST.title;
toast({ ...DEFAULT_UPLOADED_TOAST, title });
dispatch(boardIdSelected({ boardId: 'none' }));
dispatch(galleryViewChanged('assets'));
} else {
// Add this image to the board
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
board_id: autoAddBoardId,
imageDTO,
})
);
// Attempt to get the board's name for the toast
const queryArgs = selectListBoardsQueryArgs(state);
const { data } = boardsApi.endpoints.listAllBoards.select(queryArgs)(state);
// Fall back to just the board id if we can't find the board for some reason
const board = data?.find((b) => b.board_id === autoAddBoardId);
const description = board
? `${t('toast.addedToBoard')} ${board.board_name}`
: `${t('toast.addedToBoard')} ${autoAddBoardId}`;
toast({
...DEFAULT_UPLOADED_TOAST,
description,
});
dispatch(boardIdSelected({ boardId: autoAddBoardId }));
if (postUploadAction.type === 'TOAST') {
const boardId = imageDTO.board_id ?? 'none';
if (lastUploadedToastTimeout !== null) {
window.clearTimeout(lastUploadedToastTimeout);
}
const toastApi = toast({
...DEFAULT_UPLOADED_TOAST,
title: postUploadAction.title || DEFAULT_UPLOADED_TOAST.title,
description: getUploadedToastDescription(boardId, state),
duration: null, // we will close the toast manually
});
lastUploadedToastTimeout = window.setTimeout(() => {
toastApi.close();
}, 3000);
/**
* We only want to change the board and view if this is the first upload of a batch, else we end up hijacking
* the user's gallery board and view selection:
* - User uploads multiple images
* - A couple uploads finish, but others are pending still
* - User changes the board selection
* - Pending uploads finish and change the board back to the original board
* - User is confused as to why the board changed
*
* Default to true to not require _all_ image upload handlers to set this value
*/
const isFirstUploadOfBatch = action.meta.arg.originalArgs.isFirstUploadOfBatch ?? true;
if (isFirstUploadOfBatch) {
dispatch(boardIdSelected({ boardId }));
dispatch(galleryViewChanged('assets'));
}
return;
}
if (postUploadAction?.type === 'SET_UPSCALE_INITIAL_IMAGE') {
if (postUploadAction.type === 'SET_UPSCALE_INITIAL_IMAGE') {
dispatch(upscaleInitialImageChanged(imageDTO));
toast({
...DEFAULT_UPLOADED_TOAST,
@@ -92,21 +106,14 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
return;
}
// if (postUploadAction?.type === 'SET_CA_IMAGE') {
// const { id } = postUploadAction;
// dispatch(caImageChanged({ id, imageDTO }));
// toast({ ...DEFAULT_UPLOADED_TOAST, description: t('toast.setControlImage') });
// return;
// }
if (postUploadAction?.type === 'SET_IPA_IMAGE') {
if (postUploadAction.type === 'SET_IPA_IMAGE') {
const { id } = postUploadAction;
dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: { id, type: 'reference_image' }, imageDTO }));
toast({ ...DEFAULT_UPLOADED_TOAST, description: t('toast.setControlImage') });
return;
}
if (postUploadAction?.type === 'SET_RG_IP_ADAPTER_IMAGE') {
if (postUploadAction.type === 'SET_RG_IP_ADAPTER_IMAGE') {
const { id, referenceImageId } = postUploadAction;
dispatch(
rgIPAdapterImageChanged({ entityIdentifier: { id, type: 'regional_guidance' }, referenceImageId, imageDTO })
@@ -115,14 +122,14 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
return;
}
if (postUploadAction?.type === 'SET_NODES_IMAGE') {
if (postUploadAction.type === 'SET_NODES_IMAGE') {
const { nodeId, fieldName } = postUploadAction;
dispatch(fieldImageValueChanged({ nodeId, fieldName, value: imageDTO }));
toast({ ...DEFAULT_UPLOADED_TOAST, description: `${t('toast.setNodeField')} ${fieldName}` });
return;
}
if (postUploadAction?.type === 'REPLACE_LAYER_WITH_IMAGE') {
if (postUploadAction.type === 'REPLACE_LAYER_WITH_IMAGE') {
const { entityIdentifier } = postUploadAction;
const state = getState();

View File

@@ -79,6 +79,7 @@ export type AppConfig = {
metadataFetchDebounce?: number;
workflowFetchDebounce?: number;
isLocal?: boolean;
maxImageUploadCount?: number;
sd: {
defaultModel?: string;
disabledControlNetModels: string[];

View File

@@ -4,9 +4,9 @@ import { IAILoadingImageFallback, IAINoContentFallback } from 'common/components
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import type { MouseEvent, ReactElement, ReactNode, SyntheticEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { PiImageBold, PiUploadSimpleBold } from 'react-icons/pi';
import type { ImageDTO, PostUploadAction } from 'services/api/types';
@@ -17,7 +17,14 @@ const defaultUploadElement = <Icon as={PiUploadSimpleBold} boxSize={16} />;
const defaultNoContentFallback = <IAINoContentFallback icon={PiImageBold} />;
const baseStyles: SystemStyleObject = {
touchAction: 'none',
userSelect: 'none',
webkitUserSelect: 'none',
};
const sx: SystemStyleObject = {
...baseStyles,
'.gallery-image-container::before': {
content: '""',
display: 'inline-block',
@@ -102,59 +109,10 @@ const IAIDndImage = (props: IAIDndImageProps) => {
useThumbailFallback,
withHoverOverlay = false,
children,
onMouseOver,
onMouseOut,
dataTestId,
...rest
} = props;
const handleMouseOver = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (onMouseOver) {
onMouseOver(e);
}
},
[onMouseOver]
);
const handleMouseOut = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (onMouseOut) {
onMouseOut(e);
}
},
[onMouseOut]
);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction,
isDisabled: isUploadDisabled,
});
const uploadButtonStyles = useMemo<SystemStyleObject>(() => {
const styles: SystemStyleObject = {
minH: minSize,
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
transitionProperty: 'common',
transitionDuration: '0.1s',
color: 'base.500',
};
if (!isUploadDisabled) {
Object.assign(styles, {
cursor: 'pointer',
bg: 'base.700',
_hover: {
bg: 'base.650',
color: 'base.300',
},
});
}
return styles;
}, [isUploadDisabled, minSize]);
const openInNewTab = useCallback(
(e: MouseEvent) => {
if (!imageDTO) {
@@ -168,76 +126,126 @@ const IAIDndImage = (props: IAIDndImageProps) => {
[imageDTO]
);
const ref = useRef<HTMLDivElement>(null);
useImageContextMenu(imageDTO, ref);
return (
<ImageContextMenu imageDTO={imageDTO}>
{(ref) => (
<Flex
ref={ref}
width="full"
height="full"
alignItems="center"
justifyContent="center"
position="relative"
minW={minSize ? minSize : undefined}
minH={minSize ? minSize : undefined}
userSelect="none"
cursor={isDragDisabled || !imageDTO ? 'default' : 'pointer'}
sx={withHoverOverlay ? sx : baseStyles}
data-selected={isSelectedForCompare ? 'selectedForCompare' : isSelected ? 'selected' : undefined}
{...rest}
>
{imageDTO && (
<Flex
ref={ref}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
width="full"
height="full"
className="gallery-image-container"
w="full"
h="full"
position={fitContainer ? 'absolute' : 'relative'}
alignItems="center"
justifyContent="center"
position="relative"
minW={minSize ? minSize : undefined}
minH={minSize ? minSize : undefined}
userSelect="none"
cursor={isDragDisabled || !imageDTO ? 'default' : 'pointer'}
sx={withHoverOverlay ? sx : undefined}
data-selected={isSelectedForCompare ? 'selectedForCompare' : isSelected ? 'selected' : undefined}
{...rest}
>
{imageDTO && (
<Flex
className="gallery-image-container"
w="full"
h="full"
position={fitContainer ? 'absolute' : 'relative'}
alignItems="center"
justifyContent="center"
>
<Image
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError"
fallbackSrc={useThumbailFallback ? imageDTO.thumbnail_url : undefined}
fallback={useThumbailFallback ? undefined : <IAILoadingImageFallback image={imageDTO} />}
onError={onError}
draggable={false}
w={imageDTO.width}
objectFit="contain"
maxW="full"
maxH="full"
borderRadius="base"
sx={imageSx}
data-testid={dataTestId}
/>
{withMetadataOverlay && <ImageMetadataOverlay imageDTO={imageDTO} />}
</Flex>
)}
{!imageDTO && !isUploadDisabled && (
<>
<Flex sx={uploadButtonStyles} {...getUploadButtonProps()}>
<input {...getUploadInputProps()} />
{uploadElement}
</Flex>
</>
)}
{!imageDTO && isUploadDisabled && noContentFallback}
{imageDTO && !isDragDisabled && (
<IAIDraggable
data={draggableData}
disabled={isDragDisabled || !imageDTO}
onClick={onClick}
onAuxClick={openInNewTab}
/>
)}
{children}
{!isDropDisabled && <IAIDroppable data={droppableData} disabled={isDropDisabled} dropLabel={dropLabel} />}
<Image
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError"
fallbackSrc={useThumbailFallback ? imageDTO.thumbnail_url : undefined}
fallback={useThumbailFallback ? undefined : <IAILoadingImageFallback image={imageDTO} />}
onError={onError}
draggable={false}
w={imageDTO.width}
objectFit="contain"
maxW="full"
maxH="full"
borderRadius="base"
sx={imageSx}
data-testid={dataTestId}
/>
{withMetadataOverlay && <ImageMetadataOverlay imageDTO={imageDTO} />}
</Flex>
)}
</ImageContextMenu>
{!imageDTO && !isUploadDisabled && (
<UploadButton
isUploadDisabled={isUploadDisabled}
postUploadAction={postUploadAction}
uploadElement={uploadElement}
minSize={minSize}
/>
)}
{!imageDTO && isUploadDisabled && noContentFallback}
{imageDTO && !isDragDisabled && (
<IAIDraggable
data={draggableData}
disabled={isDragDisabled || !imageDTO}
onClick={onClick}
onAuxClick={openInNewTab}
/>
)}
{children}
{!isDropDisabled && <IAIDroppable data={droppableData} disabled={isDropDisabled} dropLabel={dropLabel} />}
</Flex>
);
};
export default memo(IAIDndImage);
const UploadButton = memo(
({
isUploadDisabled,
postUploadAction,
uploadElement,
minSize,
}: {
isUploadDisabled: boolean;
postUploadAction?: PostUploadAction;
uploadElement: ReactNode;
minSize: number;
}) => {
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction,
isDisabled: isUploadDisabled,
});
const uploadButtonStyles = useMemo<SystemStyleObject>(() => {
const styles: SystemStyleObject = {
minH: minSize,
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
transitionProperty: 'common',
transitionDuration: '0.1s',
color: 'base.500',
};
if (!isUploadDisabled) {
Object.assign(styles, {
cursor: 'pointer',
bg: 'base.700',
_hover: {
bg: 'base.650',
color: 'base.300',
},
});
}
return styles;
}, [isUploadDisabled, minSize]);
return (
<Flex sx={uploadButtonStyles} {...getUploadButtonProps()}>
<input {...getUploadInputProps()} />
{uploadElement}
</Flex>
);
}
);
UploadButton.displayName = 'UploadButton';

View File

@@ -1,15 +1,14 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
isOver: boolean;
label?: string;
withBackdrop?: boolean;
};
const IAIDropOverlay = (props: Props) => {
const { t } = useTranslation();
const { isOver, label = t('gallery.drop') } = props;
const { isOver, label, withBackdrop = true } = props;
return (
<Flex position="absolute" top={0} right={0} bottom={0} left={0}>
<Flex
@@ -20,7 +19,7 @@ const IAIDropOverlay = (props: Props) => {
left={0}
w="full"
h="full"
bg="base.900"
bg={withBackdrop ? 'base.900' : 'transparent'}
opacity={0.7}
borderRadius="base"
alignItems="center"
@@ -45,16 +44,18 @@ const IAIDropOverlay = (props: Props) => {
alignItems="center"
justifyContent="center"
>
<Text
fontSize="lg"
fontWeight="semibold"
color={isOver ? 'invokeYellow.300' : 'base.500'}
transitionProperty="common"
transitionDuration="0.1s"
textAlign="center"
>
{label}
</Text>
{label && (
<Text
fontSize="lg"
fontWeight="semibold"
color={isOver ? 'invokeYellow.300' : 'base.500'}
transitionProperty="common"
transitionDuration="0.1s"
textAlign="center"
>
{label}
</Text>
)}
</Flex>
</Flex>
);

View File

@@ -1,22 +1,12 @@
import { Box, Flex, Heading } from '@invoke-ai/ui-library';
import type { AnimationProps } from 'framer-motion';
import { motion } from 'framer-motion';
import { useAppSelector } from 'app/store/storeHooks';
import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { memo } from 'react';
import type { DropzoneState } from 'react-dropzone';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
const initial: AnimationProps['initial'] = {
opacity: 0,
};
const animate: AnimationProps['animate'] = {
opacity: 1,
transition: { duration: 0.1 },
};
const exit: AnimationProps['exit'] = {
opacity: 0,
transition: { duration: 0.1 },
};
import { useBoardName } from 'services/api/hooks/useBoardName';
type ImageUploadOverlayProps = {
dropzone: DropzoneState;
@@ -24,7 +14,6 @@ type ImageUploadOverlayProps = {
};
const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
const { t } = useTranslation();
const { dropzone, setIsHandlingUpload } = props;
useHotkeys(
@@ -36,67 +25,65 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
);
return (
<Box
key="image-upload-overlay"
initial={initial}
animate={animate}
exit={exit}
as={motion.div}
position="absolute"
top={0}
insetInlineStart={0}
width="100dvw"
height="100dvh"
zIndex={999}
backdropFilter="blur(20px)"
>
<Box position="absolute" top={0} right={0} bottom={0} left={0} zIndex={999} backdropFilter="blur(20px)">
<Flex position="absolute" top={0} right={0} bottom={0} left={0} bg="base.900" opacity={0.7} />
<Flex
position="absolute"
top={0}
insetInlineStart={0}
w="full"
h="full"
bg="base.900"
opacity={0.7}
alignItems="center"
justifyContent="center"
flexDir="column"
gap={4}
top={2}
right={2}
bottom={2}
left={2}
opacity={1}
borderWidth={2}
borderColor={dropzone.isDragAccept ? 'invokeYellow.300' : 'error.500'}
borderRadius="base"
borderStyle="dashed"
transitionProperty="common"
transitionDuration="0.1s"
/>
<Flex
position="absolute"
top={0}
insetInlineStart={0}
width="full"
height="full"
alignItems="center"
justifyContent="center"
p={4}
color={dropzone.isDragReject ? 'error.300' : undefined}
>
<Flex
width="full"
height="full"
alignItems="center"
justifyContent="center"
flexDir="column"
gap={4}
borderWidth={3}
borderRadius="xl"
borderStyle="dashed"
color="base.100"
borderColor="base.200"
>
{dropzone.isDragAccept ? (
<Heading size="lg">{t('gallery.dropToUpload')}</Heading>
) : (
<>
<Heading size="lg">{t('toast.invalidUpload')}</Heading>
<Heading size="md">{t('toast.uploadFailedInvalidUploadDesc')}</Heading>
</>
)}
</Flex>
{dropzone.isDragAccept && <DragAcceptMessage />}
{!dropzone.isDragAccept && <DragRejectMessage />}
</Flex>
</Box>
);
};
export default memo(ImageUploadOverlay);
const DragAcceptMessage = () => {
const { t } = useTranslation();
const selectedBoardId = useAppSelector(selectSelectedBoardId);
const boardName = useBoardName(selectedBoardId);
return (
<>
<Heading size="lg">{t('gallery.dropToUpload')}</Heading>
<Heading size="md">{t('toast.imagesWillBeAddedTo', { boardName })}</Heading>
</>
);
};
const DragRejectMessage = () => {
const { t } = useTranslation();
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
if (maxImageUploadCount === undefined) {
return (
<>
<Heading size="lg">{t('toast.invalidUpload')}</Heading>
<Heading size="md">{t('toast.uploadFailedInvalidUploadDesc')}</Heading>
</>
);
}
return (
<>
<Heading size="lg">{t('toast.invalidUpload')}</Heading>
<Heading size="md">{t('toast.uploadFailedInvalidUploadDesc_withCount', { count: maxImageUploadCount })}</Heading>
</>
);
};

View File

@@ -1,6 +1,8 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { logger } from 'app/logging/logger';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useEffect, useState } from 'react';
@@ -10,89 +12,89 @@ import { useTranslation } from 'react-i18next';
import { useUploadImageMutation } from 'services/api/endpoints/images';
import type { PostUploadAction } from 'services/api/types';
const log = logger('gallery');
const accept: Accept = {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg', '.png'],
};
const selectPostUploadAction = createMemoizedSelector(selectActiveTab, (activeTabName) => {
let postUploadAction: PostUploadAction = { type: 'TOAST' };
if (activeTabName === 'upscaling') {
postUploadAction = { type: 'SET_UPSCALE_INITIAL_IMAGE' };
}
return postUploadAction;
});
export const useFullscreenDropzone = () => {
useAssertSingleton('useFullscreenDropzone');
const { t } = useTranslation();
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const postUploadAction = useAppSelector(selectPostUploadAction);
const [uploadImage] = useUploadImageMutation();
const activeTabName = useAppSelector(selectActiveTab);
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
setIsHandlingUpload(true);
toast({
id: 'UPLOAD_FAILED',
title: t('toast.uploadFailed'),
description: rejection.errors.map((error) => error.message).join('\n'),
status: 'error',
});
},
[t]
);
const fileAcceptedCallback = useCallback(
(file: File) => {
uploadImage({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
});
},
[autoAddBoardId, postUploadAction, uploadImage]
);
const getPostUploadAction = useCallback((): PostUploadAction => {
if (activeTabName === 'upscaling') {
return { type: 'SET_UPSCALE_INITIAL_IMAGE' };
} else {
return { type: 'TOAST' };
}
}, [activeTabName]);
const onDrop = useCallback(
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
if (fileRejections.length > 1) {
if (fileRejections.length > 0) {
const errors = fileRejections.map((rejection) => ({
errors: rejection.errors.map(({ message }) => message),
file: rejection.file.path,
}));
log.error({ errors }, 'Invalid upload');
const description =
maxImageUploadCount === undefined
? t('toast.uploadFailedInvalidUploadDesc')
: t('toast.uploadFailedInvalidUploadDesc_withCount', { count: maxImageUploadCount });
toast({
id: 'UPLOAD_FAILED',
title: t('toast.uploadFailed'),
description: t('toast.uploadFailedInvalidUploadDesc'),
description,
status: 'error',
});
setIsHandlingUpload(false);
return;
}
fileRejections.forEach((rejection: FileRejection) => {
fileRejectionCallback(rejection);
});
for (const [i, file] of acceptedFiles.entries()) {
uploadImage({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction: getPostUploadAction(),
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
// The `imageUploaded` listener does some extra logic, like switching to the asset view on upload on the
// first upload of a "batch".
isFirstUploadOfBatch: i === 0,
});
}
acceptedFiles.forEach((file: File) => {
fileAcceptedCallback(file);
});
setIsHandlingUpload(false);
},
[t, fileAcceptedCallback, fileRejectionCallback]
[t, maxImageUploadCount, uploadImage, getPostUploadAction, autoAddBoardId]
);
const onDragOver = useCallback(() => {
setIsHandlingUpload(true);
}, []);
const onDragLeave = useCallback(() => {
setIsHandlingUpload(false);
}, []);
const dropzone = useDropzone({
accept,
noClick: true,
onDrop,
onDragOver,
multiple: false,
onDragLeave,
noKeyboard: true,
multiple: maxImageUploadCount === undefined || maxImageUploadCount > 1,
maxFiles: maxImageUploadCount,
});
useEffect(() => {

View File

@@ -1,15 +1,23 @@
import { logger } from 'app/logging/logger';
import { useAppSelector } from 'app/store/storeHooks';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useUploadImageMutation } from 'services/api/endpoints/images';
import type { PostUploadAction } from 'services/api/types';
type UseImageUploadButtonArgs = {
postUploadAction?: PostUploadAction;
isDisabled?: boolean;
allowMultiple?: boolean;
};
const log = logger('gallery');
/**
* Provides image uploader functionality to any component.
*
@@ -29,28 +37,58 @@ type UseImageUploadButtonArgs = {
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
*/
export const useImageUploadButton = ({ postUploadAction, isDisabled }: UseImageUploadButtonArgs) => {
export const useImageUploadButton = ({
postUploadAction,
isDisabled,
allowMultiple = false,
}: UseImageUploadButtonArgs) => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const [uploadImage] = useUploadImageMutation();
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
const { t } = useTranslation();
const onDropAccepted = useCallback(
(files: File[]) => {
const file = files[0];
if (!file) {
return;
for (const [i, file] of files.entries()) {
uploadImage({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction: postUploadAction ?? { type: 'TOAST' },
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
isFirstUploadOfBatch: i === 0,
});
}
uploadImage({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction: postUploadAction ?? { type: 'TOAST' },
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
});
},
[autoAddBoardId, postUploadAction, uploadImage]
);
const onDropRejected = useCallback(
(fileRejections: FileRejection[]) => {
if (fileRejections.length > 0) {
const errors = fileRejections.map((rejection) => ({
errors: rejection.errors.map(({ message }) => message),
file: rejection.file.path,
}));
log.error({ errors }, 'Invalid upload');
const description =
maxImageUploadCount === undefined
? t('toast.uploadFailedInvalidUploadDesc')
: t('toast.uploadFailedInvalidUploadDesc_withCount', { count: maxImageUploadCount });
toast({
id: 'UPLOAD_FAILED',
title: t('toast.uploadFailed'),
description,
status: 'error',
});
return;
}
},
[maxImageUploadCount, t]
);
const {
getRootProps: getUploadButtonProps,
getInputProps: getUploadInputProps,
@@ -58,9 +96,11 @@ export const useImageUploadButton = ({ postUploadAction, isDisabled }: UseImageU
} = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
onDropAccepted,
onDropRejected,
disabled: isDisabled,
noDrag: true,
multiple: false,
multiple: allowMultiple && (maxImageUploadCount === undefined || maxImageUploadCount > 1),
maxFiles: maxImageUploadCount,
});
return { getUploadButtonProps, getUploadInputProps, openUploader };

View File

@@ -9,7 +9,6 @@ import {
isModalOpenChanged,
selectChangeBoardModalSlice,
} from 'features/changeBoardModal/store/slice';
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
@@ -29,8 +28,7 @@ const ChangeBoardModal = () => {
useAssertSingleton('ChangeBoardModal');
const dispatch = useAppDispatch();
const [selectedBoard, setSelectedBoard] = useState<string | null>();
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
const { data: boards, isFetching } = useListAllBoardsQuery(queryArgs);
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
const isModalOpen = useAppSelector(selectIsModalOpen);
const imagesToChange = useAppSelector(selectImagesToChange);
const [addImagesToBoard] = useAddImagesToBoardMutation();

View File

@@ -2,14 +2,10 @@ import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, Flex } from '@i
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
selectCanvasRightPanelGalleryTab,
selectCanvasRightPanelLayersTab,
} from 'features/controlLayers/store/ephemeral';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useCurrentDestination } from 'features/queue/hooks/useCurrentDestination';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
import { AnimatePresence, motion } from 'framer-motion';
import type { PropsWithChildren, ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
@@ -17,10 +13,11 @@ import { Trans, useTranslation } from 'react-i18next';
const ActivateImageViewerButton = (props: PropsWithChildren) => {
const imageViewer = useImageViewer();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
imageViewer.open();
selectCanvasRightPanelGalleryTab();
}, [imageViewer]);
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [imageViewer, dispatch]);
return (
<Button onClick={onClick} size="sm" variant="link" color="base.50">
{props.children}
@@ -60,7 +57,7 @@ const ActivateCanvasButton = (props: PropsWithChildren) => {
const imageViewer = useImageViewer();
const onClick = useCallback(() => {
dispatch(setActiveTab('canvas'));
selectCanvasRightPanelLayersTab();
dispatch(activeTabCanvasRightPanelChanged('layers'));
imageViewer.close();
}, [dispatch, imageViewer]);
return (

View File

@@ -1,31 +1,50 @@
import { useDndContext } from '@dnd-kit/core';
import { Box, Button, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
$canvasRightPanelTabIndex,
selectCanvasRightPanelGalleryTab,
selectCanvasRightPanelLayersTab,
} from 'features/controlLayers/store/ephemeral';
import { selectEntityCountActive } from 'features/controlLayers/store/selectors';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo, useCallback, useMemo, useRef } from 'react';
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const CanvasRightPanel = memo(() => {
const { t } = useTranslation();
const tabIndex = useStore($canvasRightPanelTabIndex);
const activeTab = useAppSelector(selectActiveTabCanvasRightPanel);
const imageViewer = useImageViewer();
const dispatch = useAppDispatch();
const tabIndex = useMemo(() => {
if (activeTab === 'gallery') {
return 1;
} else {
return 0;
}
}, [activeTab]);
const onClickViewerToggleButton = useCallback(() => {
if ($canvasRightPanelTabIndex.get() !== 1) {
$canvasRightPanelTabIndex.set(1);
if (activeTab !== 'gallery') {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}
imageViewer.toggle();
}, [imageViewer]);
}, [imageViewer, activeTab, dispatch]);
const onChangeTab = useCallback(
(index: number) => {
if (index === 0) {
dispatch(activeTabCanvasRightPanelChanged('layers'));
} else {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}
},
[dispatch]
);
useRegisteredHotkeys({
id: 'toggleViewer',
category: 'viewer',
@@ -34,7 +53,7 @@ export const CanvasRightPanel = memo(() => {
});
return (
<Tabs index={tabIndex} onChange={$canvasRightPanelTabIndex.set} w="full" h="full" display="flex" flexDir="column">
<Tabs index={tabIndex} onChange={onChangeTab} w="full" h="full" display="flex" flexDir="column">
<TabList alignItems="center">
<PanelTabs />
<Spacer />
@@ -60,27 +79,33 @@ CanvasRightPanel.displayName = 'CanvasRightPanel';
const PanelTabs = memo(() => {
const { t } = useTranslation();
const activeTab = useAppSelector(selectActiveTabCanvasRightPanel);
const activeEntityCount = useAppSelector(selectEntityCountActive);
const tabTimeout = useRef<number | null>(null);
const dndCtx = useDndContext();
const dispatch = useAppDispatch();
const [mouseOverTab, setMouseOverTab] = useState<'layers' | 'gallery' | null>(null);
const onOnMouseOverLayersTab = useCallback(() => {
setMouseOverTab('layers');
tabTimeout.current = window.setTimeout(() => {
if (dndCtx.active) {
selectCanvasRightPanelLayersTab();
dispatch(activeTabCanvasRightPanelChanged('layers'));
}
}, 300);
}, [dndCtx.active]);
}, [dndCtx.active, dispatch]);
const onOnMouseOverGalleryTab = useCallback(() => {
setMouseOverTab('gallery');
tabTimeout.current = window.setTimeout(() => {
if (dndCtx.active) {
selectCanvasRightPanelGalleryTab();
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}
}, 300);
}, [dndCtx.active]);
}, [dndCtx.active, dispatch]);
const onMouseOut = useCallback(() => {
setMouseOverTab(null);
if (tabTimeout.current) {
clearTimeout(tabTimeout.current);
}
@@ -99,9 +124,17 @@ const PanelTabs = memo(() => {
<Box as="span" w="full">
{layersTabLabel}
</Box>
{dndCtx.active && activeTab !== 'layers' && (
<IAIDropOverlay isOver={mouseOverTab === 'layers'} withBackdrop={false} />
)}
</Tab>
<Tab position="relative" onMouseOver={onOnMouseOverGalleryTab} onMouseOut={onMouseOut}>
{t('gallery.gallery')}
<Tab position="relative" onMouseOver={onOnMouseOverGalleryTab} onMouseOut={onMouseOut} w={32}>
<Box as="span" w="full">
{t('gallery.gallery')}
</Box>
{dndCtx.active && activeTab !== 'gallery' && (
<IAIDropOverlay isOver={mouseOverTab === 'gallery'} withBackdrop={false} />
)}
</Tab>
</>
);

View File

@@ -3,15 +3,12 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { newCanvasSessionRequested, newGallerySessionRequested } from 'features/controlLayers/store/actions';
import {
selectCanvasRightPanelGalleryTab,
selectCanvasRightPanelLayersTab,
} from 'features/controlLayers/store/ephemeral';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import {
selectSystemShouldConfirmOnNewSession,
shouldConfirmOnNewSessionToggled,
} from 'features/system/store/systemSlice';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,7 +24,7 @@ export const useNewGallerySession = () => {
const newGallerySessionImmediate = useCallback(() => {
dispatch(newGallerySessionRequested());
imageViewer.open();
selectCanvasRightPanelGalleryTab();
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch, imageViewer]);
const newGallerySessionWithDialog = useCallback(() => {
@@ -50,7 +47,7 @@ export const useNewCanvasSession = () => {
const newCanvasSessionImmediate = useCallback(() => {
dispatch(newCanvasSessionRequested());
imageViewer.close();
selectCanvasRightPanelLayersTab();
dispatch(activeTabCanvasRightPanelChanged('layers'));
}, [dispatch, imageViewer]);
const newCanvasSessionWithDialog = useCallback(() => {

View File

@@ -1,13 +1,11 @@
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
import { $canvasRightPanelTab } from 'features/controlLayers/store/ephemeral';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
export function useCanvasDeleteLayerHotkey() {
@@ -15,7 +13,7 @@ export function useCanvasDeleteLayerHotkey() {
const dispatch = useAppDispatch();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isBusy = useCanvasIsBusy();
const canvasRightPanelTab = useStore($canvasRightPanelTab);
const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel);
const appTab = useAppSelector(selectActiveTab);
const imageViewer = useImageViewer();

View File

@@ -36,7 +36,7 @@ export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | nu
return false;
}, [entityIdentifier, adapter, isBusy, isInteractable, isEmpty]);
const start = useCallback(() => {
const start = useCallback(async () => {
if (isDisabled) {
return;
}
@@ -50,10 +50,10 @@ export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | nu
if (!adapter) {
return;
}
adapter.transformer.startTransform();
await adapter.transformer.startTransform();
}, [isDisabled, entityIdentifier, canvasManager]);
const fitToBbox = useCallback(() => {
const fitToBbox = useCallback(async () => {
if (isDisabled) {
return;
}
@@ -67,9 +67,9 @@ export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | nu
if (!adapter) {
return;
}
adapter.transformer.startTransform({ silent: true });
await adapter.transformer.startTransform({ silent: true });
adapter.transformer.fitToBboxContain();
adapter.transformer.applyTransform();
await adapter.transformer.applyTransform();
}, [canvasManager, entityIdentifier, isDisabled]);
return { isDisabled, start, fitToBbox } as const;

View File

@@ -16,7 +16,8 @@ import {
selectIsolatedTransformingPreview,
} from 'features/controlLayers/store/canvasSettingsSlice';
import {
buildEntityIsHiddenSelector,
buildSelectIsHidden,
buildSelectIsSelected,
selectBboxRect,
selectCanvasSlice,
selectEntity,
@@ -165,6 +166,7 @@ export abstract class CanvasEntityAdapterBase<
};
selectIsHidden: Selector<RootState, boolean>;
selectIsSelected: Selector<RootState, boolean>;
/**
* The Konva nodes that make up the entity adapter:
@@ -248,7 +250,8 @@ export abstract class CanvasEntityAdapterBase<
assert(state !== undefined, 'Missing entity state on creation');
this.state = state;
this.selectIsHidden = buildEntityIsHiddenSelector(this.entityIdentifier);
this.selectIsHidden = buildSelectIsHidden(this.entityIdentifier);
this.selectIsSelected = buildSelectIsSelected(this.entityIdentifier);
/**
* There are a number of reason we may need to show or hide a layer:
@@ -257,6 +260,7 @@ export abstract class CanvasEntityAdapterBase<
* - Staging status changes and `isolatedStagingPreview` is enabled
* - Global filtering status changes and `isolatedFilteringPreview` is enabled
* - Global transforming status changes and `isolatedTransformingPreview` is enabled
* - The entity is selected or deselected (only selected and onscreen entities are rendered)
*/
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectIsHidden, this.syncVisibility));
this.subscriptions.add(
@@ -267,6 +271,7 @@ export abstract class CanvasEntityAdapterBase<
this.manager.stateApi.createStoreSubscription(selectIsolatedTransformingPreview, this.syncVisibility)
);
this.subscriptions.add(this.manager.stateApi.$transformingAdapter.listen(this.syncVisibility));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectIsSelected, this.syncVisibility));
/**
* The tool preview may need to be updated when the entity is locked or disabled. For example, when we disable the
@@ -305,21 +310,8 @@ export abstract class CanvasEntityAdapterBase<
syncIsOnscreen = () => {
const stageRect = this.manager.stage.getScaledStageRect();
const entityRect = this.transformer.$pixelRect.get();
const position = this.manager.stateApi.runSelector(this.selectPosition);
if (!position) {
return;
}
const entityRectRelativeToStage = {
x: entityRect.x + position.x,
y: entityRect.y + position.y,
width: entityRect.width,
height: entityRect.height,
};
const intersection = getRectIntersection(stageRect, entityRectRelativeToStage);
const isOnScreen = this.checkIntersection(stageRect);
const prevIsOnScreen = this.$isOnScreen.get();
const isOnScreen = intersection.width > 0 && intersection.height > 0;
this.$isOnScreen.set(isOnScreen);
if (prevIsOnScreen !== isOnScreen) {
this.log.trace(`Moved ${isOnScreen ? 'on-screen' : 'off-screen'}`);
@@ -329,10 +321,19 @@ export abstract class CanvasEntityAdapterBase<
syncIntersectsBbox = () => {
const bboxRect = this.manager.stateApi.getBbox().rect;
const intersectsBbox = this.checkIntersection(bboxRect);
const prevIntersectsBbox = this.$intersectsBbox.get();
this.$intersectsBbox.set(intersectsBbox);
if (prevIntersectsBbox !== intersectsBbox) {
this.log.trace(`Moved ${intersectsBbox ? 'into bbox' : 'out of bbox'}`);
}
};
checkIntersection = (rect: Rect): boolean => {
const entityRect = this.transformer.$pixelRect.get();
const position = this.manager.stateApi.runSelector(this.selectPosition);
if (!position) {
return;
return false;
}
const entityRectRelativeToStage = {
x: entityRect.x + position.x,
@@ -340,14 +341,9 @@ export abstract class CanvasEntityAdapterBase<
width: entityRect.width,
height: entityRect.height,
};
const intersection = getRectIntersection(bboxRect, entityRectRelativeToStage);
const prevIntersectsBbox = this.$intersectsBbox.get();
const intersectsBbox = intersection.width > 0 && intersection.height > 0;
this.$intersectsBbox.set(intersectsBbox);
if (prevIntersectsBbox !== intersectsBbox) {
this.log.trace(`Moved ${intersectsBbox ? 'into bbox' : 'out of bbox'}`);
}
const intersection = getRectIntersection(rect, entityRectRelativeToStage);
const doesIntersect = intersection.width > 0 && intersection.height > 0;
return doesIntersect;
};
initialize = async () => {

View File

@@ -407,7 +407,9 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
*/
fitToBboxFill = () => {
if (!this.$isTransformEnabled.get()) {
this.log.warn('Cannot fit to bbox contain when transform is disabled');
this.log.warn(
'Cannot fit to bbox contain when transform is disabled. Did you forget to call `await adapter.transformer.startTransform()`?'
);
return;
}
const { rect } = this.manager.stateApi.getBbox();
@@ -428,7 +430,9 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
*/
fitToBboxContain = () => {
if (!this.$isTransformEnabled.get()) {
this.log.warn('Cannot fit to bbox contain when transform is disabled');
this.log.warn(
'Cannot fit to bbox contain when transform is disabled. Did you forget to call `await adapter.transformer.startTransform()`?'
);
return;
}
const { rect } = this.manager.stateApi.getBbox();
@@ -460,7 +464,9 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
*/
fitToBboxCover = () => {
if (!this.$isTransformEnabled.get()) {
this.log.warn('Cannot fit to bbox contain when transform is disabled');
this.log.warn(
'Cannot fit to bbox contain when transform is disabled. Did you forget to call `await adapter.transformer.startTransform()`?'
);
return;
}
const { rect } = this.manager.stateApi.getBbox();
@@ -653,9 +659,20 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
/**
* Starts the transformation of the entity.
*
* This method will asynchronously acquire a mutex to prevent concurrent operations. If you need to perform an
* operation after the transformation is started, you should await this method.
*
* @param arg Options for starting the transformation
* @param arg.silent Whether the transformation should be silent. If silent, the transform controls will not be shown,
* so you _must_ immediately call `applyTransform` or `stopTransform` to complete the transformation.
* so you _must_ call `applyTransform` or `stopTransform` to complete the transformation.
*
* @example
* ```ts
* await adapter.transformer.startTransform({ silent: true });
* adapter.transformer.fitToBboxContain();
* await adapter.transformer.applyTransform();
* ```
*/
startTransform = async (arg?: { silent: boolean }) => {
const transformingAdapter = this.manager.stateApi.$transformingAdapter.get();
@@ -676,6 +693,12 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
* Applies the transformation of the entity.
*/
applyTransform = async () => {
if (!this.$isTransforming.get()) {
this.log.warn(
'Cannot apply transform when not transforming. Did you forget to call `await adapter.transformer.startTransform()`?'
);
return;
}
this.log.debug('Applying transform');
this.$isProcessing.set(true);
this._setInteractionMode('off');

View File

@@ -1,5 +1,5 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { atom, computed } from 'nanostores';
import { atom } from 'nanostores';
// Ephemeral state for canvas - not persisted across sessions.
@@ -7,16 +7,3 @@ import { atom, computed } from 'nanostores';
* The global canvas manager instance.
*/
export const $canvasManager = atom<CanvasManager | null>(null);
/**
* The index of the active tab in the canvas right panel.
*/
export const $canvasRightPanelTabIndex = atom(0);
/**
* The name of the active tab in the canvas right panel.
*/
export const $canvasRightPanelTab = computed($canvasRightPanelTabIndex, (index) =>
index === 0 ? 'layers' : 'gallery'
);
export const selectCanvasRightPanelLayersTab = () => $canvasRightPanelTabIndex.set(0);
export const selectCanvasRightPanelGalleryTab = () => $canvasRightPanelTabIndex.set(1);

View File

@@ -308,7 +308,7 @@ const getSelectIsTypeHidden = (type: CanvasEntityType) => {
/**
* Builds a selector taht selects if the entity is hidden.
*/
export const buildEntityIsHiddenSelector = (entityIdentifier: CanvasEntityIdentifier) => {
export const buildSelectIsHidden = (entityIdentifier: CanvasEntityIdentifier) => {
const selectIsTypeHidden = getSelectIsTypeHidden(entityIdentifier.type);
return createSelector(
[selectCanvasSlice, selectIsTypeHidden, selectIsStaging, selectIsolatedStagingPreview],
@@ -339,6 +339,16 @@ export const buildEntityIsHiddenSelector = (entityIdentifier: CanvasEntityIdenti
);
};
/**
* Builds a selector taht selects if the entity is selected.
*/
export const buildSelectIsSelected = (entityIdentifier: CanvasEntityIdentifier) => {
return createSelector(
selectSelectedEntityIdentifier,
(selectedEntityIdentifier) => selectedEntityIdentifier?.id === entityIdentifier.id
);
};
export const selectWidth = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.rect.width);
export const selectHeight = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.rect.height);
export const selectAspectRatioID = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.aspectRatio.id);

View File

@@ -0,0 +1,86 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectBoardsListOrderBy, selectBoardsListOrderDir } from 'features/gallery/store/gallerySelectors';
import { boardsListOrderByChanged, boardsListOrderDirChanged } from 'features/gallery/store/gallerySlice';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
const zOrderBy = z.enum(['created_at', 'board_name']);
type OrderBy = z.infer<typeof zOrderBy>;
const isOrderBy = (v: unknown): v is OrderBy => zOrderBy.safeParse(v).success;
const zDirection = z.enum(['ASC', 'DESC']);
type Direction = z.infer<typeof zDirection>;
const isDirection = (v: unknown): v is Direction => zDirection.safeParse(v).success;
export const BoardsListSortControls = () => {
const { t } = useTranslation();
const orderBy = useAppSelector(selectBoardsListOrderBy);
const direction = useAppSelector(selectBoardsListOrderDir);
const ORDER_BY_OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'created_at', label: t('workflows.created') },
{ value: 'board_name', label: t('workflows.name') },
],
[t]
);
const DIRECTION_OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'ASC', label: t('workflows.ascending') },
{ value: 'DESC', label: t('workflows.descending') },
],
[t]
);
const dispatch = useAppDispatch();
const onChangeOrderBy = useCallback<ComboboxOnChange>(
(v) => {
if (!isOrderBy(v?.value) || v.value === orderBy) {
return;
}
dispatch(boardsListOrderByChanged(v.value));
},
[orderBy, dispatch]
);
const valueOrderBy = useMemo(() => {
return ORDER_BY_OPTIONS.find((o) => o.value === orderBy) || ORDER_BY_OPTIONS[0];
}, [orderBy, ORDER_BY_OPTIONS]);
const onChangeDirection = useCallback<ComboboxOnChange>(
(v) => {
if (!isDirection(v?.value) || v.value === direction) {
return;
}
dispatch(boardsListOrderDirChanged(v.value));
},
[direction, dispatch]
);
const valueDirection = useMemo(
() => DIRECTION_OPTIONS.find((o) => o.value === direction),
[direction, DIRECTION_OPTIONS]
);
return (
<Flex flexDir="column" gap={4}>
<FormControl orientation="horizontal" gap={1}>
<FormLabel>{t('common.orderBy')}</FormLabel>
<Combobox isSearchable={false} value={valueOrderBy} options={ORDER_BY_OPTIONS} onChange={onChangeOrderBy} />
</FormControl>
<FormControl orientation="horizontal" gap={1}>
<FormLabel>{t('common.direction')}</FormLabel>
<Combobox
isSearchable={false}
value={valueDirection}
options={DIRECTION_OPTIONS}
onChange={onChangeDirection}
/>
</FormControl>
</Flex>
);
};

View File

@@ -0,0 +1,53 @@
import {
Box,
Divider,
Flex,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import BoardAutoAddSelect from 'features/gallery/components/Boards/BoardAutoAddSelect';
import AutoAssignBoardCheckbox from 'features/gallery/components/GallerySettingsPopover/AutoAssignBoardCheckbox';
import ShowArchivedBoardsCheckbox from 'features/gallery/components/GallerySettingsPopover/ShowArchivedBoardsCheckbox';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiGearSixFill } from 'react-icons/pi';
import { BoardsListSortControls } from './BoardsListSortControls';
const BoardsSettingsPopover = () => {
const { t } = useTranslation();
return (
<Popover isLazy>
<PopoverTrigger>
<IconButton
size="sm"
variant="link"
alignSelf="stretch"
aria-label={t('gallery.boardsSettings')}
icon={<PiGearSixFill />}
tooltip={t('gallery.boardsSettings')}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverBody>
<Flex direction="column" gap={2}>
<AutoAssignBoardCheckbox />
<ShowArchivedBoardsCheckbox />
<BoardAutoAddSelect />
<Box py={2}>
<Divider />
</Box>
<BoardsListSortControls />
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default memo(BoardsSettingsPopover);

View File

@@ -23,6 +23,8 @@ import { useTranslation } from 'react-i18next';
import { PiMagnifyingGlassBold } from 'react-icons/pi';
import { useBoardName } from 'services/api/hooks/useBoardName';
import GallerySettingsPopover from './GallerySettingsPopover/GallerySettingsPopover';
import { GalleryUploadButton } from './GalleryUploadButton';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
import { GalleryPagination } from './ImageGrid/GalleryPagination';
import { GallerySearch } from './ImageGrid/GallerySearch';
@@ -85,15 +87,19 @@ export const Gallery = () => {
{t('gallery.assets')}
</Tab>
</Tooltip>
<IconButton
size="sm"
variant="link"
alignSelf="stretch"
onClick={handleClickSearch}
tooltip={searchDisclosure.isOpen ? `${t('gallery.exitSearch')}` : `${t('gallery.displaySearch')}`}
aria-label={t('gallery.displaySearch')}
icon={<PiMagnifyingGlassBold />}
/>
<Flex h="full" justifyContent="flex-end">
<GalleryUploadButton />
<GallerySettingsPopover />
<IconButton
size="sm"
variant="link"
alignSelf="stretch"
onClick={handleClickSearch}
tooltip={searchDisclosure.isOpen ? `${t('gallery.exitSearch')}` : `${t('gallery.displaySearch')}`}
aria-label={t('gallery.displaySearch')}
icon={<PiMagnifyingGlassBold />}
/>
</Flex>
</TabList>
</Tabs>

View File

@@ -15,8 +15,8 @@ import { Panel, PanelGroup } from 'react-resizable-panels';
import BoardsListWrapper from './Boards/BoardsList/BoardsListWrapper';
import BoardsSearch from './Boards/BoardsList/BoardsSearch';
import BoardsSettingsPopover from './Boards/BoardsSettingsPopover';
import { Gallery } from './Gallery';
import GallerySettingsPopover from './GallerySettingsPopover/GallerySettingsPopover';
const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 };
@@ -64,7 +64,7 @@ const GalleryPanelContent = () => {
</Flex>
<GalleryHeader />
<Flex h="full" w="25%" justifyContent="flex-end">
<GallerySettingsPopover />
<BoardsSettingsPopover />
<IconButton
size="sm"
variant="link"

View File

@@ -1,10 +1,7 @@
import { Divider, Flex, IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
import BoardAutoAddSelect from 'features/gallery/components/Boards/BoardAutoAddSelect';
import AlwaysShowImageSizeCheckbox from 'features/gallery/components/GallerySettingsPopover/AlwaysShowImageSizeCheckbox';
import AutoAssignBoardCheckbox from 'features/gallery/components/GallerySettingsPopover/AutoAssignBoardCheckbox';
import AutoSwitchCheckbox from 'features/gallery/components/GallerySettingsPopover/AutoSwitchCheckbox';
import ImageMinimumWidthSlider from 'features/gallery/components/GallerySettingsPopover/ImageMinimumWidthSlider';
import ShowArchivedBoardsCheckbox from 'features/gallery/components/GallerySettingsPopover/ShowArchivedBoardsCheckbox';
import ShowStarredFirstCheckbox from 'features/gallery/components/GallerySettingsPopover/ShowStarredFirstCheckbox';
import SortDirectionCombobox from 'features/gallery/components/GallerySettingsPopover/SortDirectionCombobox';
import { memo } from 'react';
@@ -21,8 +18,9 @@ const GallerySettingsPopover = () => {
size="sm"
variant="link"
alignSelf="stretch"
aria-label={t('gallery.gallerySettings')}
aria-label={t('gallery.imagesSettings')}
icon={<PiGearSixFill />}
tooltip={t('gallery.imagesSettings')}
/>
</PopoverTrigger>
<PopoverContent>
@@ -30,10 +28,7 @@ const GallerySettingsPopover = () => {
<Flex direction="column" gap={2}>
<ImageMinimumWidthSlider />
<AutoSwitchCheckbox />
<AutoAssignBoardCheckbox />
<AlwaysShowImageSizeCheckbox />
<ShowArchivedBoardsCheckbox />
<BoardAutoAddSelect />
<Divider pt={2} />
<ShowStarredFirstCheckbox />
<SortDirectionCombobox />

View File

@@ -0,0 +1,35 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { t } from 'i18next';
import { PiUploadBold } from 'react-icons/pi';
const options = { postUploadAction: { type: 'TOAST' }, allowMultiple: true } as const;
export const GalleryUploadButton = () => {
const uploadApi = useImageUploadButton(options);
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
return (
<>
<IconButton
size="sm"
alignSelf="stretch"
variant="link"
aria-label={
maxImageUploadCount === undefined || maxImageUploadCount > 1
? t('accessibility.uploadImages')
: t('accessibility.uploadImage')
}
tooltip={
maxImageUploadCount === undefined || maxImageUploadCount > 1
? t('accessibility.uploadImages')
: t('accessibility.uploadImage')
}
icon={<PiUploadBold />}
{...uploadApi.getUploadButtonProps()}
/>
<input {...uploadApi.getUploadInputProps()} />
</>
);
};

View File

@@ -1,42 +1,276 @@
import type { ContextMenuProps } from '@invoke-ai/ui-library';
import { ContextMenu, MenuList } from '@invoke-ai/ui-library';
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import MultipleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import { selectSelectionCount } from 'features/gallery/store/gallerySelectors';
import { memo, useCallback } from 'react';
import { map } from 'nanostores';
import type { RefObject } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import type { ImageDTO } from 'services/api/types';
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
import SingleSelectionMenuItems from './SingleSelectionMenuItems';
/**
* The delay in milliseconds before the context menu opens on long press.
*/
const LONGPRESS_DELAY_MS = 500;
/**
* The threshold in pixels that the pointer must move before the long press is cancelled.
*/
const LONGPRESS_MOVE_THRESHOLD_PX = 10;
type Props = {
imageDTO: ImageDTO | undefined;
children: ContextMenuProps<HTMLDivElement>['children'];
/**
* The singleton state of the context menu.
*/
const $imageContextMenuState = map<{
isOpen: boolean;
imageDTO: ImageDTO | null;
position: { x: number; y: number };
}>({
isOpen: false,
imageDTO: null,
position: { x: -1, y: -1 },
});
/**
* Convenience function to close the context menu.
*/
const onClose = () => {
$imageContextMenuState.setKey('isOpen', false);
};
const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selectionCount = useAppSelector(selectSelectionCount);
/**
* Map of elements to image DTOs. This is used to determine which image DTO to show the context menu for, depending on
* the target of the context menu or long press event.
*/
const elToImageMap = new Map<HTMLDivElement, ImageDTO>();
/**
* Given a target node, find the first registered parent element that contains the target node and return the imageDTO
* associated with it.
*/
const getImageDTOFromMap = (target: Node): ImageDTO | undefined => {
const entry = Array.from(elToImageMap.entries()).find((entry) => entry[0].contains(target));
return entry?.[1];
};
/**
* Register a context menu for an image DTO on a target element.
* @param imageDTO The image DTO to register the context menu for.
* @param targetRef The ref of the target element that should trigger the context menu.
*/
export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: RefObject<HTMLDivElement>) => {
useEffect(() => {
if (!targetRef.current || !imageDTO) {
return;
}
const el = targetRef.current;
elToImageMap.set(el, imageDTO);
return () => {
elToImageMap.delete(el);
};
}, [imageDTO, targetRef]);
};
/**
* Singleton component that renders the context menu for images.
*/
export const ImageContextMenu = memo(() => {
useAssertSingleton('ImageContextMenu');
const state = useStore($imageContextMenuState);
useGlobalMenuClose(onClose);
return (
<Portal>
<Menu isOpen={state.isOpen} gutter={0} placement="auto-end" onClose={onClose}>
<MenuButton
aria-hidden={true}
w={1}
h={1}
position="absolute"
left={state.position.x}
top={state.position.y}
cursor="default"
bg="transparent"
_hover={_hover}
pointerEvents="none"
/>
<MenuContent />
</Menu>
<ImageContextMenuEventLogical />
</Portal>
);
});
ImageContextMenu.displayName = 'ImageContextMenu';
const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
/**
* A logical component that listens for context menu events and opens the context menu. It's separate from
* ImageContextMenu component to avoid re-rendering the whole context menu on every context menu event.
*/
const ImageContextMenuEventLogical = memo(() => {
const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 });
const longPressTimeoutRef = useRef(0);
const animationTimeoutRef = useRef(0);
const onContextMenu = useCallback((e: MouseEvent | PointerEvent) => {
if (e.shiftKey) {
// This is a shift + right click event, which should open the native context menu
onClose();
return;
}
const imageDTO = getImageDTOFromMap(e.target as Node);
const renderMenuFunc = useCallback(() => {
if (!imageDTO) {
return null;
// Can't find the image DTO, close the context menu
onClose();
return;
}
if (selectionCount > 1) {
return (
<MenuList visibility="visible">
<MultipleSelectionMenuItems />
</MenuList>
);
// clear pending delayed open
window.clearTimeout(animationTimeoutRef.current);
e.preventDefault();
if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) {
// if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
if ($imageContextMenuState.get().isOpen) {
onClose();
}
animationTimeoutRef.current = window.setTimeout(() => {
// Open the menu after the animation with the new state
$imageContextMenuState.set({
isOpen: true,
position: { x: e.pageX, y: e.pageY },
imageDTO,
});
}, 100);
} else {
// else we can just open the menu at the current position w/ new state
$imageContextMenuState.set({
isOpen: true,
position: { x: e.pageX, y: e.pageY },
imageDTO,
});
}
// Always sync the last position
lastPositionRef.current = { x: e.pageX, y: e.pageY };
}, []);
// Use a long press to open the context menu on touch devices
const onPointerDown = useCallback(
(e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
longPressTimeoutRef.current = window.setTimeout(() => {
onContextMenu(e);
}, LONGPRESS_DELAY_MS);
lastPositionRef.current = { x: e.pageX, y: e.pageY };
},
[onContextMenu]
);
const onPointerMove = useCallback((e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
if (longPressTimeoutRef.current === null) {
return;
}
// If the pointer has moved more than the threshold, cancel the long press
const lastPosition = lastPositionRef.current;
const distanceFromLastPosition = Math.hypot(e.pageX - lastPosition.x, e.pageY - lastPosition.y);
if (distanceFromLastPosition > LONGPRESS_MOVE_THRESHOLD_PX) {
clearTimeout(longPressTimeoutRef.current);
}
}, []);
const onPointerUp = useCallback((e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
}
}, []);
const onPointerCancel = useCallback((e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
}
}, []);
useEffect(() => {
const controller = new AbortController();
// Context menu events
window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal });
// Long press events
window.addEventListener('pointerdown', onPointerDown, { signal: controller.signal });
window.addEventListener('pointerup', onPointerUp, { signal: controller.signal });
window.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal });
window.addEventListener('pointermove', onPointerMove, { signal: controller.signal });
return () => {
controller.abort();
};
}, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
useEffect(
() => () => {
// Clean up any timeouts when we unmount
window.clearTimeout(animationTimeoutRef.current);
window.clearTimeout(longPressTimeoutRef.current);
},
[]
);
return null;
});
ImageContextMenuEventLogical.displayName = 'ImageContextMenuEventLogical';
// The content of the context menu, which changes based on the selection count. Split out and memoized to avoid
// re-rendering the whole context menu too often.
const MenuContent = memo(() => {
const selectionCount = useAppSelector(selectSelectionCount);
const state = useStore($imageContextMenuState);
if (!state.imageDTO) {
return null;
}
if (selectionCount > 1) {
return (
<MenuList visibility="visible">
<SingleSelectionMenuItems imageDTO={imageDTO} />
<MultipleSelectionMenuItems />
</MenuList>
);
}, [imageDTO, selectionCount]);
}
return <ContextMenu renderMenu={renderMenuFunc}>{children}</ContextMenu>;
};
return (
<MenuList visibility="visible">
<SingleSelectionMenuItems imageDTO={state.imageDTO} />
</MenuList>
);
});
export default memo(ImageContextMenu);
MenuContent.displayName = 'MenuContent';

View File

@@ -63,12 +63,9 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name),
[imageDTO.image_name]
);
const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO);
const customStarUi = useStore($customStarUI);
const imageContainerRef = useScrollIntoView(isSelected, index, areMultiplesSelected);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
@@ -91,20 +88,6 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
}
}, [imageDTO, selectedBoardId, areMultiplesSelected]);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
const toggleStarredState = useCallback(() => {
if (imageDTO) {
if (imageDTO.starred) {
unstarImages({ imageDTOs: [imageDTO] });
}
if (!imageDTO.starred) {
starImages({ imageDTOs: [imageDTO] });
}
}
}, [starImages, unstarImages, imageDTO]);
const [isHovered, setIsHovered] = useState(false);
const handleMouseOver = useCallback(() => {
@@ -121,25 +104,6 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
setIsHovered(false);
}, []);
const starIcon = useMemo(() => {
if (imageDTO.starred) {
return customStarUi ? customStarUi.on.icon : <PiStarFill />;
}
if (!imageDTO.starred && isHovered) {
return customStarUi ? customStarUi.off.icon : <PiStarBold />;
}
}, [imageDTO.starred, isHovered, customStarUi]);
const starTooltip = useMemo(() => {
if (imageDTO.starred) {
return customStarUi ? customStarUi.off.text : 'Unstar';
}
if (!imageDTO.starred) {
return customStarUi ? customStarUi.on.text : 'Star';
}
return '';
}, [imageDTO.starred, customStarUi]);
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);
if (!imageDTO) {
@@ -155,6 +119,8 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
justifyContent="center"
alignItems="center"
aspectRatio="1/1"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
>
<IAIDndImage
onClick={handleClick}
@@ -169,38 +135,8 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
isUploadDisabled={true}
thumbnail={true}
withHoverOverlay
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
>
<>
{(isHovered || alwaysShowImageSizeBadge) && (
<Text
position="absolute"
background="base.900"
color="base.50"
fontSize="sm"
fontWeight="semibold"
bottom={1}
left={1}
opacity={0.7}
px={2}
lineHeight={1.25}
borderTopEndRadius="base"
sx={badgeSx}
pointerEvents="none"
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
)}
<IAIDndImageIcon
onClick={toggleStarredState}
icon={starIcon}
tooltip={starTooltip}
position="absolute"
top={2}
insetInlineEnd={2}
/>
{isHovered && <DeleteIcon imageDTO={imageDTO} />}
{isHovered && <OpenInViewerIconButton imageDTO={imageDTO} />}
</>
<HoverIcons imageDTO={imageDTO} isHovered={isHovered} />
</IAIDndImage>
</Flex>
</Box>
@@ -209,7 +145,21 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
GalleryImageContent.displayName = 'GalleryImageContent';
const DeleteIcon = ({ imageDTO }: { imageDTO: ImageDTO }) => {
const HoverIcons = memo(({ imageDTO, isHovered }: { imageDTO: ImageDTO; isHovered: boolean }) => {
const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
return (
<>
{(isHovered || alwaysShowImageSizeBadge) && <SizeBadge imageDTO={imageDTO} />}
{(isHovered || imageDTO.starred) && <StarIcon imageDTO={imageDTO} />}
{isHovered && <DeleteIcon imageDTO={imageDTO} />}
{isHovered && <OpenInViewerIconButton imageDTO={imageDTO} />}
</>
);
});
HoverIcons.displayName = 'HoverIcons';
const DeleteIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const shift = useShiftModifier();
const { t } = useTranslation();
const dispatch = useAppDispatch();
@@ -238,9 +188,11 @@ const DeleteIcon = ({ imageDTO }: { imageDTO: ImageDTO }) => {
insetInlineEnd={2}
/>
);
};
});
const OpenInViewerIconButton = ({ imageDTO }: { imageDTO: ImageDTO }) => {
DeleteIcon.displayName = 'DeleteIcon';
const OpenInViewerIconButton = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const imageViewer = useImageViewer();
const { t } = useTranslation();
@@ -258,4 +210,77 @@ const OpenInViewerIconButton = ({ imageDTO }: { imageDTO: ImageDTO }) => {
insetInlineStart={2}
/>
);
};
});
OpenInViewerIconButton.displayName = 'OpenInViewerIconButton';
const StarIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const customStarUi = useStore($customStarUI);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
const toggleStarredState = useCallback(() => {
if (imageDTO) {
if (imageDTO.starred) {
unstarImages({ imageDTOs: [imageDTO] });
}
if (!imageDTO.starred) {
starImages({ imageDTOs: [imageDTO] });
}
}
}, [starImages, unstarImages, imageDTO]);
const starIcon = useMemo(() => {
if (imageDTO.starred) {
return customStarUi ? customStarUi.on.icon : <PiStarFill />;
}
if (!imageDTO.starred) {
return customStarUi ? customStarUi.off.icon : <PiStarBold />;
}
}, [imageDTO.starred, customStarUi]);
const starTooltip = useMemo(() => {
if (imageDTO.starred) {
return customStarUi ? customStarUi.off.text : 'Unstar';
}
if (!imageDTO.starred) {
return customStarUi ? customStarUi.on.text : 'Star';
}
return '';
}, [imageDTO.starred, customStarUi]);
return (
<IAIDndImageIcon
onClick={toggleStarredState}
icon={starIcon}
tooltip={starTooltip}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
});
StarIcon.displayName = 'StarIcon';
const SizeBadge = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
return (
<Text
position="absolute"
background="base.900"
color="base.50"
fontSize="sm"
fontWeight="semibold"
bottom={1}
left={1}
opacity={0.7}
px={2}
lineHeight={1.25}
borderTopEndRadius="base"
sx={badgeSx}
pointerEvents="none"
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
);
});
SizeBadge.displayName = 'SizeBadge';

View File

@@ -1,11 +1,11 @@
import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library';
import { ELLIPSIS, useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { useCallback } from 'react';
import { memo, useCallback } from 'react';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
import { JumpTo } from './JumpTo';
export const GalleryPagination = () => {
export const GalleryPagination = memo(() => {
const { goPrev, goNext, isPrevEnabled, isNextEnabled, pageButtons, goToPage, currentPage, total } =
useGalleryPagination();
@@ -47,7 +47,9 @@ export const GalleryPagination = () => {
<JumpTo />
</Flex>
);
};
});
GalleryPagination.displayName = 'GalleryPagination';
type PageButtonProps = {
page: number | typeof ELLIPSIS;
@@ -55,7 +57,7 @@ type PageButtonProps = {
goToPage: (page: number) => void;
};
const PageButton = ({ page, currentPage, goToPage }: PageButtonProps) => {
const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => {
if (page === ELLIPSIS) {
return (
<Button size="sm" variant="link" isDisabled>
@@ -68,4 +70,6 @@ const PageButton = ({ page, currentPage, goToPage }: PageButtonProps) => {
{page}
</Button>
);
};
});
PageButton.displayName = 'PageButton';

View File

@@ -11,11 +11,11 @@ import {
useDisclosure,
} from '@invoke-ai/ui-library';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
export const JumpTo = () => {
export const JumpTo = memo(() => {
const { t } = useTranslation();
const { goToPage, currentPage, pages } = useGalleryPagination();
const [newPage, setNewPage] = useState(currentPage);
@@ -64,7 +64,7 @@ export const JumpTo = () => {
}, [currentPage]);
return (
<Popover isOpen={isOpen} onClose={onClose} onOpen={onOpen}>
<Popover isOpen={isOpen} onClose={onClose} onOpen={onOpen} isLazy lazyBehavior="unmount">
<PopoverTrigger>
<Button aria-label={t('gallery.jump')} size="sm" onClick={onToggle} variant="outline">
{t('gallery.jump')}
@@ -94,4 +94,6 @@ export const JumpTo = () => {
</PopoverContent>
</Popover>
);
};
});
JumpTo.displayName = 'JumpTo';

View File

@@ -33,6 +33,7 @@ const ImageMetadataActions = (props: Props) => {
<MetadataItem metadata={metadata} handlers={handlers.scheduler} />
<MetadataItem metadata={metadata} handlers={handlers.cfgScale} />
<MetadataItem metadata={metadata} handlers={handlers.cfgRescaleMultiplier} />
<MetadataItem metadata={metadata} handlers={handlers.guidance} />
{activeTabName !== 'canvas' && <MetadataItem metadata={metadata} handlers={handlers.strength} />}
<MetadataItem metadata={metadata} handlers={handlers.hrfEnabled} />
<MetadataItem metadata={metadata} handlers={handlers.hrfMethod} />

View File

@@ -1,13 +1,38 @@
import { Flex, Spinner, Text } from '@invoke-ai/ui-library';
import { Button, Divider, Flex, Spinner, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { InvokeLogoIcon } from 'common/components/InvokeLogoIcon';
import { LOADING_SYMBOL, useHasImages } from 'features/gallery/hooks/useHasImages';
import { $installModelsTab } from 'features/modelManagerV2/subpanels/InstallModels';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectIsLocal } from 'features/system/store/configSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiImageBold } from 'react-icons/pi';
import { useMainModels } from 'services/api/hooks/modelsByType';
export const NoContentForViewer = () => {
const hasImages = useHasImages();
const [mainModels, { data }] = useMainModels();
const isLocal = useAppSelector(selectIsLocal);
const isEnabled = useFeatureStatus('starterModels');
const { t } = useTranslation();
const dispatch = useAppDispatch();
const handleClickDownloadStarterModels = useCallback(() => {
dispatch(setActiveTab('models'));
$installModelsTab.set(3);
}, [dispatch]);
const handleClickImportModels = useCallback(() => {
dispatch(setActiveTab('models'));
$installModelsTab.set(0);
}, [dispatch]);
const showStarterBundles = useMemo(() => {
return isEnabled && data && mainModels.length === 0;
}, [mainModels.length, data, isEnabled]);
if (hasImages === LOADING_SYMBOL) {
return (
@@ -28,32 +53,64 @@ export const NoContentForViewer = () => {
return (
<Flex flexDir="column" gap={4} alignItems="center" textAlign="center" maxW="600px">
<InvokeLogoIcon w={40} h={40} />
<Text fontSize="md" color="base.200" pt={16}>
<Trans
i18nKey="newUserExperience.toGetStarted"
components={{
StrongComponent: <Text as="span" color="white" fontSize="md" fontWeight="semibold" />,
}}
/>
</Text>
<Flex flexDir="column" gap={8} alignItems="center" textAlign="center">
<Text fontSize="md" color="base.200" pt={16}>
{isLocal ? (
<Trans
i18nKey="newUserExperience.toGetStartedLocal"
components={{
StrongComponent: <Text as="span" color="white" fontSize="md" fontWeight="semibold" />,
}}
/>
) : (
<Trans
i18nKey="newUserExperience.toGetStarted"
components={{
StrongComponent: <Text as="span" color="white" fontSize="md" fontWeight="semibold" />,
}}
/>
)}
</Text>
<Text fontSize="md" color="base.200">
<Trans
i18nKey="newUserExperience.gettingStartedSeries"
components={{
LinkComponent: (
<Text
as="a"
color="white"
fontSize="md"
fontWeight="semibold"
href="https://www.youtube.com/playlist?list=PLvWK1Kc8iXGrQy8r9TYg6QdUuJ5MMx-ZO"
target="_blank"
/>
),
}}
/>
</Text>
{showStarterBundles && (
<Flex flexDir="column" gap={2} alignItems="center">
<Text fontSize="md" color="base.200">
{t('newUserExperience.noModelsInstalled')}
</Text>
<Flex gap={3} alignItems="center">
<Button size="sm" onClick={handleClickDownloadStarterModels}>
{t('newUserExperience.downloadStarterModels')}
</Button>
<Text fontSize="sm" color="base.200">
{t('common.or')}
</Text>
<Button size="sm" onClick={handleClickImportModels}>
{t('newUserExperience.importModels')}
</Button>
</Flex>
</Flex>
)}
<Divider />
<Text fontSize="md" color="base.200">
<Trans
i18nKey="newUserExperience.gettingStartedSeries"
components={{
LinkComponent: (
<Text
as="a"
color="white"
fontSize="md"
fontWeight="semibold"
href="https://www.youtube.com/playlist?list=PLvWK1Kc8iXGrQy8r9TYg6QdUuJ5MMx-ZO"
target="_blank"
/>
),
}}
/>
</Text>
</Flex>
</Flex>
);
};

View File

@@ -1,14 +1,12 @@
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { $canvasRightPanelTab } from 'features/controlLayers/store/ephemeral';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
@@ -22,7 +20,7 @@ export const useGalleryHotkeys = () => {
const selection = useAppSelector((s) => s.gallery.selection);
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const queryResult = useListImagesQuery(queryArgs);
const canvasRightPanelTab = useStore($canvasRightPanelTab);
const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel);
const appTab = useAppSelector(selectActiveTab);
const isWorkflowsFocused = useIsRegionFocused('workflows');
const isGalleryFocused = useIsRegionFocused('gallery');

View File

@@ -32,6 +32,8 @@ export const selectListImagesQueryArgs = createMemoizedSelector(
export const selectListBoardsQueryArgs = createMemoizedSelector(
selectGallerySlice,
(gallery): ListBoardsArgs => ({
order_by: gallery.boardsListOrderBy,
direction: gallery.boardsListOrderDir,
include_archived: gallery.shouldShowArchivedBoards ? true : undefined,
})
);
@@ -44,6 +46,9 @@ export const selectAutoAssignBoardOnClick = createSelector(
);
export const selectBoardSearchText = createSelector(selectGallerySlice, (gallery) => gallery.boardSearchText);
export const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm);
export const selectBoardsListOrderBy = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderBy);
export const selectBoardsListOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderDir);
export const selectSelectionCount = createSelector(selectGallerySlice, (gallery) => gallery.selection.length);
export const selectHasMultipleImagesSelected = createSelector(selectSelectionCount, (count) => count > 1);
export const selectGalleryImageMinimumWidth = createSelector(

View File

@@ -2,7 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { isEqual, uniqBy } from 'lodash-es';
import type { ImageDTO } from 'services/api/types';
import type { BoardRecordOrderBy, ImageDTO } from 'services/api/types';
import type { BoardId, ComparisonMode, GalleryState, GalleryView, OrderDir } from './types';
@@ -25,6 +25,8 @@ const initialGalleryState: GalleryState = {
comparisonMode: 'slider',
comparisonFit: 'fill',
shouldShowArchivedBoards: false,
boardsListOrderBy: 'created_at',
boardsListOrderDir: 'DESC',
};
export const gallerySlice = createSlice({
@@ -161,6 +163,12 @@ export const gallerySlice = createSlice({
state.searchTerm = action.payload;
state.offset = 0;
},
boardsListOrderByChanged: (state, action: PayloadAction<BoardRecordOrderBy>) => {
state.boardsListOrderBy = action.payload;
},
boardsListOrderDirChanged: (state, action: PayloadAction<OrderDir>) => {
state.boardsListOrderDir = action.payload;
},
},
});
@@ -186,6 +194,8 @@ export const {
starredFirstChanged,
shouldShowArchivedBoardsChanged,
searchTermChanged,
boardsListOrderByChanged,
boardsListOrderDirChanged,
} = gallerySlice.actions;
export const selectGallerySlice = (state: RootState) => state.gallery;

View File

@@ -1,4 +1,4 @@
import type { ImageCategory, ImageDTO } from 'services/api/types';
import type { BoardRecordOrderBy, ImageCategory, ImageDTO } from 'services/api/types';
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other'];
@@ -28,4 +28,6 @@ export type GalleryState = {
comparisonMode: ComparisonMode;
comparisonFit: ComparisonFit;
shouldShowArchivedBoards: boolean;
boardsListOrderBy: BoardRecordOrderBy;
boardsListOrderDir: OrderDir;
};

View File

@@ -168,6 +168,11 @@ export const handlers = {
parser: parsers.cfgScale,
recaller: recallers.cfgScale,
}),
guidance: buildHandlers({
getLabel: () => t('metadata.guidance'),
parser: parsers.guidance,
recaller: recallers.guidance,
}),
height: buildHandlers({ getLabel: () => t('metadata.height'), parser: parsers.height, recaller: recallers.height }),
negativePrompt: buildHandlers({
getLabel: () => t('metadata.negativePrompt'),

View File

@@ -27,6 +27,7 @@ import { zControlField, zIPAdapterField, zModelIdentifierField, zT2IAdapterField
import type {
ParameterCFGRescaleMultiplier,
ParameterCFGScale,
ParameterGuidance,
ParameterHeight,
ParameterHRFEnabled,
ParameterHRFMethod,
@@ -49,6 +50,7 @@ import type {
import {
isParameterCFGRescaleMultiplier,
isParameterCFGScale,
isParameterGuidance,
isParameterHeight,
isParameterHRFEnabled,
isParameterHRFMethod,
@@ -142,6 +144,9 @@ const parseCFGScale: MetadataParseFunc<ParameterCFGScale> = (metadata) =>
const parseCFGRescaleMultiplier: MetadataParseFunc<ParameterCFGRescaleMultiplier> = (metadata) =>
getProperty(metadata, 'cfg_rescale_multiplier', isParameterCFGRescaleMultiplier);
const parseGuidance: MetadataParseFunc<ParameterGuidance> = (metadata) =>
getProperty(metadata, 'guidance', isParameterGuidance);
const parseScheduler: MetadataParseFunc<ParameterScheduler> = (metadata) =>
getProperty(metadata, 'scheduler', isParameterScheduler);
@@ -636,6 +641,7 @@ export const parsers = {
seed: parseSeed,
cfgScale: parseCFGScale,
cfgRescaleMultiplier: parseCFGRescaleMultiplier,
guidance: parseGuidance,
scheduler: parseScheduler,
width: parseWidth,
height: parseHeight,

View File

@@ -9,6 +9,7 @@ import {
refinerModelChanged,
setCfgRescaleMultiplier,
setCfgScale,
setGuidance,
setImg2imgStrength,
setRefinerCFGScale,
setRefinerNegativeAestheticScore,
@@ -29,6 +30,7 @@ import { modelSelected } from 'features/parameters/store/actions';
import type {
ParameterCFGRescaleMultiplier,
ParameterCFGScale,
ParameterGuidance,
ParameterHeight,
ParameterHRFEnabled,
ParameterHRFMethod,
@@ -74,6 +76,10 @@ const recallCFGScale: MetadataRecallFunc<ParameterCFGScale> = (cfgScale) => {
getStore().dispatch(setCfgScale(cfgScale));
};
const recallGuidance: MetadataRecallFunc<ParameterGuidance> = (guidance) => {
getStore().dispatch(setGuidance(guidance));
};
const recallCFGRescaleMultiplier: MetadataRecallFunc<ParameterCFGRescaleMultiplier> = (cfgRescaleMultiplier) => {
getStore().dispatch(setCfgRescaleMultiplier(cfgRescaleMultiplier));
};
@@ -198,6 +204,7 @@ export const recallers = {
sdxlNegativeStylePrompt: recallSDXLNegativeStylePrompt,
seed: recallSeed,
cfgScale: recallCFGScale,
guidance: recallGuidance,
cfgRescaleMultiplier: recallCFGRescaleMultiplier,
scheduler: recallScheduler,
width: recallWidth,

View File

@@ -0,0 +1,47 @@
import { EMPTY_ARRAY } from 'app/store/constants';
import { useCallback } from 'react';
import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
import type { StarterModel } from 'services/api/types';
type ModelInstallArg = {
config: Pick<StarterModel, 'name' | 'base' | 'type' | 'description' | 'format'>;
source: string;
};
/**
* Flattens a starter model and its dependencies into a list of models, including the starter model itself.
*/
export const flattenStarterModel = (starterModel: StarterModel): StarterModel[] => {
return [starterModel, ...(starterModel.dependencies || [])];
};
export const useBuildModelInstallArg = () => {
const { modelList } = useGetModelConfigsQuery(undefined, {
selectFromResult: ({ data }) => ({ modelList: data ? modelConfigsAdapterSelectors.selectAll(data) : EMPTY_ARRAY }),
});
const getIsInstalled = useCallback(
({ source, name, base, type, is_installed }: StarterModel): boolean =>
modelList.some(
(mc) => is_installed || source === mc.source || (base === mc.base && name === mc.name && type === mc.type)
),
[modelList]
);
const buildModelInstallArg = useCallback((starterModel: StarterModel): ModelInstallArg => {
const { name, base, type, source, description, format } = starterModel;
return {
config: {
name,
base,
type,
description,
format,
},
source,
};
}, []);
return { getIsInstalled, buildModelInstallArg };
};

View File

@@ -1,71 +0,0 @@
import { Badge, Box, Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { useInstallModel } from 'features/modelManagerV2/hooks/useInstallModel';
import ModelBaseBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
import type { GetStarterModelsResponse } from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';
type Props = {
result: GetStarterModelsResponse[number];
modelList: AnyModelConfig[];
};
export const StarterModelsResultItem = memo(({ result, modelList }: Props) => {
const { t } = useTranslation();
const allSources = useMemo(() => {
const _allSources = [
{
source: result.source,
config: {
name: result.name,
description: result.description,
type: result.type,
base: result.base,
format: result.format,
},
},
];
if (result.dependencies) {
for (const d of result.dependencies) {
_allSources.push({
source: d.source,
config: { name: d.name, description: d.description, type: d.type, base: d.base, format: d.format },
});
}
}
return _allSources;
}, [result]);
const [installModel] = useInstallModel();
const onClick = useCallback(() => {
for (const { config, source } of allSources) {
if (modelList.some((mc) => config.base === mc.base && config.name === mc.name && config.type === mc.type)) {
continue;
}
installModel({ config, source });
}
}, [modelList, allSources, installModel]);
return (
<Flex alignItems="center" justifyContent="space-between" w="100%" gap={3}>
<Flex fontSize="sm" flexDir="column">
<Flex gap={3}>
<Badge h="min-content">{result.type.replaceAll('_', ' ')}</Badge>
<ModelBaseBadge base={result.base} />
<Text fontWeight="semibold">{result.name}</Text>
</Flex>
<Text variant="subtext">{result.description}</Text>
</Flex>
<Box>
{result.is_installed ? (
<Badge>{t('common.installed')}</Badge>
) : (
<IconButton aria-label={t('modelManager.install')} icon={<PiPlusBold />} onClick={onClick} size="sm" />
)}
</Box>
</Flex>
);
});
StarterModelsResultItem.displayName = 'StarterModelsResultItem';

View File

@@ -0,0 +1,65 @@
import { Button, Flex, Text, Tooltip } from '@invoke-ai/ui-library';
import { flattenStarterModel, useBuildModelInstallArg } from 'features/modelManagerV2/hooks/useBuildModelsToInstall';
import { isMainModelBase } from 'features/nodes/types/common';
import { MODEL_TYPE_SHORT_MAP } from 'features/parameters/types/constants';
import { toast } from 'features/toast/toast';
import { flatMap, negate, uniqWith } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useInstallModelMutation } from 'services/api/endpoints/models';
import type { StarterModel } from 'services/api/types';
export const StarterBundle = ({ bundleName, bundle }: { bundleName: string; bundle: StarterModel[] }) => {
const [installModel] = useInstallModelMutation();
const { getIsInstalled, buildModelInstallArg } = useBuildModelInstallArg();
const { t } = useTranslation();
const modelsToInstall = useMemo(() => {
// Flatten the models and remove duplicates, which is expected as models can have the same dependencies
const flattenedModels = flatMap(bundle, flattenStarterModel);
const uniqueModels = uniqWith(
flattenedModels,
(m1, m2) => m1.source === m2.source || (m1.name === m2.name && m1.base === m2.base && m1.type === m2.type)
);
// We want to install models that are not installed and skip models that are already installed
const install = uniqueModels.filter(negate(getIsInstalled)).map(buildModelInstallArg);
const skip = uniqueModels.filter(getIsInstalled).map(buildModelInstallArg);
return { install, skip };
}, [buildModelInstallArg, bundle, getIsInstalled]);
const handleClickBundle = useCallback(() => {
modelsToInstall.install.forEach(installModel);
let description = t('modelManager.installingXModels', { count: modelsToInstall.install.length });
if (modelsToInstall.skip.length > 1) {
description += t('modelManager.skippingXDuplicates', { count: modelsToInstall.skip.length - 1 });
}
toast({
status: 'info',
title: t('modelManager.installingBundle'),
description,
});
}, [modelsToInstall.install, modelsToInstall.skip.length, installModel, t]);
return (
<Tooltip
label={
<Flex flexDir="column">
<Text>{t('modelManager.includesNModels', { n: bundle.length })}</Text>
</Flex>
}
>
<Button size="sm" onClick={handleClickBundle} py={6} isDisabled={modelsToInstall.install.length === 0}>
<Flex flexDir="column">
<Text>{isMainModelBase(bundleName) ? MODEL_TYPE_SHORT_MAP[bundleName] : bundleName}</Text>
{modelsToInstall.install.length > 0 && (
<Text fontSize="xs">
({bundle.length} {t('settings.models')})
</Text>
)}
{modelsToInstall.install.length === 0 && <Text fontSize="xs">{t('common.installed')}</Text>}
</Flex>
</Button>
</Tooltip>
);
};

View File

@@ -1,31 +1,19 @@
import { Flex } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { FetchingModelsLoader } from 'features/modelManagerV2/subpanels/ModelManagerPanel/FetchingModelsLoader';
import { memo, useMemo } from 'react';
import {
modelConfigsAdapterSelectors,
useGetModelConfigsQuery,
useGetStarterModelsQuery,
} from 'services/api/endpoints/models';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetStarterModelsQuery } from 'services/api/endpoints/models';
import { StarterModelsResults } from './StarterModelsResults';
export const StarterModelsForm = memo(() => {
const { isLoading, data } = useGetStarterModelsQuery();
const { data: modelListRes } = useGetModelConfigsQuery();
const modelList = useMemo(() => {
if (!modelListRes) {
return EMPTY_ARRAY;
}
return modelConfigsAdapterSelectors.selectAll(modelListRes);
}, [modelListRes]);
const { t } = useTranslation();
return (
<Flex flexDir="column" height="100%" gap={3}>
{isLoading && <FetchingModelsLoader loadingMessage="Loading Embeddings..." />}
{data && <StarterModelsResults results={data} modelList={modelList} />}
{isLoading && <FetchingModelsLoader loadingMessage={t('common.loading')} />}
{data && <StarterModelsResults results={data} />}
</Flex>
);
});

View File

@@ -0,0 +1,63 @@
import { Badge, Box, Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { flattenStarterModel, useBuildModelInstallArg } from 'features/modelManagerV2/hooks/useBuildModelsToInstall';
import { useInstallModel } from 'features/modelManagerV2/hooks/useInstallModel';
import ModelBaseBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge';
import { toast } from 'features/toast/toast';
import { negate } from 'lodash-es';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
import type { StarterModel } from 'services/api/types';
type Props = {
starterModel: StarterModel;
};
export const StarterModelsResultItem = memo(({ starterModel }: Props) => {
const { t } = useTranslation();
const { getIsInstalled, buildModelInstallArg } = useBuildModelInstallArg();
// The model is installed if it and all its dependencies are installed
const isInstalled = useMemo(
() => flattenStarterModel(starterModel).every(getIsInstalled),
[getIsInstalled, starterModel]
);
// Build the install arguments for all models that are not installed
const modelsToInstall = useMemo(
() => flattenStarterModel(starterModel).filter(negate(getIsInstalled)).map(buildModelInstallArg),
[getIsInstalled, starterModel, buildModelInstallArg]
);
const [installModel] = useInstallModel();
const onClick = useCallback(() => {
modelsToInstall.forEach(installModel);
toast({
status: 'info',
title: t('modelManager.installingModel'),
description: t('modelManager.installingXModels', { count: modelsToInstall.length }),
});
}, [modelsToInstall, installModel, t]);
return (
<Flex alignItems="center" justifyContent="space-between" w="100%" gap={3}>
<Flex fontSize="sm" flexDir="column">
<Flex gap={3}>
<Badge h="min-content">{starterModel.type.replaceAll('_', ' ')}</Badge>
<ModelBaseBadge base={starterModel.base} />
<Text fontWeight="semibold">{starterModel.name}</Text>
</Flex>
<Text variant="subtext">{starterModel.description}</Text>
</Flex>
<Box>
{isInstalled ? (
<Badge>{t('common.installed')}</Badge>
) : (
<IconButton aria-label={t('modelManager.install')} icon={<PiPlusBold />} onClick={onClick} size="sm" />
)}
</Box>
</Flex>
);
});
StarterModelsResultItem.displayName = 'StarterModelsResultItem';

View File

@@ -1,25 +1,35 @@
import { Flex, IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
import {
Box,
Flex,
Icon,
IconButton,
Input,
InputGroup,
InputRightElement,
Text,
Tooltip,
} from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { map, size } from 'lodash-es';
import type { ChangeEventHandler } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { PiInfoBold, PiXBold } from 'react-icons/pi';
import type { GetStarterModelsResponse } from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';
import { StarterModelsResultItem } from './StartModelsResultItem';
import { StarterBundle } from './StarterBundle';
import { StarterModelsResultItem } from './StarterModelsResultItem';
type StarterModelsResultsProps = {
results: NonNullable<GetStarterModelsResponse>;
modelList: AnyModelConfig[];
};
export const StarterModelsResults = memo(({ results, modelList }: StarterModelsResultsProps) => {
export const StarterModelsResults = memo(({ results }: StarterModelsResultsProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const filteredResults = useMemo(() => {
return results.filter((result) => {
return results.starter_models.filter((result) => {
const trimmedSearchTerm = searchTerm.trim().toLowerCase();
const matchStrings = [
result.name.toLowerCase(),
@@ -46,7 +56,26 @@ export const StarterModelsResults = memo(({ results, modelList }: StarterModelsR
return (
<Flex flexDir="column" gap={3} height="100%">
<Flex justifyContent="flex-end" alignItems="center">
<Flex justifyContent="space-between" alignItems="center">
{size(results.starter_bundles) > 0 && (
<Flex gap={4} alignItems="center">
<Flex gap={1} alignItems="center">
<Text color="base.200" fontWeight="semibold">
{t('modelManager.starterBundles')}
</Text>
<Tooltip label={t('modelManager.starterBundleHelpText')}>
<Box>
<Icon as={PiInfoBold} color="base.200" />
</Box>
</Tooltip>
</Flex>
<Flex gap={2}>
{map(results.starter_bundles, (bundle, bundleName) => (
<StarterBundle key={bundleName} bundleName={bundleName} bundle={bundle} />
))}
</Flex>
</Flex>
)}
<InputGroup w={64} size="xs">
<Input
placeholder={t('modelManager.search')}
@@ -74,7 +103,7 @@ export const StarterModelsResults = memo(({ results, modelList }: StarterModelsR
<ScrollableContent>
<Flex flexDir="column" gap={3}>
{filteredResults.map((result) => (
<StarterModelsResultItem key={result.source} result={result} modelList={modelList} />
<StarterModelsResultItem key={result.source} starterModel={result} />
))}
</Flex>
</ScrollableContent>

View File

@@ -1,6 +1,6 @@
import { Button, Flex, Heading } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { setSelectedModelKey } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectSelectedModelKey, setSelectedModelKey } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
@@ -14,14 +14,19 @@ export const ModelManager = memo(() => {
const handleClickAddModel = useCallback(() => {
dispatch(setSelectedModelKey(null));
}, [dispatch]);
const selectedModelKey = useAppSelector(selectSelectedModelKey);
return (
<Flex flexDir="column" layerStyle="first" p={4} gap={4} borderRadius="base" w="50%" h="full">
<Flex w="full" gap={4} justifyContent="space-between" alignItems="center">
<Heading fontSize="xl">{t('common.modelManager')}</Heading>
<Button size="sm" colorScheme="invokeYellow" leftIcon={<PiPlusBold />} onClick={handleClickAddModel}>
{t('modelManager.addModels')}
</Button>
<Heading fontSize="xl" py={1}>
{t('common.modelManager')}
</Heading>
{!!selectedModelKey && (
<Button size="sm" colorScheme="invokeYellow" leftIcon={<PiPlusBold />} onClick={handleClickAddModel}>
{t('modelManager.addModels')}
</Button>
)}
</Flex>
<Flex flexDir="column" layerStyle="second" p={4} gap={4} borderRadius="base" w="full" h="full">
<ModelListNavigation />

View File

@@ -1,7 +1,6 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
import { useAppDispatch } from 'app/store/storeHooks';
import { fieldBoardValueChanged } from 'features/nodes/store/nodesSlice';
import type { BoardFieldInputInstance, BoardFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useMemo } from 'react';
@@ -14,26 +13,28 @@ const BoardFieldInputComponent = (props: FieldComponentProps<BoardFieldInputInst
const { nodeId, field } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
const { options, hasBoards } = useListAllBoardsQuery(queryArgs, {
selectFromResult: ({ data }) => {
const options: ComboboxOption[] = [
{
label: 'None',
value: 'none',
},
].concat(
(data ?? []).map(({ board_id, board_name }) => ({
label: board_name,
value: board_id,
}))
);
return {
options,
hasBoards: options.length > 1,
};
},
});
const { options, hasBoards } = useListAllBoardsQuery(
{ include_archived: true },
{
selectFromResult: ({ data }) => {
const options: ComboboxOption[] = [
{
label: 'None',
value: 'none',
},
].concat(
(data ?? []).map(({ board_id, board_name }) => ({
label: board_name,
value: board_id,
}))
);
return {
options,
hasBoards: options.length > 1,
};
},
}
);
const onChange = useCallback<ComboboxOnChange>(
(v) => {

View File

@@ -43,7 +43,7 @@ export const ShareWorkflowModal = () => {
if (!workflowToShare || !projectUrl) {
return null;
}
return `${projectUrl}/studio?selectedWorkflowId=${workflowToShare.workflow_id}`;
return `${window.location.origin}${projectUrl}/studio?selectedWorkflowId=${workflowToShare.workflow_id}`;
}, [projectUrl, workflowToShare]);
const handleCopy = useCallback(() => {

View File

@@ -18,9 +18,8 @@ import {
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectSendToCanvas, settingsSendToCanvasChanged } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectCanvasRightPanelLayersTab } from 'features/controlLayers/store/ephemeral';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
import type { ChangeEvent, PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@@ -186,7 +185,7 @@ const ActivateCanvasButton = (props: PropsWithChildren) => {
const imageViewer = useImageViewer();
const onClick = useCallback(() => {
dispatch(setActiveTab('canvas'));
selectCanvasRightPanelLayersTab();
dispatch(activeTabCanvasRightPanelChanged('layers'));
imageViewer.close();
}, [dispatch, imageViewer]);
return (

View File

@@ -218,3 +218,6 @@ export const selectWorkflowFetchDebounce = createConfigSelector((config) => conf
export const selectMetadataFetchDebounce = createConfigSelector((config) => config.metadataFetchDebounce ?? 300);
export const selectIsModelsTabDisabled = createConfigSelector((config) => config.disabledTabs.includes('models'));
export const selectMaxImageUploadCount = createConfigSelector((config) => config.maxImageUploadCount);
export const selectIsLocal = createSelector(selectConfigSlice, (config) => config.isLocal);

View File

@@ -4,3 +4,4 @@ import { selectUiSlice } from 'features/ui/store/uiSlice';
export const selectActiveTab = createSelector(selectUiSlice, (ui) => ui.activeTab);
export const selectShouldShowImageDetails = createSelector(selectUiSlice, (ui) => ui.shouldShowImageDetails);
export const selectShouldShowProgressInViewer = createSelector(selectUiSlice, (ui) => ui.shouldShowProgressInViewer);
export const selectActiveTabCanvasRightPanel = createSelector(selectUiSlice, (ui) => ui.activeTabCanvasRightPanel);

View File

@@ -5,11 +5,12 @@ import { newSessionRequested } from 'features/controlLayers/store/actions';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import { atom } from 'nanostores';
import type { TabName, UIState } from './uiTypes';
import type { CanvasRightPanelTabName, TabName, UIState } from './uiTypes';
const initialUIState: UIState = {
_version: 3,
activeTab: 'canvas',
activeTabCanvasRightPanel: 'gallery',
shouldShowImageDetails: false,
shouldShowProgressInViewer: true,
accordions: {},
@@ -24,6 +25,9 @@ export const uiSlice = createSlice({
setActiveTab: (state, action: PayloadAction<TabName>) => {
state.activeTab = action.payload;
},
activeTabCanvasRightPanelChanged: (state, action: PayloadAction<CanvasRightPanelTabName>) => {
state.activeTabCanvasRightPanel = action.payload;
},
setShouldShowImageDetails: (state, action: PayloadAction<boolean>) => {
state.shouldShowImageDetails = action.payload;
},
@@ -54,6 +58,7 @@ export const uiSlice = createSlice({
export const {
setActiveTab,
activeTabCanvasRightPanelChanged,
setShouldShowImageDetails,
setShouldShowProgressInViewer,
accordionStateChanged,

View File

@@ -1,4 +1,5 @@
export type TabName = 'canvas' | 'upscaling' | 'workflows' | 'models' | 'queue';
export type CanvasRightPanelTabName = 'layers' | 'gallery';
export interface UIState {
/**
@@ -9,6 +10,10 @@ export interface UIState {
* The currently active tab.
*/
activeTab: TabName;
/**
* The currently active right panel canvas tab
*/
activeTabCanvasRightPanel: CanvasRightPanelTabName;
/**
* Whether or not to show image details, e.g. metadata, workflow, etc.
*/

View File

@@ -272,6 +272,7 @@ export const imagesApi = api.injectEndpoints({
board_id?: string;
crop_visible?: boolean;
metadata?: SerializableObject;
isFirstUploadOfBatch?: boolean;
}
>({
query: ({ file, image_category, is_intermediate, session_id, board_id, crop_visible, metadata }) => {

View File

@@ -1,5 +1,4 @@
import { getStore } from 'app/store/nanostores/store';
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import { modelsApi } from 'services/api/endpoints/models';
@@ -44,10 +43,9 @@ export const checkImageAccess = async (name: string): Promise<boolean> => {
* @returns A promise that resolves to true if the client has access, else false.
*/
export const checkBoardAccess = async (id: string): Promise<boolean> => {
const { dispatch, getState } = getStore();
const { dispatch } = getStore();
try {
const queryArgs = selectListBoardsQueryArgs(getState());
const req = dispatch(boardsApi.endpoints.listAllBoards.initiate(queryArgs));
const req = dispatch(boardsApi.endpoints.listAllBoards.initiate({ include_archived: true }));
req.unsubscribe();
const result = await req.unwrap();
return result.some((b) => b.board_id === id);

View File

@@ -1,19 +1,19 @@
import { useAppSelector } from 'app/store/storeHooks';
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
import type { BoardId } from 'features/gallery/store/types';
import { t } from 'i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
export const useBoardName = (board_id: BoardId) => {
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
const { boardName } = useListAllBoardsQuery(queryArgs, {
selectFromResult: ({ data }) => {
const selectedBoard = data?.find((b) => b.board_id === board_id);
const boardName = selectedBoard?.board_name || t('boards.uncategorized');
const { boardName } = useListAllBoardsQuery(
{ include_archived: true },
{
selectFromResult: ({ data }) => {
const selectedBoard = data?.find((b) => b.board_id === board_id);
const boardName = selectedBoard?.board_name || t('boards.uncategorized');
return { boardName };
},
});
return { boardName };
},
}
);
return boardName;
};

View File

@@ -2033,6 +2033,12 @@ export type components = {
*/
board_id: string;
};
/**
* BoardRecordOrderBy
* @description The order by options for board records
* @enum {string}
*/
BoardRecordOrderBy: "created_at" | "board_name";
/** Body_add_image_to_board */
Body_add_image_to_board: {
/**
@@ -15160,6 +15166,15 @@ export type components = {
/** Dependencies */
dependencies?: components["schemas"]["StarterModelWithoutDependencies"][] | null;
};
/** StarterModelResponse */
StarterModelResponse: {
/** Starter Models */
starter_models: components["schemas"]["StarterModel"][];
/** Starter Bundles */
starter_bundles: {
[key: string]: components["schemas"]["StarterModel"][];
};
};
/** StarterModelWithoutDependencies */
StarterModelWithoutDependencies: {
/** Description */
@@ -17966,7 +17981,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StarterModel"][];
"application/json": components["schemas"]["StarterModelResponse"];
};
};
};
@@ -18884,6 +18899,10 @@ export interface operations {
list_boards: {
parameters: {
query?: {
/** @description The attribute to order by */
order_by?: components["schemas"]["BoardRecordOrderBy"];
/** @description The direction to order by */
direction?: components["schemas"]["SQLiteDirection"];
/** @description Whether to list all boards */
all?: boolean | null;
/** @description The page offset */
@@ -20040,7 +20059,7 @@ export interface operations {
/** @description The direction to order by */
direction?: components["schemas"]["SQLiteDirection"];
/** @description The category of workflow to get */
category?: components["schemas"]["WorkflowCategory"] | null;
category?: components["schemas"]["WorkflowCategory"];
/** @description The text to query by (matches name and description) */
query?: string | null;
};

View File

@@ -241,3 +241,6 @@ export type PostUploadAction =
| RGIPAdapterImagePostUploadAction
| UpscaleInitialImageAction
| ReplaceLayerWithImagePostUploadAction;
export type BoardRecordOrderBy = S['BoardRecordOrderBy'];
export type StarterModel = S['StarterModel'];

View File

@@ -24,6 +24,15 @@ export default defineConfig(({ mode }) => {
cssInjectedByJsPlugin(),
],
build: {
/**
* zone.js (via faro) requires max ES2015 to prevent spamming unhandled promise rejections.
*
* See:
* - https://github.com/grafana/faro-web-sdk/issues/566
* - https://github.com/angular/angular/issues/51328
* - https://github.com/open-telemetry/opentelemetry-js/issues/3030
*/
target: 'ES2015',
cssCodeSplit: true,
lib: {
entry: path.resolve(__dirname, './src/index.ts'),

View File

@@ -1 +1 @@
__version__ = "5.2.0rc1"
__version__ = "5.2.0"

View File

@@ -43,8 +43,8 @@ dependencies = [
"invisible-watermark==0.2.0", # needed to install SDXL base and refiner using their repo_ids
"mediapipe>=0.10.7", # needed for "mediapipeface" controlnet model
"numpy==1.26.4", # >1.24.0 is needed to use the 'strict' argument to np.testing.assert_array_equal()
"onnx>=1.15.0",
"onnxruntime>=1.16.3",
"onnx==1.16.1",
"onnxruntime==1.19.2",
"opencv-python==4.9.0.80",
"pytorch-lightning==2.1.3",
"safetensors==0.4.3",