mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-18 01:37:56 -05:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
358dbdbf84 | ||
|
|
5ec2d71be0 | ||
|
|
8f28903c81 | ||
|
|
a071f2788a | ||
|
|
d9a257ef8a | ||
|
|
23fada3eea | ||
|
|
2917e59c38 | ||
|
|
c691855a67 | ||
|
|
a00347379b | ||
|
|
ad1a8fbb8d | ||
|
|
f03b77e882 | ||
|
|
2b000cb006 | ||
|
|
af636f08b8 | ||
|
|
f8150f46a5 | ||
|
|
b613be0f5d | ||
|
|
a833d74913 | ||
|
|
02df055e8a | ||
|
|
add31ce596 | ||
|
|
7d7ad3052e | ||
|
|
3b16dbffb2 | ||
|
|
d8b0648766 | ||
|
|
ae64ee224f | ||
|
|
1251dfd7f6 | ||
|
|
804ee3a7fb | ||
|
|
fc5f9047c2 | ||
|
|
0b208220e5 | ||
|
|
916b9f7741 | ||
|
|
0947a006cc | ||
|
|
2c2df6423e | ||
|
|
c3df9d38c0 | ||
|
|
3790c254f5 | ||
|
|
abf46eaacd | ||
|
|
166548246d | ||
|
|
985dcd9862 | ||
|
|
b1df592506 | ||
|
|
a09a0eff69 | ||
|
|
e73bd09d93 | ||
|
|
6f5477a3f0 | ||
|
|
f78a542401 | ||
|
|
8613efb03a | ||
|
|
d8347d856d | ||
|
|
336e6e0c19 | ||
|
|
5bd87ca89b | ||
|
|
fe87c198eb | ||
|
|
69a4a88925 | ||
|
|
6e7491b086 | ||
|
|
3da8076a2b | ||
|
|
80360a8abb | ||
|
|
acfeb4a276 | ||
|
|
b33dbfc95f | ||
|
|
f9bc29203b | ||
|
|
cbe7717409 | ||
|
|
d6add93901 | ||
|
|
ea45dce9dc | ||
|
|
8d44363d49 | ||
|
|
9933cdb6b7 | ||
|
|
e3e9d1f27c | ||
|
|
bb59ad438a | ||
|
|
e38f5b1576 | ||
|
|
1bb49b698f | ||
|
|
fa1fbd89fe | ||
|
|
190ef6732c | ||
|
|
947cd4694b | ||
|
|
ee32d0666d | ||
|
|
bc8ad9ccbf | ||
|
|
e96b290fa9 | ||
|
|
b9f83eae6a | ||
|
|
9868e23235 | ||
|
|
0060cae17c | ||
|
|
56f0845552 | ||
|
|
da3f85dd8b |
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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 you’ve 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 you’ve 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",
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -79,6 +79,7 @@ export type AppConfig = {
|
||||
metadataFetchDebounce?: number;
|
||||
workflowFetchDebounce?: number;
|
||||
isLocal?: boolean;
|
||||
maxImageUploadCount?: number;
|
||||
sd: {
|
||||
defaultModel?: string;
|
||||
disabledControlNetModels: string[];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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()} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -241,3 +241,6 @@ export type PostUploadAction =
|
||||
| RGIPAdapterImagePostUploadAction
|
||||
| UpscaleInitialImageAction
|
||||
| ReplaceLayerWithImagePostUploadAction;
|
||||
|
||||
export type BoardRecordOrderBy = S['BoardRecordOrderBy'];
|
||||
export type StarterModel = S['StarterModel'];
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "5.2.0rc1"
|
||||
__version__ = "5.2.0"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user