mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-20 08:37:55 -05:00
Compare commits
275 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cbd609860 | ||
|
|
047c643295 | ||
|
|
d1e03aa1c5 | ||
|
|
1bb8edf57e | ||
|
|
a3e78f0db6 | ||
|
|
1ccf43aa1e | ||
|
|
a290975fae | ||
|
|
43c2116d64 | ||
|
|
9d0a24ead3 | ||
|
|
d61a3d2950 | ||
|
|
7b63858802 | ||
|
|
fae23a744f | ||
|
|
7c574719e5 | ||
|
|
43a212dd47 | ||
|
|
a103bc8a0a | ||
|
|
1a42fbf541 | ||
|
|
d550067dd4 | ||
|
|
7003bcad62 | ||
|
|
ef95f4962c | ||
|
|
2e13bbbe1b | ||
|
|
43349cb5ce | ||
|
|
d037eea42a | ||
|
|
42c5be16d1 | ||
|
|
c7c4453a92 | ||
|
|
c71ddf6e5d | ||
|
|
c33ed68f78 | ||
|
|
48e389f155 | ||
|
|
5c423fece4 | ||
|
|
3f86049802 | ||
|
|
47d395d0a8 | ||
|
|
b666ef41ff | ||
|
|
375f62380b | ||
|
|
42c4462edc | ||
|
|
7591adebd5 | ||
|
|
9d9b2f73db | ||
|
|
abaae39c29 | ||
|
|
b1c9f59c30 | ||
|
|
7bcbe180df | ||
|
|
a626387a0b | ||
|
|
759229e3c8 | ||
|
|
ad4b81ba21 | ||
|
|
637b629b95 | ||
|
|
4aaa807415 | ||
|
|
e884be5042 | ||
|
|
13e129bef2 | ||
|
|
157904522f | ||
|
|
3045cd7b3a | ||
|
|
e9e2bab4ee | ||
|
|
6cd794d860 | ||
|
|
c9b0307bcd | ||
|
|
55aee034b0 | ||
|
|
e81ef0a090 | ||
|
|
1a806739f2 | ||
|
|
067aeeac23 | ||
|
|
47b37d946f | ||
|
|
ddfdeca8bd | ||
|
|
55b2a4388d | ||
|
|
6ab2bebfa6 | ||
|
|
3f18bfed4e | ||
|
|
012054acaa | ||
|
|
efb7f36f28 | ||
|
|
05ea1c7637 | ||
|
|
2ba0f920d2 | ||
|
|
c3ab4f4d6e | ||
|
|
36b3089d5d | ||
|
|
6c4d002bd6 | ||
|
|
b2cfa137a3 | ||
|
|
9d57bc1697 | ||
|
|
e6db36d0c4 | ||
|
|
78832e546a | ||
|
|
6cfeadb33b | ||
|
|
d1d3971ee3 | ||
|
|
e9ce259d43 | ||
|
|
34d988063f | ||
|
|
e2bdbfe721 | ||
|
|
fe7e1958ea | ||
|
|
cf8f18e690 | ||
|
|
da7b31b2a8 | ||
|
|
fb82664944 | ||
|
|
58ae9ed8a5 | ||
|
|
d142a94b67 | ||
|
|
c8135126f2 | ||
|
|
560910ed2f | ||
|
|
b78ac40a22 | ||
|
|
9ecafc8706 | ||
|
|
871cb54988 | ||
|
|
e3069ad336 | ||
|
|
28027702dd | ||
|
|
d72840620a | ||
|
|
4f2de2674e | ||
|
|
340c9c0697 | ||
|
|
f77549dc4f | ||
|
|
5653352ae8 | ||
|
|
f1bc2ea962 | ||
|
|
2a9f7b2e38 | ||
|
|
c379d76844 | ||
|
|
6496fcdcbd | ||
|
|
812b8fddd6 | ||
|
|
dc9165dfc1 | ||
|
|
59826438f6 | ||
|
|
87cd52241d | ||
|
|
7506b0e7ae | ||
|
|
4b29a2f395 | ||
|
|
3bcaa42309 | ||
|
|
8e14cdb8b6 | ||
|
|
9ef6e52ad8 | ||
|
|
148bd70a24 | ||
|
|
1461c88c12 | ||
|
|
bcfeae94d2 | ||
|
|
40eedfebf7 | ||
|
|
d0a231d59e | ||
|
|
4bba7de070 | ||
|
|
e1f2b232c8 | ||
|
|
2c5b0195fc | ||
|
|
56792b2d2c | ||
|
|
d71e8b4980 | ||
|
|
ca50f8193c | ||
|
|
7ee636b68b | ||
|
|
926f69677a | ||
|
|
675ac348de | ||
|
|
62e5b9da18 | ||
|
|
65eabde297 | ||
|
|
6bebd2bfc8 | ||
|
|
cd785ba64b | ||
|
|
726b4637db | ||
|
|
b50241fe6a | ||
|
|
5b8735db3b | ||
|
|
ce286363d0 | ||
|
|
2fa47cf270 | ||
|
|
3446486f40 | ||
|
|
a0cdcdef57 | ||
|
|
abbb3609c8 | ||
|
|
700ad78f87 | ||
|
|
cfb08f326e | ||
|
|
aae4fa3cca | ||
|
|
109adc5a93 | ||
|
|
acb7ef8837 | ||
|
|
3c5e829c72 | ||
|
|
10d9e75391 | ||
|
|
b6a892a673 | ||
|
|
479d5cc362 | ||
|
|
01e4fd100f | ||
|
|
8ecf9fb7e3 | ||
|
|
436d5ee0c6 | ||
|
|
0671fec844 | ||
|
|
4dbde53f9b | ||
|
|
f6c4682b99 | ||
|
|
b3288ed64e | ||
|
|
f3dfb1b6ea | ||
|
|
65a37ca4ff | ||
|
|
9adbe31fec | ||
|
|
0a2925f02b | ||
|
|
877dcc73c3 | ||
|
|
aec2136323 | ||
|
|
8ef5c54ffe | ||
|
|
6faed4f1ec | ||
|
|
aa71db4d31 | ||
|
|
6407ab4a2e | ||
|
|
a91b0f25cb | ||
|
|
ef664863b5 | ||
|
|
bf8ba1bb37 | ||
|
|
54747bd521 | ||
|
|
d040a6953f | ||
|
|
828497cf89 | ||
|
|
28950a4891 | ||
|
|
1c92838bf9 | ||
|
|
71f6737e19 | ||
|
|
dcac65f46b | ||
|
|
46f549a57a | ||
|
|
fb93101085 | ||
|
|
9aabcfa4b8 | ||
|
|
64587b37db | ||
|
|
c673b6e11d | ||
|
|
a3a49ddda0 | ||
|
|
330a0f0028 | ||
|
|
1104d2a00f | ||
|
|
aed802fa74 | ||
|
|
498d99c828 | ||
|
|
3d19b98208 | ||
|
|
85f5bb4a02 | ||
|
|
269f718d2c | ||
|
|
211bb8a204 | ||
|
|
ef0ef875dd | ||
|
|
9c62648283 | ||
|
|
4ca45f7651 | ||
|
|
2abe2f52f7 | ||
|
|
6f1c814af4 | ||
|
|
1ad6ccc426 | ||
|
|
aedee536a0 | ||
|
|
d2b15fba12 | ||
|
|
a674e781a1 | ||
|
|
0db74f0cde | ||
|
|
d66db67d1a | ||
|
|
2507a7f674 | ||
|
|
145503a0a0 | ||
|
|
32e8dd5647 | ||
|
|
fe87adcb52 | ||
|
|
e95255f6e8 | ||
|
|
efec224523 | ||
|
|
e948e236e7 | ||
|
|
189eb85663 | ||
|
|
94f90f4082 | ||
|
|
1eb491fdaa | ||
|
|
176248a023 | ||
|
|
3c676ed11a | ||
|
|
7a9340b850 | ||
|
|
2c0b474f55 | ||
|
|
74c76611a9 | ||
|
|
1c7176b3f4 | ||
|
|
30363a0018 | ||
|
|
b46dbcc76d | ||
|
|
09879f4e19 | ||
|
|
4daa82c912 | ||
|
|
1cb04d9a4a | ||
|
|
3e6969128c | ||
|
|
e14c490ac6 | ||
|
|
3ef3b97c58 | ||
|
|
3baaefb0cc | ||
|
|
98b0a8ffb2 | ||
|
|
4f85bf078a | ||
|
|
f0563d41db | ||
|
|
a7a71ca935 | ||
|
|
c04822054b | ||
|
|
132e9bebd7 | ||
|
|
0dc45ac903 | ||
|
|
4f9d81917c | ||
|
|
d3c22eceaf | ||
|
|
fb77d271ab | ||
|
|
0371881349 | ||
|
|
4b178fdeca | ||
|
|
b53e36aaaa | ||
|
|
c061cd5e54 | ||
|
|
ddda915ebd | ||
|
|
9a2d8844a2 | ||
|
|
48583df02e | ||
|
|
f9432d10d2 | ||
|
|
0d28cd7ebe | ||
|
|
c9f9a2f2d4 | ||
|
|
a05d10f648 | ||
|
|
14845932fb | ||
|
|
2aa1fc9301 | ||
|
|
98139562f3 | ||
|
|
8365bba5ba | ||
|
|
9f07e83a23 | ||
|
|
1f995d0257 | ||
|
|
6ae2d5ef9d | ||
|
|
55973b4c66 | ||
|
|
d8c6531b70 | ||
|
|
81e385a756 | ||
|
|
f6cb1a455f | ||
|
|
bf60be99dc | ||
|
|
bee0e8248f | ||
|
|
1e658cf9e7 | ||
|
|
f130fa4d66 | ||
|
|
02a47a6806 | ||
|
|
1063498458 | ||
|
|
e9a13ec882 | ||
|
|
bd0765b744 | ||
|
|
6e1388f4fc | ||
|
|
2a9f2b2fe2 | ||
|
|
0a6b0dc3bf | ||
|
|
8753406a6c | ||
|
|
e2b09bed62 | ||
|
|
011910a08c | ||
|
|
bfd70be50b | ||
|
|
9c53bd6a3b | ||
|
|
e479cb5fe4 | ||
|
|
52947f40c3 | ||
|
|
bce9a23b25 | ||
|
|
2d05579568 | ||
|
|
11aabb5693 | ||
|
|
1e1e31d5b7 | ||
|
|
fe86cf6d99 | ||
|
|
cfb63c1b81 | ||
|
|
b44415415a |
@@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
|
||||
|
||||
#### Build the Web UI ------------------------------------
|
||||
|
||||
FROM node:20-slim AS web-builder
|
||||
FROM docker.io/node:22-slim AS web-builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack use pnpm@8.x
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional, Union
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
from fastapi import Body, HTTPException, Path, Query
|
||||
from fastapi.routing import APIRouter
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
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.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
|
||||
@@ -87,7 +88,9 @@ async def delete_board(
|
||||
try:
|
||||
if include_images is True:
|
||||
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id=board_id
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
@@ -98,7 +101,9 @@ async def delete_board(
|
||||
)
|
||||
else:
|
||||
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id=board_id
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
return DeleteBoardResult(
|
||||
@@ -141,11 +146,15 @@ async def list_boards(
|
||||
response_model=list[str],
|
||||
)
|
||||
async def list_all_board_image_names(
|
||||
board_id: str = Path(description="The id of the board"),
|
||||
board_id: str | Literal["none"] = Path(description="The id of the board"),
|
||||
categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: bool | None = Query(default=None, description="Whether to list intermediate images."),
|
||||
) -> list[str]:
|
||||
"""Gets a list of images for a board"""
|
||||
|
||||
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
return image_names
|
||||
|
||||
@@ -16,6 +16,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
ClearResult,
|
||||
EnqueueBatchResult,
|
||||
PruneResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueCountsByDestination,
|
||||
SessionQueueItem,
|
||||
SessionQueueItemDTO,
|
||||
@@ -135,6 +136,19 @@ async def cancel_by_destination(
|
||||
)
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
"/{queue_id}/retry_items_by_id",
|
||||
operation_id="retry_items_by_id",
|
||||
responses={200: {"model": RetryItemsResult}},
|
||||
)
|
||||
async def retry_items_by_id(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
item_ids: list[int] = Body(description="The queue item ids to retry"),
|
||||
) -> RetryItemsResult:
|
||||
"""Immediately cancels all queue items with the given origin"""
|
||||
return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids)
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
"/{queue_id}/clear",
|
||||
operation_id="clear",
|
||||
|
||||
@@ -64,13 +64,50 @@ class ImageBatchInvocation(BaseBatchInvocation):
|
||||
"""Create a batched generation, where the workflow is executed once for each image in the batch."""
|
||||
|
||||
images: list[ImageField] = InputField(
|
||||
default=[], min_length=1, description="The images to batch over", input=Input.Direct
|
||||
default=[],
|
||||
min_length=1,
|
||||
description="The images to batch over",
|
||||
)
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
|
||||
@invocation_output("image_generator_output")
|
||||
class ImageGeneratorOutput(BaseInvocationOutput):
|
||||
"""Base class for nodes that output a collection of boards"""
|
||||
|
||||
images: list[ImageField] = OutputField(description="The generated images")
|
||||
|
||||
|
||||
class ImageGeneratorField(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@invocation(
|
||||
"image_generator",
|
||||
title="Image Generator",
|
||||
tags=["primitives", "board", "image", "batch", "special"],
|
||||
category="primitives",
|
||||
version="1.0.0",
|
||||
classification=Classification.Special,
|
||||
)
|
||||
class ImageGenerator(BaseInvocation):
|
||||
"""Generated a collection of images for use in a batched generation"""
|
||||
|
||||
generator: ImageGeneratorField = InputField(
|
||||
description="The image generator.",
|
||||
input=Input.Direct,
|
||||
title="Generator Type",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageGeneratorOutput:
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
|
||||
@invocation(
|
||||
"string_batch",
|
||||
title="String Batch",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
|
||||
|
||||
class BoardImageRecordStorageBase(ABC):
|
||||
@@ -25,7 +27,9 @@ class BoardImageRecordStorageBase(ABC):
|
||||
@abstractmethod
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
board_id: str | Literal["none"],
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Optional, cast
|
||||
from typing import Literal, Optional, cast
|
||||
|
||||
from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
|
||||
from invokeai.app.services.image_records.image_records_common import ImageRecord, deserialize_image_record
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageRecord,
|
||||
deserialize_image_record,
|
||||
)
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
|
||||
@@ -97,17 +101,59 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
self._lock.release()
|
||||
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
|
||||
|
||||
def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str | Literal["none"],
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT image_name
|
||||
FROM board_images
|
||||
WHERE board_id = ?;
|
||||
""",
|
||||
(board_id,),
|
||||
)
|
||||
|
||||
params: list[str | bool] = []
|
||||
|
||||
# Base query is a join between images and board_images
|
||||
stmt = """
|
||||
SELECT images.image_name
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
# Add the board_id filter
|
||||
if board_id == "none":
|
||||
stmt += "AND board_images.board_id IS NULL"
|
||||
else:
|
||||
stmt += "AND board_images.board_id = ?"
|
||||
params.append(board_id)
|
||||
|
||||
# Add the category filter
|
||||
if categories is not None:
|
||||
# Convert the enum values to unique list of strings
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
# Create the correct length of placeholders
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
stmt += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
|
||||
# Unpack the included categories into the query params
|
||||
for c in category_strings:
|
||||
params.append(c)
|
||||
|
||||
# Add the is_intermediate filter
|
||||
if is_intermediate is not None:
|
||||
stmt += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
params.append(is_intermediate)
|
||||
|
||||
# Put a ring on it
|
||||
stmt += ";"
|
||||
|
||||
# Execute the query
|
||||
self._cursor.execute(stmt, params)
|
||||
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
image_names = [r[0] for r in result]
|
||||
return image_names
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
|
||||
|
||||
class BoardImagesServiceABC(ABC):
|
||||
@@ -25,7 +27,9 @@ class BoardImagesServiceABC(ABC):
|
||||
@abstractmethod
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
board_id: str | Literal["none"],
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
@@ -25,9 +26,15 @@ class BoardImagesService(BoardImagesServiceABC):
|
||||
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
board_id: str | Literal["none"],
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
|
||||
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
|
||||
def get_board_for_image(
|
||||
self,
|
||||
|
||||
@@ -28,6 +28,7 @@ from invokeai.app.services.events.events_common import (
|
||||
ModelLoadCompleteEvent,
|
||||
ModelLoadStartedEvent,
|
||||
QueueClearedEvent,
|
||||
QueueItemsRetriedEvent,
|
||||
QueueItemStatusChangedEvent,
|
||||
)
|
||||
|
||||
@@ -39,6 +40,7 @@ if TYPE_CHECKING:
|
||||
from invokeai.app.services.session_queue.session_queue_common import (
|
||||
BatchStatus,
|
||||
EnqueueBatchResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueItem,
|
||||
SessionQueueStatus,
|
||||
)
|
||||
@@ -99,6 +101,10 @@ class EventServiceBase:
|
||||
"""Emitted when a batch is enqueued"""
|
||||
self.dispatch(BatchEnqueuedEvent.build(enqueue_result))
|
||||
|
||||
def emit_queue_items_retried(self, retry_result: "RetryItemsResult") -> None:
|
||||
"""Emitted when a list of queue items are retried"""
|
||||
self.dispatch(QueueItemsRetriedEvent.build(retry_result))
|
||||
|
||||
def emit_queue_cleared(self, queue_id: str) -> None:
|
||||
"""Emitted when a queue is cleared"""
|
||||
self.dispatch(QueueClearedEvent.build(queue_id))
|
||||
|
||||
@@ -10,6 +10,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
QUEUE_ITEM_STATUS,
|
||||
BatchStatus,
|
||||
EnqueueBatchResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueItem,
|
||||
SessionQueueStatus,
|
||||
)
|
||||
@@ -290,6 +291,22 @@ class BatchEnqueuedEvent(QueueEventBase):
|
||||
)
|
||||
|
||||
|
||||
@payload_schema.register
|
||||
class QueueItemsRetriedEvent(QueueEventBase):
|
||||
"""Event model for queue_items_retried"""
|
||||
|
||||
__event_name__ = "queue_items_retried"
|
||||
|
||||
retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
|
||||
|
||||
@classmethod
|
||||
def build(cls, retry_result: RetryItemsResult) -> "QueueItemsRetriedEvent":
|
||||
return cls(
|
||||
queue_id=retry_result.queue_id,
|
||||
retried_item_ids=retry_result.retried_item_ids,
|
||||
)
|
||||
|
||||
|
||||
@payload_schema.register
|
||||
class QueueClearedEvent(QueueEventBase):
|
||||
"""Event model for queue_cleared"""
|
||||
|
||||
@@ -14,6 +14,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
IsEmptyResult,
|
||||
IsFullResult,
|
||||
PruneResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueCountsByDestination,
|
||||
SessionQueueItem,
|
||||
SessionQueueItemDTO,
|
||||
@@ -139,3 +140,8 @@ class SessionQueueBase(ABC):
|
||||
def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem:
|
||||
"""Sets the session for a session queue item. Use this to update the session state."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
|
||||
"""Retries the given queue items"""
|
||||
pass
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
from itertools import chain, product
|
||||
from typing import Generator, Iterable, Literal, NamedTuple, Optional, TypeAlias, Union, cast
|
||||
from typing import Generator, Literal, Optional, TypeAlias, Union, cast
|
||||
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
@@ -234,6 +234,9 @@ class SessionQueueItemWithoutGraph(BaseModel):
|
||||
field_values: Optional[list[NodeFieldValue]] = Field(
|
||||
default=None, description="The field values that were used for this queue item"
|
||||
)
|
||||
retried_from_item_id: Optional[int] = Field(
|
||||
default=None, description="The item_id of the queue item that this item was retried from"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO":
|
||||
@@ -344,6 +347,11 @@ class EnqueueBatchResult(BaseModel):
|
||||
priority: int = Field(description="The priority of the enqueued batch")
|
||||
|
||||
|
||||
class RetryItemsResult(BaseModel):
|
||||
queue_id: str = Field(description="The ID of the queue")
|
||||
retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
|
||||
|
||||
|
||||
class ClearResult(BaseModel):
|
||||
"""Result of clearing the session queue"""
|
||||
|
||||
@@ -398,61 +406,143 @@ class IsFullResult(BaseModel):
|
||||
# region Util
|
||||
|
||||
|
||||
def populate_graph(graph: Graph, node_field_values: Iterable[NodeFieldValue]) -> Graph:
|
||||
def create_session_nfv_tuples(batch: Batch, maximum: int) -> Generator[tuple[str, str, str], None, None]:
|
||||
"""
|
||||
Populates the given graph with the given batch data items.
|
||||
"""
|
||||
graph_clone = graph.model_copy(deep=True)
|
||||
for item in node_field_values:
|
||||
node = graph_clone.get_node(item.node_path)
|
||||
if node is None:
|
||||
continue
|
||||
setattr(node, item.field_name, item.value)
|
||||
graph_clone.update_node(item.node_path, node)
|
||||
return graph_clone
|
||||
Given a batch and a maximum number of sessions to create, generate a tuple of session_id, session_json, and
|
||||
field_values_json for each session.
|
||||
|
||||
The batch has a "source" graph and a data property. The data property is a list of lists of BatchDatum objects.
|
||||
Each BatchDatum has a field identifier (e.g. a node id and field name), and a list of values to substitute into
|
||||
the field.
|
||||
|
||||
def create_session_nfv_tuples(
|
||||
batch: Batch, maximum: int
|
||||
) -> Generator[tuple[GraphExecutionState, list[NodeFieldValue], Optional[WorkflowWithoutID]], None, None]:
|
||||
"""
|
||||
Create all graph permutations from the given batch data and graph. Yields tuples
|
||||
of the form (graph, batch_data_items) where batch_data_items is the list of BatchDataItems
|
||||
that was applied to the graph.
|
||||
This structure allows us to create a new graph for every possible permutation of BatchDatum objects:
|
||||
- Each BatchDatum can be "expanded" into a dict of node-field-value tuples - one for each item in the BatchDatum.
|
||||
- Zip each inner list of expanded BatchDatum objects together. Call this a "batch_data_list".
|
||||
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of permutations of BatchDatum
|
||||
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of lists of BatchDatum objects.
|
||||
Each inner list now represents the substitution values for a single permutation (session).
|
||||
- For each permutation, substitute the values into the graph
|
||||
|
||||
This function is optimized for performance, as it is used to generate a large number of sessions at once.
|
||||
|
||||
Args:
|
||||
batch: The batch to generate sessions from
|
||||
maximum: The maximum number of sessions to generate
|
||||
|
||||
Returns:
|
||||
A generator that yields tuples of session_id, session_json, and field_values_json for each session. The
|
||||
generator will stop early if the maximum number of sessions is reached.
|
||||
"""
|
||||
|
||||
# TODO: Should this be a class method on Batch?
|
||||
|
||||
data: list[list[tuple[NodeFieldValue]]] = []
|
||||
data: list[list[tuple[dict]]] = []
|
||||
batch_data_collection = batch.data if batch.data is not None else []
|
||||
for batch_datum_list in batch_data_collection:
|
||||
# each batch_datum_list needs to be convered to NodeFieldValues and then zipped
|
||||
|
||||
node_field_values_to_zip: list[list[NodeFieldValue]] = []
|
||||
for batch_datum_list in batch_data_collection:
|
||||
node_field_values_to_zip: list[list[dict]] = []
|
||||
# Expand each BatchDatum into a list of dicts - one for each item in the BatchDatum
|
||||
for batch_datum in batch_datum_list:
|
||||
node_field_values = [
|
||||
NodeFieldValue(node_path=batch_datum.node_path, field_name=batch_datum.field_name, value=item)
|
||||
# Note: A tuple here is slightly faster than a dict, but we need the object in dict form to be inserted
|
||||
# in the session_queue table anyways. So, overall creating NFVs as dicts is faster.
|
||||
{"node_path": batch_datum.node_path, "field_name": batch_datum.field_name, "value": item}
|
||||
for item in batch_datum.items
|
||||
]
|
||||
node_field_values_to_zip.append(node_field_values)
|
||||
# Zip the dicts together to create a list of dicts for each permutation
|
||||
data.append(list(zip(*node_field_values_to_zip, strict=True))) # type: ignore [arg-type]
|
||||
|
||||
# create generator to yield session,nfv tuples
|
||||
# We serialize the graph and session once, then mutate the graph dict in place for each session.
|
||||
#
|
||||
# This sounds scary, but it's actually fine.
|
||||
#
|
||||
# The batch prep logic injects field values into the same fields for each generated session.
|
||||
#
|
||||
# For example, after the product operation, we'll end up with a list of node-field-value tuples like this:
|
||||
# [
|
||||
# (
|
||||
# {"node_path": "1", "field_name": "a", "value": 1},
|
||||
# {"node_path": "2", "field_name": "b", "value": 2},
|
||||
# {"node_path": "3", "field_name": "c", "value": 3},
|
||||
# ),
|
||||
# (
|
||||
# {"node_path": "1", "field_name": "a", "value": 4},
|
||||
# {"node_path": "2", "field_name": "b", "value": 5},
|
||||
# {"node_path": "3", "field_name": "c", "value": 6},
|
||||
# )
|
||||
# ]
|
||||
#
|
||||
# Note that each tuple has the same length, and each tuple substitutes values in for exactly the same node fields.
|
||||
# No matter the complexity of the batch, this property holds true.
|
||||
#
|
||||
# This means each permutation's substitution can be done in-place on the same graph dict, because it overwrites the
|
||||
# previous mutation. We only need to serialize the graph once, and then we can mutate it in place for each session.
|
||||
#
|
||||
# Previously, we had created new Graph objects for each session, but this was very slow for large (1k+ session
|
||||
# batches). We then tried dumping the graph to dict and using deep-copy to create a new dict for each session,
|
||||
# but this was also slow.
|
||||
#
|
||||
# Overall, we achieved a 100x speedup by mutating the graph dict in place for each session over creating new Graph
|
||||
# objects for each session.
|
||||
#
|
||||
# We will also mutate the session dict in place, setting a new ID for each session and setting the mutated graph
|
||||
# dict as the session's graph.
|
||||
|
||||
# Dump the batch's graph to a dict once
|
||||
graph_as_dict = batch.graph.model_dump(warnings=False, exclude_none=True)
|
||||
|
||||
# We must provide a Graph object when creating the "dummy" session dict, but we don't actually use it. It will be
|
||||
# overwritten for each session by the mutated graph_as_dict.
|
||||
session_dict = GraphExecutionState(graph=Graph()).model_dump(warnings=False, exclude_none=True)
|
||||
|
||||
# Now we can create a generator that yields the session_id, session_json, and field_values_json for each session.
|
||||
count = 0
|
||||
|
||||
# Each batch may have multiple runs, so we need to generate the same number of sessions for each run. The total is
|
||||
# still limited by the maximum number of sessions.
|
||||
for _ in range(batch.runs):
|
||||
for d in product(*data):
|
||||
if count >= maximum:
|
||||
# We've reached the maximum number of sessions we may generate
|
||||
return
|
||||
|
||||
# Flatten the list of lists of dicts into a single list of dicts
|
||||
# TODO(psyche): Is the a more efficient way to do this?
|
||||
flat_node_field_values = list(chain.from_iterable(d))
|
||||
graph = populate_graph(batch.graph, flat_node_field_values)
|
||||
yield (GraphExecutionState(graph=graph), flat_node_field_values, batch.workflow)
|
||||
|
||||
# Need a fresh ID for each session
|
||||
session_id = uuid_string()
|
||||
|
||||
# Mutate the session dict in place
|
||||
session_dict["id"] = session_id
|
||||
|
||||
# Substitute the values into the graph
|
||||
for nfv in flat_node_field_values:
|
||||
graph_as_dict["nodes"][nfv["node_path"]][nfv["field_name"]] = nfv["value"]
|
||||
|
||||
# Mutate the session dict in place
|
||||
session_dict["graph"] = graph_as_dict
|
||||
|
||||
# Serialize the session and field values
|
||||
# Note the use of pydantic's to_jsonable_python to handle serialization of any python object, including sets.
|
||||
session_json = json.dumps(session_dict, default=to_jsonable_python)
|
||||
field_values_json = json.dumps(flat_node_field_values, default=to_jsonable_python)
|
||||
|
||||
# Yield the session_id, session_json, and field_values_json
|
||||
yield (session_id, session_json, field_values_json)
|
||||
|
||||
# Increment the count so we know when to stop
|
||||
count += 1
|
||||
|
||||
|
||||
def calc_session_count(batch: Batch) -> int:
|
||||
"""
|
||||
Calculates the number of sessions that would be created by the batch, without incurring
|
||||
the overhead of actually generating them. Adapted from `create_sessions().
|
||||
Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
|
||||
creating them, as is done in `create_session_nfv_tuples()`.
|
||||
|
||||
The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
|
||||
many were _actually_ created (which may be less due to the maximum number of sessions).
|
||||
"""
|
||||
# TODO: Should this be a class method on Batch?
|
||||
if not batch.data:
|
||||
@@ -468,41 +558,75 @@ def calc_session_count(batch: Batch) -> int:
|
||||
return len(data_product) * batch.runs
|
||||
|
||||
|
||||
class SessionQueueValueToInsert(NamedTuple):
|
||||
"""A tuple of values to insert into the session_queue table"""
|
||||
|
||||
# Careful with the ordering of this - it must match the insert statement
|
||||
queue_id: str # queue_id
|
||||
session: str # session json
|
||||
session_id: str # session_id
|
||||
batch_id: str # batch_id
|
||||
field_values: Optional[str] # field_values json
|
||||
priority: int # priority
|
||||
workflow: Optional[str] # workflow json
|
||||
origin: str | None
|
||||
destination: str | None
|
||||
ValueToInsertTuple: TypeAlias = tuple[
|
||||
str, # queue_id
|
||||
str, # session (as stringified JSON)
|
||||
str, # session_id
|
||||
str, # batch_id
|
||||
str | None, # field_values (optional, as stringified JSON)
|
||||
int, # priority
|
||||
str | None, # workflow (optional, as stringified JSON)
|
||||
str | None, # origin (optional)
|
||||
str | None, # destination (optional)
|
||||
str | None, # retried_from_item_id (optional, this is always None for new items)
|
||||
]
|
||||
"""A type alias for the tuple of values to insert into the session queue table."""
|
||||
|
||||
|
||||
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
|
||||
def prepare_values_to_insert(
|
||||
queue_id: str, batch: Batch, priority: int, max_new_queue_items: int
|
||||
) -> list[ValueToInsertTuple]:
|
||||
"""
|
||||
Given a batch, prepare the values to insert into the session queue table. The list of tuples can be used with an
|
||||
`executemany` statement to insert multiple rows at once.
|
||||
|
||||
Args:
|
||||
queue_id: The ID of the queue to insert the items into
|
||||
batch: The batch to prepare the values for
|
||||
priority: The priority of the queue items
|
||||
max_new_queue_items: The maximum number of queue items to insert
|
||||
|
||||
def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new_queue_items: int) -> ValuesToInsert:
|
||||
values_to_insert: ValuesToInsert = []
|
||||
for session, field_values, workflow in create_session_nfv_tuples(batch, max_new_queue_items):
|
||||
# sessions must have unique id
|
||||
session.id = uuid_string()
|
||||
Returns:
|
||||
A list of tuples to insert into the session queue table. Each tuple contains the following values:
|
||||
- queue_id
|
||||
- session (as stringified JSON)
|
||||
- session_id
|
||||
- batch_id
|
||||
- field_values (optional, as stringified JSON)
|
||||
- priority
|
||||
- workflow (optional, as stringified JSON)
|
||||
- origin (optional)
|
||||
- destination (optional)
|
||||
- retried_from_item_id (optional, this is always None for new items)
|
||||
"""
|
||||
|
||||
# A tuple is a fast and memory-efficient way to store the values to insert. Previously, we used a NamedTuple, but
|
||||
# measured a ~5% performance improvement by using a normal tuple instead. For very large batches (10k+ items), the
|
||||
# this difference becomes noticeable.
|
||||
#
|
||||
# So, despite the inferior DX with normal tuples, we use one here for performance reasons.
|
||||
|
||||
values_to_insert: list[ValueToInsertTuple] = []
|
||||
|
||||
# pydantic's to_jsonable_python handles serialization of any python object, including sets, which json.dumps does
|
||||
# not support by default. Apparently there are sets somewhere in the graph.
|
||||
|
||||
# The same workflow is used for all sessions in the batch - serialize it once
|
||||
workflow_json = json.dumps(batch.workflow, default=to_jsonable_python) if batch.workflow else None
|
||||
|
||||
for session_id, session_json, field_values_json in create_session_nfv_tuples(batch, max_new_queue_items):
|
||||
values_to_insert.append(
|
||||
SessionQueueValueToInsert(
|
||||
queue_id, # queue_id
|
||||
session.model_dump_json(warnings=False, exclude_none=True), # session (json)
|
||||
session.id, # session_id
|
||||
batch.batch_id, # batch_id
|
||||
# must use pydantic_encoder bc field_values is a list of models
|
||||
json.dumps(field_values, default=to_jsonable_python) if field_values else None, # field_values (json)
|
||||
priority, # priority
|
||||
json.dumps(workflow, default=to_jsonable_python) if workflow else None, # workflow (json)
|
||||
batch.origin, # origin
|
||||
batch.destination, # destination
|
||||
(
|
||||
queue_id,
|
||||
session_json,
|
||||
session_id,
|
||||
batch.batch_id,
|
||||
field_values_json,
|
||||
priority,
|
||||
workflow_json,
|
||||
batch.origin,
|
||||
batch.destination,
|
||||
None,
|
||||
)
|
||||
)
|
||||
return values_to_insert
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Optional, Union, cast
|
||||
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase
|
||||
from invokeai.app.services.session_queue.session_queue_common import (
|
||||
@@ -18,6 +21,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
IsEmptyResult,
|
||||
IsFullResult,
|
||||
PruneResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueCountsByDestination,
|
||||
SessionQueueItem,
|
||||
SessionQueueItemDTO,
|
||||
@@ -130,8 +134,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
|
||||
self.__cursor.executemany(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
values_to_insert,
|
||||
)
|
||||
@@ -761,3 +765,71 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
canceled=counts.get("canceled", 0),
|
||||
total=total,
|
||||
)
|
||||
|
||||
def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
|
||||
"""Retries the given queue items"""
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
|
||||
values_to_insert: list[tuple] = []
|
||||
retried_item_ids: list[int] = []
|
||||
|
||||
for item_id in item_ids:
|
||||
queue_item = self.get_queue_item(item_id)
|
||||
|
||||
if queue_item.status not in ("failed", "canceled"):
|
||||
continue
|
||||
|
||||
retried_item_ids.append(item_id)
|
||||
|
||||
field_values_json = (
|
||||
json.dumps(queue_item.field_values, default=to_jsonable_python) if queue_item.field_values else None
|
||||
)
|
||||
workflow_json = (
|
||||
json.dumps(queue_item.workflow, default=to_jsonable_python) if queue_item.workflow else None
|
||||
)
|
||||
cloned_session = GraphExecutionState(graph=queue_item.session.graph)
|
||||
cloned_session_json = cloned_session.model_dump_json(warnings=False, exclude_none=True)
|
||||
|
||||
retried_from_item_id = (
|
||||
queue_item.retried_from_item_id
|
||||
if queue_item.retried_from_item_id is not None
|
||||
else queue_item.item_id
|
||||
)
|
||||
|
||||
value_to_insert = (
|
||||
queue_item.queue_id,
|
||||
queue_item.batch_id,
|
||||
queue_item.destination,
|
||||
field_values_json,
|
||||
queue_item.origin,
|
||||
queue_item.priority,
|
||||
workflow_json,
|
||||
cloned_session_json,
|
||||
cloned_session.id,
|
||||
retried_from_item_id,
|
||||
)
|
||||
values_to_insert.append(value_to_insert)
|
||||
|
||||
# TODO(psyche): Handle max queue size?
|
||||
|
||||
self.__cursor.executemany(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
values_to_insert,
|
||||
)
|
||||
|
||||
self.__conn.commit()
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
retry_result = RetryItemsResult(
|
||||
queue_id=queue_id,
|
||||
retried_item_ids=retried_item_ids,
|
||||
)
|
||||
self.__invoker.services.events.emit_queue_items_retried(retry_result)
|
||||
return retry_result
|
||||
|
||||
@@ -18,6 +18,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_12 import
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_13 import build_migration_13
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_14 import build_migration_14
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_15 import build_migration_15
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_16 import build_migration_16
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
|
||||
|
||||
|
||||
@@ -53,6 +54,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
|
||||
migrator.register_migration(build_migration_13())
|
||||
migrator.register_migration(build_migration_14())
|
||||
migrator.register_migration(build_migration_15())
|
||||
migrator.register_migration(build_migration_16())
|
||||
migrator.run_migrations()
|
||||
|
||||
return db
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import sqlite3
|
||||
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
|
||||
|
||||
class Migration16Callback:
|
||||
def __call__(self, cursor: sqlite3.Cursor) -> None:
|
||||
self._add_retried_from_item_id_col(cursor)
|
||||
|
||||
def _add_retried_from_item_id_col(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""
|
||||
- Adds `retried_from_item_id` column to the session queue table.
|
||||
"""
|
||||
|
||||
cursor.execute("ALTER TABLE session_queue ADD COLUMN retried_from_item_id INTEGER;")
|
||||
|
||||
|
||||
def build_migration_16() -> Migration:
|
||||
"""
|
||||
Build the migration from database version 15 to 16.
|
||||
|
||||
This migration does the following:
|
||||
- Adds `retried_from_item_id` column to the session queue table.
|
||||
"""
|
||||
migration_16 = Migration(
|
||||
from_version=15,
|
||||
to_version=16,
|
||||
callback=Migration16Callback(),
|
||||
)
|
||||
|
||||
return migration_16
|
||||
@@ -62,9 +62,13 @@ class WorkflowWithoutID(BaseModel):
|
||||
notes: str = Field(description="The notes of the workflow.")
|
||||
exposedFields: list[ExposedField] = Field(description="The exposed fields of the workflow.")
|
||||
meta: WorkflowMeta = Field(description="The meta of the workflow.")
|
||||
# TODO: nodes and edges are very loosely typed
|
||||
# TODO(psyche): nodes, edges and form are very loosely typed - they are strictly modeled and checked on the frontend.
|
||||
nodes: list[dict[str, JsonValue]] = Field(description="The nodes of the workflow.")
|
||||
edges: list[dict[str, JsonValue]] = Field(description="The edges of the workflow.")
|
||||
# TODO(psyche): We have a crapload of workflows that have no form, bc it was added after we introduced workflows.
|
||||
# This is typed as optional to prevent errors when pulling workflows from the DB. The frontend adds a default form if
|
||||
# it is None.
|
||||
form: dict[str, JsonValue] | None = Field(default=None, description="The form of the workflow.")
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@@ -58,10 +58,11 @@
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dagrejs/graphlib": "^2.2.4",
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"@invoke-ai/ui-library": "^0.0.44",
|
||||
"@invoke-ai/ui-library": "^0.0.46",
|
||||
"@nanostores/react": "^0.7.3",
|
||||
"@reduxjs/toolkit": "2.2.3",
|
||||
"@reduxjs/toolkit": "2.5.1",
|
||||
"@roarr/browser-log-writer": "^1.3.0",
|
||||
"@xyflow/react": "^12.4.2",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chakra-react-select": "^4.9.2",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -96,9 +97,9 @@
|
||||
"react-icons": "^5.3.0",
|
||||
"react-redux": "9.1.2",
|
||||
"react-resizable-panels": "^2.1.4",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.10.4",
|
||||
"reactflow": "^11.11.4",
|
||||
"redux-dynamic-middlewares": "^2.2.0",
|
||||
"redux-remember": "^5.1.0",
|
||||
"redux-undo": "^1.1.0",
|
||||
@@ -135,8 +136,8 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/ui": "^3.0.5",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"@vitest/ui": "^3.0.6",
|
||||
"concurrently": "^8.2.2",
|
||||
"csstype": "^3.1.3",
|
||||
"dpdm": "^3.14.0",
|
||||
@@ -157,7 +158,7 @@
|
||||
"vite-plugin-dts": "^4.5.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": "8"
|
||||
|
||||
1783
invokeai/frontend/web/pnpm-lock.yaml
generated
1783
invokeai/frontend/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@
|
||||
"back": "Back",
|
||||
"batch": "Batch Manager",
|
||||
"beta": "Beta",
|
||||
"board": "Board",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
@@ -187,7 +188,10 @@
|
||||
"values": "Values",
|
||||
"resetToDefaults": "Reset to Defaults",
|
||||
"seed": "Seed",
|
||||
"combinatorial": "Combinatorial"
|
||||
"combinatorial": "Combinatorial",
|
||||
"layout": "Layout",
|
||||
"row": "Row",
|
||||
"column": "Column"
|
||||
},
|
||||
"hrf": {
|
||||
"hrf": "High Resolution Fix",
|
||||
@@ -225,6 +229,8 @@
|
||||
"cancelTooltip": "Cancel Current Item",
|
||||
"cancelSucceeded": "Item Canceled",
|
||||
"cancelFailed": "Problem Canceling Item",
|
||||
"retrySucceeded": "Item Retried",
|
||||
"retryFailed": "Problem Retrying Item",
|
||||
"confirm": "Confirm",
|
||||
"prune": "Prune",
|
||||
"pruneTooltip": "Prune {{item_count}} Completed Items",
|
||||
@@ -236,6 +242,7 @@
|
||||
"clearFailed": "Problem Clearing Queue",
|
||||
"cancelBatch": "Cancel Batch",
|
||||
"cancelItem": "Cancel Item",
|
||||
"retryItem": "Retry Item",
|
||||
"cancelBatchSucceeded": "Batch Canceled",
|
||||
"cancelBatchFailed": "Problem Canceling Batch",
|
||||
"clearQueueAlertDialog": "Clearing the queue immediately cancels any processing items and clears the queue entirely. Pending filters will be canceled.",
|
||||
@@ -305,6 +312,7 @@
|
||||
},
|
||||
"gallery": {
|
||||
"gallery": "Gallery",
|
||||
"images": "Images",
|
||||
"assets": "Assets",
|
||||
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
|
||||
"assetsTab": "Files you’ve uploaded for use in your projects.",
|
||||
@@ -875,11 +883,15 @@
|
||||
"parseString": "Parse String",
|
||||
"splitOn": "Split On",
|
||||
"noBatchGroup": "no group",
|
||||
"generatorImagesCategory": "Category",
|
||||
"generatorImages_one": "{{count}} image",
|
||||
"generatorImages_other": "{{count}} images",
|
||||
"generatorNRandomValues_one": "{{count}} random value",
|
||||
"generatorNRandomValues_other": "{{count}} random values",
|
||||
"generatorNoValues": "empty",
|
||||
"generatorLoading": "loading",
|
||||
"generatorLoadFromFile": "Load from File",
|
||||
"generatorImagesFromBoard": "Images from Board",
|
||||
"dynamicPromptsRandom": "Dynamic Prompts (Random)",
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Combinatorial)",
|
||||
"addNode": "Add Node",
|
||||
@@ -896,6 +908,8 @@
|
||||
"missingNode": "Missing invocation node",
|
||||
"missingInvocationTemplate": "Missing invocation template",
|
||||
"missingFieldTemplate": "Missing field template",
|
||||
"missingSourceOrTargetNode": "Missing source or target node",
|
||||
"missingSourceOrTargetHandle": "Missing source or target handle",
|
||||
"nodePack": "Node pack",
|
||||
"collection": "Collection",
|
||||
"singleFieldType": "{{name}} (Single)",
|
||||
@@ -931,6 +945,7 @@
|
||||
"noWorkflows": "No Workflows",
|
||||
"noMatchingWorkflows": "No Matching Workflows",
|
||||
"noWorkflow": "No Workflow",
|
||||
"unableToUpdateNode": "Node update failed: node {{node}} of type {{type}} (may require deleting and recreating)",
|
||||
"mismatchedVersion": "Invalid node: node {{node}} of type {{type}} has mismatched version (try updating?)",
|
||||
"missingTemplate": "Invalid node: node {{node}} of type {{type}} missing template (not installed?)",
|
||||
"sourceNodeDoesNotExist": "Invalid edge: source/output node {{node}} does not exist",
|
||||
@@ -938,12 +953,14 @@
|
||||
"sourceNodeFieldDoesNotExist": "Invalid edge: source/output field {{node}}.{{field}} does not exist",
|
||||
"targetNodeFieldDoesNotExist": "Invalid edge: target/input field {{node}}.{{field}} does not exist",
|
||||
"deletedInvalidEdge": "Deleted invalid edge {{source}} -> {{target}}",
|
||||
"deletedMissingNodeFieldFormElement": "Deleted missing form field: node {{nodeId}} field {{fieldName}}",
|
||||
"noConnectionInProgress": "No connection in progress",
|
||||
"node": "Node",
|
||||
"nodeOutputs": "Node Outputs",
|
||||
"nodeSearch": "Search for nodes",
|
||||
"nodeTemplate": "Node Template",
|
||||
"nodeType": "Node Type",
|
||||
"nodeName": "Node Name",
|
||||
"noFieldsLinearview": "No fields added to Linear View",
|
||||
"noFieldsViewMode": "This workflow has no selected fields to display. View the full workflow to configure values.",
|
||||
"workflowHelpText": "Need Help? Check out our guide to <LinkComponent>Getting Started with Workflows</LinkComponent>.",
|
||||
@@ -952,6 +969,7 @@
|
||||
"nodeVersion": "Node Version",
|
||||
"noOutputRecorded": "No outputs recorded",
|
||||
"notes": "Notes",
|
||||
"description": "Description",
|
||||
"notesDescription": "Add notes about your workflow",
|
||||
"problemSettingTitle": "Problem Setting Title",
|
||||
"resetToDefaultValue": "Reset to default value",
|
||||
@@ -961,6 +979,8 @@
|
||||
"newWorkflow": "New Workflow",
|
||||
"newWorkflowDesc": "Create a new workflow?",
|
||||
"newWorkflowDesc2": "Your current workflow has unsaved changes.",
|
||||
"loadWorkflowDesc": "Load workflow?",
|
||||
"loadWorkflowDesc2": "Your current workflow has unsaved changes.",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"clearWorkflowDesc": "Clear this workflow and start a new one?",
|
||||
"clearWorkflowDesc2": "Your current workflow has unsaved changes.",
|
||||
@@ -990,6 +1010,7 @@
|
||||
"unknownOutput": "Unknown output: {{name}}",
|
||||
"updateNode": "Update Node",
|
||||
"updateApp": "Update App",
|
||||
"loadingTemplates": "Loading {{name}}",
|
||||
"updateAllNodes": "Update Nodes",
|
||||
"allNodesUpdated": "All Nodes Updated",
|
||||
"unableToUpdateNodes_one": "Unable to update {{count}} node",
|
||||
@@ -1065,7 +1086,7 @@
|
||||
"emptyBatches": "empty batches",
|
||||
"batchNodeNotConnected": "Batch node not connected: {{label}}",
|
||||
"batchNodeEmptyCollection": "Some batch nodes have empty collections",
|
||||
"invalidBatchConfigurationCannotCalculate": "Invalid batch configuration; cannot calculate",
|
||||
"collectionEmpty": "empty collection",
|
||||
"collectionTooFewItems": "too few items, minimum {{minItems}}",
|
||||
"collectionTooManyItems": "too many items, maximum {{maxItems}}",
|
||||
"collectionStringTooLong": "too long, max {{maxLength}}",
|
||||
@@ -1075,6 +1096,7 @@
|
||||
"collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (exc max)",
|
||||
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (exc min)",
|
||||
"collectionNumberNotMultipleOf": "{{value}} not multiple of {{multipleOf}}",
|
||||
"batchNodeCollectionSizeMismatchNoGroupId": "Batch group collection size mismatch",
|
||||
"batchNodeCollectionSizeMismatch": "Collection size mismatch on Batch {{batchGroupId}}",
|
||||
"noModelSelected": "No model selected",
|
||||
"noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation",
|
||||
@@ -1694,7 +1716,34 @@
|
||||
"download": "Download",
|
||||
"copyShareLink": "Copy Share Link",
|
||||
"copyShareLinkForWorkflow": "Copy Share Link for Workflow",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"openLibrary": "Open Library",
|
||||
"builder": {
|
||||
"deleteAllElements": "Delete All Form Elements",
|
||||
"resetAllNodeFields": "Reset All Node Fields",
|
||||
"builder": "Form Builder",
|
||||
"layout": "Layout",
|
||||
"row": "Row",
|
||||
"column": "Column",
|
||||
"nodeField": "Node Field",
|
||||
"zoomToNode": "Zoom to Node",
|
||||
"nodeFieldTooltip": "To add a node field, click the small plus sign button on the field in the Workflow Editor, or drag the field by its name into the form.",
|
||||
"addToForm": "Add to Form",
|
||||
"label": "Label",
|
||||
"showDescription": "Show Description",
|
||||
"component": "Component",
|
||||
"numberInput": "Number Input",
|
||||
"singleLine": "Single Line",
|
||||
"multiLine": "Multi Line",
|
||||
"slider": "Slider",
|
||||
"both": "Both",
|
||||
"emptyRootPlaceholderViewMode": "Click Edit to start building a form for this workflow.",
|
||||
"emptyRootPlaceholderEditMode": "Drag a form element or node field here to get started.",
|
||||
"containerPlaceholder": "Empty Container",
|
||||
"headingPlaceholder": "Empty Heading",
|
||||
"textPlaceholder": "Empty Text",
|
||||
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release."
|
||||
}
|
||||
},
|
||||
"controlLayers": {
|
||||
"regional": "Regional",
|
||||
|
||||
@@ -699,7 +699,6 @@
|
||||
"batchNodeEmptyCollection": "Alcuni nodi lotto hanno raccolte vuote",
|
||||
"emptyBatches": "lotti vuoti",
|
||||
"batchNodeCollectionSizeMismatch": "Le dimensioni della raccolta nel Lotto {{batchGroupId}} non corrispondono",
|
||||
"invalidBatchConfigurationCannotCalculate": "Configurazione lotto non valida; impossibile calcolare",
|
||||
"collectionStringTooShort": "troppo corto, minimo {{minLength}}",
|
||||
"collectionNumberNotMultipleOf": "{{value}} non è multiplo di {{multipleOf}}",
|
||||
"collectionNumberLTMin": "{{value}} < {{minimum}} (incr min)",
|
||||
@@ -2174,7 +2173,11 @@
|
||||
"pasteToCanvas": "Tela",
|
||||
"pasteToCanvasDesc": "Nuovo livello (nella Tela)",
|
||||
"pastedTo": "Incollato su {{destination}}",
|
||||
"regionCopiedToClipboard": "{{region}} Copiato negli appunti"
|
||||
"regionCopiedToClipboard": "{{region}} Copiato negli appunti",
|
||||
"errors": {
|
||||
"unableToFindImage": "Impossibile trovare l'immagine",
|
||||
"unableToLoadImage": "Impossibile caricare l'immagine"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"compareImage": "So Sánh Ảnh",
|
||||
"compareHelp4": "Nhấn <Kbd>Z</Kbd> hoặc <Kbd>Esc</Kbd> để thoát.",
|
||||
"compareHelp3": "Nhấn <Kbd>C</Kbd> để đổi ảnh được so sánh.",
|
||||
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
|
||||
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện ảnh hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
|
||||
"showArchivedBoards": "Hiển Thị Bảng Được Lưu Trữ",
|
||||
"drop": "Thả",
|
||||
"copy": "Sao Chép",
|
||||
@@ -76,11 +76,11 @@
|
||||
"deleteImagePermanent": "Ảnh đã xoá không thể phục hồi.",
|
||||
"exitSearch": "Thoát Tìm Kiếm Hình Ảnh",
|
||||
"exitBoardSearch": "Thoát Tìm Kiểm Bảng",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"galleryImageSize": "Kích Thước Ảnh",
|
||||
"downloadSelection": "Tải xuống Phần Được Lựa Chọn",
|
||||
"bulkDownloadRequested": "Chuẩn Bị Tải Xuống",
|
||||
"unableToLoad": "Không Thể Tải Thư viện",
|
||||
"unableToLoad": "Không Thể Tải Thư viện Ảnh",
|
||||
"newestFirst": "Mới Nhất Trước",
|
||||
"showStarredImagesFirst": "Hiển Thị Ảnh Gắn Sao Trước",
|
||||
"bulkDownloadRequestedDesc": "Yêu cầu tải xuống đang được chuẩn bị. Vui lòng chờ trong giây lát.",
|
||||
@@ -103,7 +103,7 @@
|
||||
"displaySearch": "Tìm Kiếm Hình Ảnh",
|
||||
"selectAnImageToCompare": "Chọn Ảnh Để So Sánh",
|
||||
"slider": "Thanh Trượt",
|
||||
"gallerySettings": "Cài Đặt Thư Viện",
|
||||
"gallerySettings": "Cài Đặt Thư Viện Ảnh",
|
||||
"image": "hình ảnh",
|
||||
"noImageSelected": "Không Có Ảnh Được Chọn",
|
||||
"noImagesInGallery": "Không Có Ảnh Để Hiển Thị",
|
||||
@@ -117,7 +117,7 @@
|
||||
"unstarImage": "Ngừng Gắn Sao Cho Ảnh",
|
||||
"compareHelp2": "Nhấn <Kbd>M</Kbd> để tuần hoàn trong chế độ so sánh.",
|
||||
"boardsSettings": "Thiết Lập Bảng",
|
||||
"imagesSettings": "Cài Đặt Thư Viện Ảnh",
|
||||
"imagesSettings": "Cài Đặt Ảnh Trong Thư Viện Ảnh",
|
||||
"assets": "Tài Nguyên"
|
||||
},
|
||||
"common": {
|
||||
@@ -230,7 +230,10 @@
|
||||
"max": "Tối Đa",
|
||||
"resetToDefaults": "Đặt Lại Về Mặc Định",
|
||||
"seed": "Hạt Giống",
|
||||
"combinatorial": "Tổ Hợp"
|
||||
"combinatorial": "Tổ Hợp",
|
||||
"column": "Cột",
|
||||
"layout": "Bố Cục",
|
||||
"row": "Hàng"
|
||||
},
|
||||
"prompt": {
|
||||
"addPromptTrigger": "Thêm Prompt Trigger",
|
||||
@@ -285,7 +288,7 @@
|
||||
"cancelBatch": "Huỷ Bỏ Lô",
|
||||
"status": "Trạng Thái",
|
||||
"pending": "Đang Chờ",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"front": "trước",
|
||||
"batch": "Lô",
|
||||
"origin": "Nguồn Gốc",
|
||||
@@ -305,10 +308,13 @@
|
||||
"graphQueued": "Đồ Thị Đã Vào Hàng",
|
||||
"batchQueuedDesc_other": "Thêm {{count}} phiên vào {{direction}} của hàng",
|
||||
"batchSize": "Kích Thước Lô",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "Huỷ tất cả mục đang xếp hàng ngoại trừ mục hiện tại, sẽ dừng các mục đang chờ nhưng cho phép các mục đang chạy được hoàn tất.",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "Huỷ tất cả mục đang xếp hàng ngoại trừ việc nó sẽ dừng các mục đang chờ nhưng cho phép các mục đang chạy được hoàn tất.",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog2": "Bạn có chắc muốn huỷ tất cả mục đang chờ?",
|
||||
"cancelAllExceptCurrentTooltip": "Huỷ Bỏ Tất Cả Ngoại Trừ Mục Hiện Tại",
|
||||
"confirm": "Đồng Ý"
|
||||
"confirm": "Đồng Ý",
|
||||
"retrySucceeded": "Mục Đã Thử Lại",
|
||||
"retryFailed": "Có Vấn Đề Khi Thử Lại Mục",
|
||||
"retryItem": "Thử Lại Mục"
|
||||
},
|
||||
"hotkeys": {
|
||||
"canvas": {
|
||||
@@ -514,16 +520,16 @@
|
||||
},
|
||||
"gallery": {
|
||||
"galleryNavRight": {
|
||||
"desc": "Sang phải theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
|
||||
"desc": "Sang phải theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
|
||||
"title": "Sang Phải"
|
||||
},
|
||||
"galleryNavDown": {
|
||||
"title": "Đi Xuống",
|
||||
"desc": "Đi xuống theo mạng lưới thư viện, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
|
||||
"desc": "Đi xuống theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
|
||||
},
|
||||
"galleryNavLeft": {
|
||||
"title": "Sang Trái",
|
||||
"desc": "Sang trái theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
|
||||
"desc": "Sang trái theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
|
||||
},
|
||||
"galleryNavUpAlt": {
|
||||
"title": "Đi Lên (So Sánh Ảnh)",
|
||||
@@ -535,7 +541,7 @@
|
||||
},
|
||||
"galleryNavUp": {
|
||||
"title": "Đi Lên",
|
||||
"desc": "Đi lên theo mạng lưới thư viện, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
|
||||
"desc": "Đi lên theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
|
||||
},
|
||||
"galleryNavRightAlt": {
|
||||
"title": "Sang Phải (So Sánh Ảnh)",
|
||||
@@ -545,7 +551,7 @@
|
||||
"title": "Chọn Tất Cả Trên Trang",
|
||||
"desc": "Chọn tất cả ảnh trên trang hiện tại."
|
||||
},
|
||||
"title": "Thư Viện",
|
||||
"title": "Thư Viện Ảnh",
|
||||
"galleryNavDownAlt": {
|
||||
"title": "Đi Xuống (So Sánh Ảnh)",
|
||||
"desc": "Giống với \"Đi Xuống\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở."
|
||||
@@ -964,7 +970,7 @@
|
||||
"versionUnknown": " Phiên Bản Không Rõ",
|
||||
"workflowContact": "Thông Tin Liên Lạc",
|
||||
"workflowName": "Tên",
|
||||
"saveToGallery": "Lưu Vào Thư Viện",
|
||||
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
|
||||
"connectionWouldCreateCycle": "Kết nối này sẽ tạo ra vòng lặp",
|
||||
"addNode": "Thêm Node",
|
||||
"unsupportedAnyOfLength": "quá nhiều dữ liệu hợp nhất: {{count}}",
|
||||
@@ -995,7 +1001,16 @@
|
||||
"generatorLoading": "đang tải",
|
||||
"generatorLoadFromFile": "Tải Từ Tệp",
|
||||
"dynamicPromptsRandom": "Dynamic Prompts (Ngẫu Nhiên)",
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)"
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)",
|
||||
"missingSourceOrTargetNode": "Thiếu nguồn hoặc node mục tiêu",
|
||||
"missingSourceOrTargetHandle": "Thiếu nguồn hoặc mục tiêu xử lý",
|
||||
"deletedMissingNodeFieldFormElement": "Xóa vùng nhập bị thiếu: vùng {{fieldName}} của node {{nodeId}}",
|
||||
"description": "Mô Tả",
|
||||
"loadWorkflowDesc": "Tải workflow?",
|
||||
"loadWorkflowDesc2": "Workflow hiện tại của bạn có những điều chỉnh chưa được lưu.",
|
||||
"loadingTemplates": "Đang Tải {{name}}",
|
||||
"nodeName": "Tên Node",
|
||||
"unableToUpdateNode": "Cập nhật node thất bại: node {{node}} thuộc dạng {{type}} (có thể cần xóa và tạo lại)"
|
||||
},
|
||||
"popovers": {
|
||||
"paramCFGRescaleMultiplier": {
|
||||
@@ -1479,8 +1494,7 @@
|
||||
"batchNodeCollectionSizeMismatch": "Kích cỡ tài nguyên không phù hợp với Lô {{batchGroupId}}",
|
||||
"emptyBatches": "lô trống",
|
||||
"batchNodeNotConnected": "Node Hàng Loạt chưa được kết nối: {{label}}",
|
||||
"batchNodeEmptyCollection": "Một vài node hàng loạt có tài nguyên rỗng",
|
||||
"invalidBatchConfigurationCannotCalculate": "Thiết lập lô không hợp lệ; không thể tính toán"
|
||||
"batchNodeEmptyCollection": "Một vài node hàng loạt có tài nguyên rỗng"
|
||||
},
|
||||
"cfgScale": "Thang CFG",
|
||||
"useSeed": "Dùng Hạt Giống",
|
||||
@@ -1582,14 +1596,14 @@
|
||||
"clearIntermediates": "Dọn Sạch Sản Phẩm Trung Gian",
|
||||
"clearIntermediatesDisabled": "Hàng đợi phải trống để dọn dẹp các sản phẩm trung gian",
|
||||
"clearIntermediatesDesc1": "Dọn dẹp các sản phẩm trung gian sẽ làm mới trạng thái của Canvas và ControlNet.",
|
||||
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
|
||||
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện ảnh. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
|
||||
"resetWebUI": "Khởi Động Lại Giao Diện Web",
|
||||
"showProgressInViewer": "Hiển Thị Hình Ảnh Đang Xử Lý Trong Trình Xem",
|
||||
"ui": "Giao Diện Người Dùng",
|
||||
"clearIntermediatesDesc3": "Ảnh trong thư viện sẽ không bị xoá.",
|
||||
"clearIntermediatesDesc3": "Ảnh trong thư viện ảnh sẽ không bị xoá.",
|
||||
"informationalPopoversDisabled": "Hộp Thoại Hỗ Trợ Thông Tin Đã Tắt",
|
||||
"resetComplete": "Giao diện web đã được khởi động lại.",
|
||||
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
|
||||
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện ảnh hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
|
||||
"displayInProgress": "Hiển Thị Hình Ảnh Đang Xử Lý",
|
||||
"intermediatesClearedFailed": "Có Vấn Đề Khi Dọn Sạch Sản Phẩm Trung Gian",
|
||||
"enableInvisibleWatermark": "Bật Chế Độ Ẩn Watermark",
|
||||
@@ -1617,7 +1631,7 @@
|
||||
"width": "Chiều Rộng",
|
||||
"negativePrompt": "Lệnh Tiêu Cực",
|
||||
"removeBookmark": "Bỏ Đánh Dấu",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
|
||||
"global": "Toàn Vùng",
|
||||
"pullBboxIntoReferenceImageError": "Có Vấn Đề Khi Chuyển Hộp Giới Hạn Thành Ảnh Mẫu",
|
||||
"clearHistory": "Xoá Lịch Sử",
|
||||
@@ -1625,12 +1639,12 @@
|
||||
"mergeVisibleOk": "Đã gộp layer",
|
||||
"saveLayerToAssets": "Lưu Layer Vào Khu Tài Nguyên",
|
||||
"canvas": "Canvas",
|
||||
"savedToGalleryOk": "Đã Lưu Vào Thư Viện",
|
||||
"savedToGalleryOk": "Đã Lưu Vào Thư Viện Ảnh",
|
||||
"addGlobalReferenceImage": "Thêm $t(controlLayers.globalReferenceImage)",
|
||||
"clipToBbox": "Chuyển Nét Thành Hộp Giới Hạn",
|
||||
"moveToFront": "Chuyển Lên Trước",
|
||||
"mergeVisible": "Gộp Layer Đang Hiển Thị",
|
||||
"savedToGalleryError": "Lỗi khi lưu vào thư viện",
|
||||
"savedToGalleryError": "Lỗi khi lưu vào thư viện ảnh",
|
||||
"moveToBack": "Chuyển Về Sau",
|
||||
"moveBackward": "Chuyển Xuống Cuối",
|
||||
"newGlobalReferenceImageError": "Có Vấn Đề Khi Tạo Ảnh Mẫu Toàn Vùng",
|
||||
@@ -1650,7 +1664,7 @@
|
||||
"regional": "Khu Vực",
|
||||
"regionIsEmpty": "Vùng được chọn trống",
|
||||
"bookmark": "Đánh Dấu Để Đổi Nhanh",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
|
||||
"cropLayerToBbox": "Xén Layer Vào Hộp Giới Hạn",
|
||||
"mergeDown": "Gộp Xuống",
|
||||
"mergeVisibleError": "Lỗi khi gộp layer",
|
||||
@@ -1718,11 +1732,11 @@
|
||||
"pullBboxIntoLayer": "Chuyển Hộp Giới Hạn Vào Layer",
|
||||
"addInpaintMask": "Thêm $t(controlLayers.inpaintMask)",
|
||||
"addRegionalGuidance": "Thêm $t(controlLayers.regionalGuidance)",
|
||||
"sendToGallery": "Chuyển Tới Thư Viện",
|
||||
"sendToGallery": "Đã Chuyển Tới Thư Viện Ảnh",
|
||||
"unlocked": "Mở Khoá",
|
||||
"addReferenceImage": "Thêm $t(controlLayers.referenceImage)",
|
||||
"sendingToCanvas": "Chuyển Ảnh Tạo Sinh Vào Canvas",
|
||||
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện",
|
||||
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện Ảnh",
|
||||
"viewProgressOnCanvas": "Xem quá trình xử lý và ảnh đầu ra trong <Btn>Canvas</Btn>.",
|
||||
"inpaintMask_withCount_other": "Lớp Phủ Inpaint",
|
||||
"regionalGuidance_withCount_other": "Chỉ Dẫn Khu Vực",
|
||||
@@ -1733,7 +1747,7 @@
|
||||
"copyRasterLayerTo": "Sao Chép $t(controlLayers.rasterLayer) Tới",
|
||||
"copyControlLayerTo": "Sao Chép $t(controlLayers.controlLayer) Tới",
|
||||
"newRegionalGuidance": "$t(controlLayers.regionalGuidance) Mới",
|
||||
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện.",
|
||||
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện ảnh.",
|
||||
"stagingOnCanvas": "Hiển thị hình ảnh lên",
|
||||
"pullBboxIntoReferenceImage": "Chuyển Hộp Giới Hạn Vào Ảnh Mẫu",
|
||||
"maskFill": "Lấp Đầy Lớp Phủ",
|
||||
@@ -1755,8 +1769,8 @@
|
||||
"deleteReferenceImage": "Xoá Ảnh Mẫu",
|
||||
"inpaintMasks_withCount_visible": "Lớp Phủ Inpaint ({{count}})",
|
||||
"disableTransparencyEffect": "Tắt Hiệu Ứng Trong Suốt",
|
||||
"newGallerySession": "Phiên Thư Viện Mới",
|
||||
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện.",
|
||||
"newGallerySession": "Phiên Thư Viện Ảnh Mới",
|
||||
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện ảnh.",
|
||||
"opacity": "Độ Mờ Đục",
|
||||
"rectangle": "Hình Chữ Nhật",
|
||||
"addNegativePrompt": "Thêm $t(controlLayers.negativePrompt)",
|
||||
@@ -1791,13 +1805,13 @@
|
||||
"process": "Xử Lý"
|
||||
},
|
||||
"canvasContextMenu": {
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
|
||||
"newGlobalReferenceImage": "Ảnh Mẫu Toàn Vùng Mới",
|
||||
"cropCanvasToBbox": "Xén Canvas Vào Hộp Giới Hạn",
|
||||
"newRegionalGuidance": "Chỉ Dẫn Khu Vực Mới",
|
||||
"saveToGalleryGroup": "Lưu Vào Thư Viện",
|
||||
"saveToGalleryGroup": "Lưu Vào Thư Viện Ảnh",
|
||||
"newInpaintMask": "Lớp Phủ Inpaint Mới",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
|
||||
"newRegionalReferenceImage": "Ảnh Mẫu Khu Vực Mới",
|
||||
"newControlLayer": "Layer Điều Khiển Được Mới",
|
||||
"newRasterLayer": "Layer Dạng Raster Mới",
|
||||
@@ -1808,7 +1822,7 @@
|
||||
"copyBboxToClipboard": "Sao Chép Hộp Giới Hạn Vào Clipboard"
|
||||
},
|
||||
"stagingArea": {
|
||||
"saveToGallery": "Lưu Vào Thư Viện",
|
||||
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
|
||||
"accept": "Chấp Nhận",
|
||||
"discard": "Bỏ Đi",
|
||||
"previous": "Trước",
|
||||
@@ -2090,7 +2104,7 @@
|
||||
"enableLogging": "Bật Chế Độ Ghi Log",
|
||||
"logNamespaces": {
|
||||
"models": "Models",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"config": "Cấu Hình",
|
||||
"queue": "Queue",
|
||||
"workflows": "Workflow",
|
||||
@@ -2178,7 +2192,7 @@
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"models": "Models",
|
||||
"generation": "Generation (Máy Tạo Sinh)",
|
||||
"upscaling": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)",
|
||||
@@ -2210,7 +2224,7 @@
|
||||
"savingWorkflow": "Đang Lưu Workflow...",
|
||||
"ascending": "Tăng Dần",
|
||||
"loading": "Đang Tải Workflow",
|
||||
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Túi Đồ",
|
||||
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Thư Viện",
|
||||
"workflows": "Workflow",
|
||||
"copyShareLinkForWorkflow": "Sao Chép Liên Kết Chia Sẻ Cho Workflow",
|
||||
"openWorkflow": "Mở Workflow",
|
||||
@@ -2230,11 +2244,38 @@
|
||||
"convertGraph": "Chuyển Đổi Đồ Thị",
|
||||
"saveWorkflowToProject": "Lưu Workflow Vào Dự Án",
|
||||
"workflowName": "Tên Workflow",
|
||||
"workflowLibrary": "Túi Đồ",
|
||||
"workflowLibrary": "Thư Viện",
|
||||
"opened": "Ngày Mở",
|
||||
"deleteWorkflow": "Xoá Workflow",
|
||||
"workflowEditorMenu": "Menu Biên Tập Viên Workflow",
|
||||
"uploadAndSaveWorkflow": "Tải Lên Túi Đồ"
|
||||
"uploadAndSaveWorkflow": "Tải Lên Thư Viện",
|
||||
"openLibrary": "Mở Thư Viện",
|
||||
"builder": {
|
||||
"resetAllNodeFields": "Khởi Động Lại Tất Cả Vùng Cho Node",
|
||||
"builder": "Trình Tạo Vùng Nhập",
|
||||
"layout": "Bố Cục",
|
||||
"row": "Hàng",
|
||||
"zoomToNode": "Phóng To Vào Node",
|
||||
"addToForm": "Thêm Vào Vùng Nhập",
|
||||
"label": "Nhãn Tên",
|
||||
"showDescription": "Hiện Dòng Mô Tả",
|
||||
"component": "Thành Phần",
|
||||
"numberInput": "Nhập Số",
|
||||
"singleLine": "Một Dòng",
|
||||
"multiLine": "Nhiều Dòng",
|
||||
"slider": "Thanh Trượt",
|
||||
"both": "Cả Hai",
|
||||
"emptyRootPlaceholderViewMode": "Chọn Chỉnh Sửa để bắt đầu tạo nên một vùng nhập cho workflow này.",
|
||||
"emptyRootPlaceholderEditMode": "Kéo thành phần vùng nhập hoặc vùng cho node vào đây để bắt đầu.",
|
||||
"containerPlaceholder": "Hộp Chứa Trống",
|
||||
"headingPlaceholder": "Đầu Dòng Trống",
|
||||
"textPlaceholder": "Mô Tả Trống",
|
||||
"column": "Cột",
|
||||
"deleteAllElements": "Xóa Tất Cả Thành Phần Vùng Nhập",
|
||||
"nodeField": "Vùng Cho Node",
|
||||
"nodeFieldTooltip": "Để thêm vùng cho node, bấm vào dấu cộng nhỏ trên vùng trong Vùng Biên Tập Workflow, hoặc kéo vùng theo tên của nó vào vùng nhập.",
|
||||
"workflowBuilderAlphaWarning": "Trình tạo workflow đang trong giai đoạn alpha. Nó có thể xuất hiện những thay đổi đột ngột trước khi chính thức được phát hành."
|
||||
}
|
||||
},
|
||||
"upscaling": {
|
||||
"missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale",
|
||||
@@ -2257,9 +2298,9 @@
|
||||
"incompatibleBaseModelDesc": "Upscale chỉ hỗ trợ cho model phiên bản SD1.5 và SDXL. Đổi model chính để bật lại tính năng upscale."
|
||||
},
|
||||
"newUserExperience": {
|
||||
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"gettingStartedSeries": "Cần thêm hướng dẫn? Xem thử <LinkComponent>Bắt Đầu Làm Quen</LinkComponent> để biết thêm mẹo khai thác toàn bộ tiềm năng của Invoke Studio.",
|
||||
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"noModelsInstalled": "Dường như bạn chưa tải model nào cả! Bạn có thể <DownloadStarterModelsButton>tải xuống các model khởi đầu</DownloadStarterModelsButton> hoặc <ImportModelsButton>nhập vào thêm model</ImportModelsButton>.",
|
||||
"lowVRAMMode": "Cho hiệu suất tốt nhất, hãy làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi."
|
||||
},
|
||||
@@ -2306,8 +2347,8 @@
|
||||
"title": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)"
|
||||
},
|
||||
"howDoIGenerateAndSaveToTheGallery": {
|
||||
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện?",
|
||||
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện."
|
||||
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện Ảnh?",
|
||||
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện ảnh."
|
||||
},
|
||||
"howDoIEditOnTheCanvas": {
|
||||
"description": "Hướng dẫn chỉnh sửa ảnh trực tiếp trên canvas.",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Box, useGlobalModifiersInit } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
|
||||
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { $didStudioInit, useStudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
|
||||
import { useLogger } from 'app/logging/useLogger';
|
||||
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
|
||||
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import type { PartialAppConfig } from 'app/types/invokeai';
|
||||
import Loading from 'common/components/Loading/Loading';
|
||||
import { useFocusRegionWatcher } from 'common/hooks/focus';
|
||||
import { useClearStorage } from 'common/hooks/useClearStorage';
|
||||
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
|
||||
@@ -27,6 +29,7 @@ import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterM
|
||||
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal';
|
||||
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
|
||||
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
|
||||
import { useReadinessWatcher } from 'features/queue/store/readiness';
|
||||
import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog';
|
||||
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
|
||||
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
|
||||
@@ -35,6 +38,7 @@ import { configChanged } from 'features/system/store/configSlice';
|
||||
import { selectLanguage } from 'features/system/store/systemSelectors';
|
||||
import { AppContent } from 'features/ui/components/AppContent';
|
||||
import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
|
||||
import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
|
||||
import i18n from 'i18n';
|
||||
import { size } from 'lodash-es';
|
||||
@@ -53,49 +57,22 @@ interface Props {
|
||||
}
|
||||
|
||||
const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
const language = useAppSelector(selectLanguage);
|
||||
const logger = useLogger('system');
|
||||
const dispatch = useAppDispatch();
|
||||
const didStudioInit = useStore($didStudioInit);
|
||||
const clearStorage = useClearStorage();
|
||||
|
||||
// singleton!
|
||||
useSocketIO();
|
||||
useGlobalModifiersInit();
|
||||
useGlobalHotkeys();
|
||||
useGetOpenAPISchemaQuery();
|
||||
useSyncLoggingConfig();
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
clearStorage();
|
||||
location.reload();
|
||||
return false;
|
||||
}, [clearStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (size(config)) {
|
||||
logger.info({ config }, 'Received config');
|
||||
dispatch(configChanged(config));
|
||||
}
|
||||
}, [dispatch, config, logger]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(appStarted());
|
||||
}, [dispatch]);
|
||||
|
||||
useStudioInitAction(studioInitAction);
|
||||
useStarterModelsToast();
|
||||
useSyncQueueStatus();
|
||||
useFocusRegionWatcher();
|
||||
|
||||
return (
|
||||
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
|
||||
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
|
||||
<AppContent />
|
||||
{!didStudioInit && <Loading />}
|
||||
</Box>
|
||||
<HookIsolator config={config} studioInitAction={studioInitAction} />
|
||||
<DeleteImageModal />
|
||||
<ChangeBoardModal />
|
||||
<DynamicPromptsModal />
|
||||
@@ -103,6 +80,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
|
||||
<ClearQueueConfirmationsAlertDialog />
|
||||
<NewWorkflowConfirmationAlertDialog />
|
||||
<LoadWorkflowConfirmationAlertDialog />
|
||||
<DeleteStylePresetDialog />
|
||||
<DeleteWorkflowDialog />
|
||||
<ShareWorkflowModal />
|
||||
@@ -122,3 +100,43 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
};
|
||||
|
||||
export default memo(App);
|
||||
|
||||
// Running these hooks in a separate component ensures we do not inadvertently rerender the entire app when they change.
|
||||
const HookIsolator = memo(
|
||||
({ config, studioInitAction }: { config: PartialAppConfig; studioInitAction?: StudioInitAction }) => {
|
||||
const language = useAppSelector(selectLanguage);
|
||||
const logger = useLogger('system');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// singleton!
|
||||
useReadinessWatcher();
|
||||
useSocketIO();
|
||||
useGlobalModifiersInit();
|
||||
useGlobalHotkeys();
|
||||
useGetOpenAPISchemaQuery();
|
||||
useSyncLoggingConfig();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (size(config)) {
|
||||
logger.info({ config }, 'Received config');
|
||||
dispatch(configChanged(config));
|
||||
}
|
||||
}, [dispatch, config, logger]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(appStarted());
|
||||
}, [dispatch]);
|
||||
|
||||
useStudioInitAction(studioInitAction);
|
||||
useStarterModelsToast();
|
||||
useSyncQueueStatus();
|
||||
useFocusRegionWatcher();
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
HookIsolator.displayName = 'HookIsolator';
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'i18n';
|
||||
|
||||
import type { Middleware } from '@reduxjs/toolkit';
|
||||
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
|
||||
import type { LoggingOverrides } from 'app/logging/logger';
|
||||
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
|
||||
import { $authToken } from 'app/store/nanostores/authToken';
|
||||
@@ -87,6 +88,12 @@ const InvokeAIUI = ({
|
||||
);
|
||||
}, [loggingOverrides]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (studioInitAction) {
|
||||
$didStudioInit.set(false);
|
||||
}
|
||||
}, [studioInitAction]);
|
||||
|
||||
useEffect(() => {
|
||||
// configure API client token
|
||||
if (token) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@fontsource-variable/inter';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import '@xyflow/react/dist/base.css';
|
||||
|
||||
import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -16,7 +16,8 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { atom } from 'nanostores';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images';
|
||||
import { getStylePreset } from 'services/api/endpoints/stylePresets';
|
||||
@@ -32,6 +33,9 @@ type StudioDestinationAction = _StudioInitAction<
|
||||
{ destination: 'generation' | 'canvas' | 'workflows' | 'upscaling' | 'viewAllWorkflows' | 'viewAllStylePresets' }
|
||||
>;
|
||||
|
||||
// Use global state to show loader until we are ready to render the studio.
|
||||
export const $didStudioInit = atom(false);
|
||||
|
||||
export type StudioInitAction =
|
||||
| LoadWorkflowAction
|
||||
| SelectStylePresetAction
|
||||
@@ -51,8 +55,6 @@ export type StudioInitAction =
|
||||
export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
useAssertSingleton('useStudioInitAction');
|
||||
const { t } = useTranslation();
|
||||
// Use a ref to ensure that we only perform the action once
|
||||
const didInit = useRef(false);
|
||||
const didParseOpenAPISchema = useStore($hasTemplates);
|
||||
const store = useAppStore();
|
||||
const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
|
||||
@@ -102,16 +104,16 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
}
|
||||
const metadata = getImageMetadataResult.value;
|
||||
// This shows a toast
|
||||
parseAndRecallAllMetadata(metadata, true);
|
||||
await parseAndRecallAllMetadata(metadata, true);
|
||||
store.dispatch(setActiveTab('canvas'));
|
||||
},
|
||||
[store, t]
|
||||
);
|
||||
|
||||
const handleLoadWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
async (workflowId: string) => {
|
||||
// This shows a toast
|
||||
getAndLoadWorkflow(workflowId);
|
||||
await getAndLoadWorkflow(workflowId);
|
||||
store.dispatch(setActiveTab('workflows'));
|
||||
},
|
||||
[getAndLoadWorkflow, store]
|
||||
@@ -176,36 +178,48 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
[store]
|
||||
);
|
||||
|
||||
const handleStudioInitAction = useCallback(
|
||||
async (action: StudioInitAction) => {
|
||||
// This cannot be in the useEffect below because we need to await some of the actions before setting didStudioInit.
|
||||
switch (action.type) {
|
||||
case 'loadWorkflow':
|
||||
await handleLoadWorkflow(action.data.workflowId);
|
||||
break;
|
||||
case 'selectStylePreset':
|
||||
await handleSelectStylePreset(action.data.stylePresetId);
|
||||
break;
|
||||
|
||||
case 'sendToCanvas':
|
||||
await handleSendToCanvas(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'useAllParameters':
|
||||
await handleUseAllMetadata(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'goToDestination':
|
||||
handleGoToDestination(action.data.destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
$didStudioInit.set(true);
|
||||
},
|
||||
[handleGoToDestination, handleLoadWorkflow, handleSelectStylePreset, handleSendToCanvas, handleUseAllMetadata]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInit.current || !action || !didParseOpenAPISchema) {
|
||||
if ($didStudioInit.get() || !didParseOpenAPISchema) {
|
||||
return;
|
||||
}
|
||||
|
||||
didInit.current = true;
|
||||
|
||||
switch (action.type) {
|
||||
case 'loadWorkflow':
|
||||
handleLoadWorkflow(action.data.workflowId);
|
||||
break;
|
||||
case 'selectStylePreset':
|
||||
handleSelectStylePreset(action.data.stylePresetId);
|
||||
break;
|
||||
|
||||
case 'sendToCanvas':
|
||||
handleSendToCanvas(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'useAllParameters':
|
||||
handleUseAllMetadata(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'goToDestination':
|
||||
handleGoToDestination(action.data.destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
if (!action) {
|
||||
$didStudioInit.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleStudioInitAction(action);
|
||||
}, [
|
||||
handleSendToCanvas,
|
||||
handleUseAllMetadata,
|
||||
@@ -214,5 +228,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
handleGoToDestination,
|
||||
handleLoadWorkflow,
|
||||
didParseOpenAPISchema,
|
||||
handleStudioInitAction,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createDraftSafeSelectorCreator, createSelectorCreator, lruMemoize } from '@reduxjs/toolkit';
|
||||
import type { GetSelectorsOptions } from '@reduxjs/toolkit/dist/entities/state_selectors';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
/**
|
||||
@@ -14,11 +12,9 @@ export const createMemoizedSelector = createSelectorCreator({
|
||||
argsMemoize: lruMemoize,
|
||||
});
|
||||
|
||||
export const getSelectorsOptions: GetSelectorsOptions = {
|
||||
export const getSelectorsOptions = {
|
||||
createSelector: createDraftSafeSelectorCreator({
|
||||
memoize: lruMemoize,
|
||||
argsMemoize: lruMemoize,
|
||||
}),
|
||||
};
|
||||
|
||||
export const createMemoizedAppSelector = createMemoizedSelector.withTypes<RootState>();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { enqueueRequested } from 'app/store/actions';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
|
||||
import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue';
|
||||
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
|
||||
import { resolveBatchValue } from 'features/queue/store/readiness';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
import type { Batch, BatchConfig } from 'services/api/types';
|
||||
@@ -15,12 +16,13 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
enqueueRequested.match(action) && action.payload.tabName === 'workflows',
|
||||
effect: async (action, { getState, dispatch }) => {
|
||||
const state = getState();
|
||||
const nodes = selectNodesSlice(state);
|
||||
const nodesState = selectNodesSlice(state);
|
||||
const workflow = state.workflow;
|
||||
const graph = buildNodesGraph(nodes);
|
||||
const templates = $templates.get();
|
||||
const graph = buildNodesGraph(state, templates);
|
||||
const builtWorkflow = buildWorkflowWithValidation({
|
||||
nodes: nodes.nodes,
|
||||
edges: nodes.edges,
|
||||
nodes: nodesState.nodes,
|
||||
edges: nodesState.edges,
|
||||
workflow,
|
||||
});
|
||||
|
||||
@@ -31,7 +33,7 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
|
||||
const data: Batch['data'] = [];
|
||||
|
||||
const invocationNodes = nodes.nodes.filter(isInvocationNode);
|
||||
const invocationNodes = nodesState.nodes.filter(isInvocationNode);
|
||||
const batchNodes = invocationNodes.filter(isBatchNode);
|
||||
|
||||
// Handle zipping batch nodes. First group the batch nodes by their batch_group_id
|
||||
@@ -42,9 +44,11 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
const zippedBatchDataCollectionItems: NonNullable<Batch['data']>[number] = [];
|
||||
|
||||
for (const node of batchNodes) {
|
||||
const value = resolveBatchValue(node, invocationNodes, nodes.edges);
|
||||
const value = await resolveBatchValue({ nodesState, node, dispatch });
|
||||
const sourceHandle = node.data.type === 'image_batch' ? 'image' : 'value';
|
||||
const edgesFromBatch = nodes.edges.filter((e) => e.source === node.id && e.sourceHandle === sourceHandle);
|
||||
const edgesFromBatch = nodesState.edges.filter(
|
||||
(e) => e.source === node.id && e.sourceHandle === sourceHandle
|
||||
);
|
||||
if (batchGroupId !== 'None') {
|
||||
// If this batch node has a batch_group_id, we will zip the data collection items
|
||||
for (const edge of edgesFromBatch) {
|
||||
|
||||
@@ -8,12 +8,13 @@ import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'
|
||||
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { forEach, intersectionBy } from 'lodash-es';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { Param0 } from 'tsafe';
|
||||
|
||||
const log = logger('gallery');
|
||||
|
||||
@@ -21,6 +22,7 @@ const log = logger('gallery');
|
||||
|
||||
// Some utils to delete images from different parts of the app
|
||||
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
const actions: Param0<typeof dispatch>[] = [];
|
||||
state.nodes.present.nodes.forEach((node) => {
|
||||
if (!isInvocationNode(node)) {
|
||||
return;
|
||||
@@ -28,16 +30,28 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
|
||||
|
||||
forEach(node.data.inputs, (input) => {
|
||||
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
|
||||
dispatch(
|
||||
actions.push(
|
||||
fieldImageValueChanged({
|
||||
nodeId: node.data.id,
|
||||
fieldName: input.name,
|
||||
value: undefined,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isImageFieldCollectionInputInstance(input)) {
|
||||
actions.push(
|
||||
fieldImageCollectionValueChanged({
|
||||
nodeId: node.data.id,
|
||||
fieldName: input.name,
|
||||
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
actions.forEach(dispatch);
|
||||
};
|
||||
|
||||
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState';
|
||||
import { $nodeExecutionStates } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import { workflowLoaded, workflowLoadRequested } from 'features/nodes/store/actions';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { $needsFit } from 'features/nodes/store/reactFlowInstance';
|
||||
@@ -22,12 +22,18 @@ const getWorkflow = async (data: GraphAndWorkflowResponse, templates: Templates)
|
||||
if (data.workflow) {
|
||||
// Prefer to load the workflow if it's available - it has more information
|
||||
const parsed = JSON.parse(data.workflow);
|
||||
return await validateWorkflow(parsed, templates, checkImageAccess, checkBoardAccess, checkModelAccess);
|
||||
return await validateWorkflow({
|
||||
workflow: parsed,
|
||||
templates,
|
||||
checkImageAccess,
|
||||
checkBoardAccess,
|
||||
checkModelAccess,
|
||||
});
|
||||
} else if (data.graph) {
|
||||
// Else we fall back on the graph, using the graphToWorkflow function to convert and do layout
|
||||
const parsed = JSON.parse(data.graph);
|
||||
const workflow = graphToWorkflow(parsed as NonNullableGraph, true);
|
||||
return await validateWorkflow(workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess);
|
||||
return await validateWorkflow({ workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess });
|
||||
} else {
|
||||
throw new Error('No workflow or graph provided');
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export type AppFeature =
|
||||
| 'modelCache'
|
||||
| 'bulkDownload'
|
||||
| 'starterModels'
|
||||
| 'hfToken';
|
||||
| 'hfToken'
|
||||
| 'retryQueueItem';
|
||||
/**
|
||||
* A disable-able Stable Diffusion feature
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { As, ChakraProps, FlexProps } from '@invoke-ai/ui-library';
|
||||
import type { ChakraProps, FlexProps } from '@invoke-ai/ui-library';
|
||||
import { Flex, Icon, Skeleton, Spinner, Text } from '@invoke-ai/ui-library';
|
||||
import type { ElementType } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { PiImageBold } from 'react-icons/pi';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -28,7 +29,7 @@ IAILoadingImageFallback.displayName = 'IAILoadingImageFallback';
|
||||
|
||||
type IAINoImageFallbackProps = FlexProps & {
|
||||
label?: string;
|
||||
icon?: As | null;
|
||||
icon?: ElementType | null;
|
||||
boxSize?: ChakraProps['boxSize'];
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,18 @@ import { memo } from 'react';
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<Flex position="relative" width="100dvw" height="100dvh" alignItems="center" justifyContent="center" bg="#151519">
|
||||
<Flex
|
||||
position="absolute"
|
||||
width="100dvw"
|
||||
height="100dvh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="#151519"
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
>
|
||||
<Image src={InvokeLogoWhite} w="8rem" h="8rem" />
|
||||
<Spinner
|
||||
label="Loading"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Box } from '@invoke-ai/ui-library';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
type Props = {
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
};
|
||||
const SelectionOverlay = ({ isSelected, isHovered }: Props) => {
|
||||
const shadow = useMemo(() => {
|
||||
if (isSelected && isHovered) {
|
||||
return 'nodeHoveredSelected';
|
||||
}
|
||||
if (isSelected) {
|
||||
return 'nodeSelected';
|
||||
}
|
||||
if (isHovered) {
|
||||
return 'nodeHovered';
|
||||
}
|
||||
return undefined;
|
||||
}, [isHovered, isSelected]);
|
||||
return (
|
||||
<Box
|
||||
className="selection-box"
|
||||
position="absolute"
|
||||
top={0}
|
||||
insetInlineEnd={0}
|
||||
bottom={0}
|
||||
insetInlineStart={0}
|
||||
borderRadius="base"
|
||||
opacity={isSelected || isHovered ? 1 : 0.5}
|
||||
transitionProperty="common"
|
||||
transitionDuration="0.1s"
|
||||
pointerEvents="none"
|
||||
shadow={shadow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SelectionOverlay);
|
||||
@@ -1,238 +0,0 @@
|
||||
import { useToken } from '@invoke-ai/ui-library';
|
||||
|
||||
export const useChakraThemeTokens = () => {
|
||||
const [
|
||||
base50,
|
||||
base100,
|
||||
base150,
|
||||
base200,
|
||||
base250,
|
||||
base300,
|
||||
base350,
|
||||
base400,
|
||||
base450,
|
||||
base500,
|
||||
base550,
|
||||
base600,
|
||||
base650,
|
||||
base700,
|
||||
base750,
|
||||
base800,
|
||||
base850,
|
||||
base900,
|
||||
base950,
|
||||
accent50,
|
||||
accent100,
|
||||
accent150,
|
||||
accent200,
|
||||
accent250,
|
||||
accent300,
|
||||
accent350,
|
||||
accent400,
|
||||
accent450,
|
||||
accent500,
|
||||
accent550,
|
||||
accent600,
|
||||
accent650,
|
||||
accent700,
|
||||
accent750,
|
||||
accent800,
|
||||
accent850,
|
||||
accent900,
|
||||
accent950,
|
||||
baseAlpha50,
|
||||
baseAlpha100,
|
||||
baseAlpha150,
|
||||
baseAlpha200,
|
||||
baseAlpha250,
|
||||
baseAlpha300,
|
||||
baseAlpha350,
|
||||
baseAlpha400,
|
||||
baseAlpha450,
|
||||
baseAlpha500,
|
||||
baseAlpha550,
|
||||
baseAlpha600,
|
||||
baseAlpha650,
|
||||
baseAlpha700,
|
||||
baseAlpha750,
|
||||
baseAlpha800,
|
||||
baseAlpha850,
|
||||
baseAlpha900,
|
||||
baseAlpha950,
|
||||
accentAlpha50,
|
||||
accentAlpha100,
|
||||
accentAlpha150,
|
||||
accentAlpha200,
|
||||
accentAlpha250,
|
||||
accentAlpha300,
|
||||
accentAlpha350,
|
||||
accentAlpha400,
|
||||
accentAlpha450,
|
||||
accentAlpha500,
|
||||
accentAlpha550,
|
||||
accentAlpha600,
|
||||
accentAlpha650,
|
||||
accentAlpha700,
|
||||
accentAlpha750,
|
||||
accentAlpha800,
|
||||
accentAlpha850,
|
||||
accentAlpha900,
|
||||
accentAlpha950,
|
||||
] = useToken('colors', [
|
||||
'base.50',
|
||||
'base.100',
|
||||
'base.150',
|
||||
'base.200',
|
||||
'base.250',
|
||||
'base.300',
|
||||
'base.350',
|
||||
'base.400',
|
||||
'base.450',
|
||||
'base.500',
|
||||
'base.550',
|
||||
'base.600',
|
||||
'base.650',
|
||||
'base.700',
|
||||
'base.750',
|
||||
'base.800',
|
||||
'base.850',
|
||||
'base.900',
|
||||
'base.950',
|
||||
'accent.50',
|
||||
'accent.100',
|
||||
'accent.150',
|
||||
'accent.200',
|
||||
'accent.250',
|
||||
'accent.300',
|
||||
'accent.350',
|
||||
'accent.400',
|
||||
'accent.450',
|
||||
'accent.500',
|
||||
'accent.550',
|
||||
'accent.600',
|
||||
'accent.650',
|
||||
'accent.700',
|
||||
'accent.750',
|
||||
'accent.800',
|
||||
'accent.850',
|
||||
'accent.900',
|
||||
'accent.950',
|
||||
'baseAlpha.50',
|
||||
'baseAlpha.100',
|
||||
'baseAlpha.150',
|
||||
'baseAlpha.200',
|
||||
'baseAlpha.250',
|
||||
'baseAlpha.300',
|
||||
'baseAlpha.350',
|
||||
'baseAlpha.400',
|
||||
'baseAlpha.450',
|
||||
'baseAlpha.500',
|
||||
'baseAlpha.550',
|
||||
'baseAlpha.600',
|
||||
'baseAlpha.650',
|
||||
'baseAlpha.700',
|
||||
'baseAlpha.750',
|
||||
'baseAlpha.800',
|
||||
'baseAlpha.850',
|
||||
'baseAlpha.900',
|
||||
'baseAlpha.950',
|
||||
'accentAlpha.50',
|
||||
'accentAlpha.100',
|
||||
'accentAlpha.150',
|
||||
'accentAlpha.200',
|
||||
'accentAlpha.250',
|
||||
'accentAlpha.300',
|
||||
'accentAlpha.350',
|
||||
'accentAlpha.400',
|
||||
'accentAlpha.450',
|
||||
'accentAlpha.500',
|
||||
'accentAlpha.550',
|
||||
'accentAlpha.600',
|
||||
'accentAlpha.650',
|
||||
'accentAlpha.700',
|
||||
'accentAlpha.750',
|
||||
'accentAlpha.800',
|
||||
'accentAlpha.850',
|
||||
'accentAlpha.900',
|
||||
'accentAlpha.950',
|
||||
]);
|
||||
|
||||
return {
|
||||
base50,
|
||||
base100,
|
||||
base150,
|
||||
base200,
|
||||
base250,
|
||||
base300,
|
||||
base350,
|
||||
base400,
|
||||
base450,
|
||||
base500,
|
||||
base550,
|
||||
base600,
|
||||
base650,
|
||||
base700,
|
||||
base750,
|
||||
base800,
|
||||
base850,
|
||||
base900,
|
||||
base950,
|
||||
accent50,
|
||||
accent100,
|
||||
accent150,
|
||||
accent200,
|
||||
accent250,
|
||||
accent300,
|
||||
accent350,
|
||||
accent400,
|
||||
accent450,
|
||||
accent500,
|
||||
accent550,
|
||||
accent600,
|
||||
accent650,
|
||||
accent700,
|
||||
accent750,
|
||||
accent800,
|
||||
accent850,
|
||||
accent900,
|
||||
accent950,
|
||||
baseAlpha50,
|
||||
baseAlpha100,
|
||||
baseAlpha150,
|
||||
baseAlpha200,
|
||||
baseAlpha250,
|
||||
baseAlpha300,
|
||||
baseAlpha350,
|
||||
baseAlpha400,
|
||||
baseAlpha450,
|
||||
baseAlpha500,
|
||||
baseAlpha550,
|
||||
baseAlpha600,
|
||||
baseAlpha650,
|
||||
baseAlpha700,
|
||||
baseAlpha750,
|
||||
baseAlpha800,
|
||||
baseAlpha850,
|
||||
baseAlpha900,
|
||||
baseAlpha950,
|
||||
accentAlpha50,
|
||||
accentAlpha100,
|
||||
accentAlpha150,
|
||||
accentAlpha200,
|
||||
accentAlpha250,
|
||||
accentAlpha300,
|
||||
accentAlpha350,
|
||||
accentAlpha400,
|
||||
accentAlpha450,
|
||||
accentAlpha500,
|
||||
accentAlpha550,
|
||||
accentAlpha600,
|
||||
accentAlpha650,
|
||||
accentAlpha700,
|
||||
accentAlpha750,
|
||||
accentAlpha800,
|
||||
accentAlpha850,
|
||||
accentAlpha900,
|
||||
accentAlpha950,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useClipboard } from 'common/hooks/useClipboard';
|
||||
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
|
||||
import { imageCopiedToClipboard } from 'features/gallery/store/actions';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
export const useCopyImageToClipboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const copyImageToClipboard = useCallback(
|
||||
async (image_url: string) => {
|
||||
@@ -23,6 +26,7 @@ export const useCopyImageToClipboard = () => {
|
||||
title: t('toast.imageCopied'),
|
||||
status: 'success',
|
||||
});
|
||||
dispatch(imageCopiedToClipboard());
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
@@ -33,7 +37,7 @@ export const useCopyImageToClipboard = () => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[clipboard, t]
|
||||
[clipboard, t, dispatch]
|
||||
);
|
||||
|
||||
return copyImageToClipboard;
|
||||
|
||||
72
invokeai/frontend/web/src/common/hooks/useEditable.ts
Normal file
72
invokeai/frontend/web/src/common/hooks/useEditable.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type UseEditableArg = {
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
onChange: (value: string) => void;
|
||||
onStartEditing?: () => void;
|
||||
inputRef?: RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||
};
|
||||
|
||||
export const useEditable = ({ value, defaultValue, onChange: _onChange, onStartEditing, inputRef }: UseEditableArg) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
const trimmedValue = localValue.trim();
|
||||
const newValue = trimmedValue || defaultValue;
|
||||
setLocalValue(newValue);
|
||||
if (newValue !== value) {
|
||||
_onChange(newValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
inputRef?.current?.setSelectionRange(0, 0);
|
||||
}, [localValue, defaultValue, value, inputRef, _onChange]);
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
onBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(value);
|
||||
_onChange(value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[_onChange, onBlur, value]
|
||||
);
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
onStartEditing?.();
|
||||
}, [onStartEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
// Another component may change the title; sync local title with global state
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef?.current?.focus();
|
||||
inputRef?.current?.select();
|
||||
}
|
||||
}, [inputRef, isEditing]);
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
startEditing,
|
||||
value: localValue,
|
||||
inputProps: {
|
||||
value: localValue,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onBlur,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flex, IconButton } from '@invoke-ai/ui-library';
|
||||
import { createMemoizedAppSelector } from 'app/store/createMemoizedSelector';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
} from 'services/api/types';
|
||||
|
||||
const buildSelectControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) =>
|
||||
createMemoizedAppSelector(selectCanvasSlice, (canvas) => {
|
||||
createSelector(selectCanvasSlice, (canvas) => {
|
||||
const layer = selectEntityOrThrow(canvas, entityIdentifier, 'ControlLayerControlAdapter');
|
||||
return layer.controlAdapter;
|
||||
});
|
||||
|
||||
@@ -1,67 +1,43 @@
|
||||
import { Input } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useBoolean } from 'common/hooks/useBoolean';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { useEntityTitle } from 'features/controlLayers/hooks/useEntityTitle';
|
||||
import { useEntityName, useEntityTypeName } from 'features/controlLayers/hooks/useEntityTitle';
|
||||
import { entityNameChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
|
||||
export const CanvasEntityEditableTitle = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
const title = useEntityTitle(entityIdentifier);
|
||||
const isEditing = useBoolean(false);
|
||||
const [localTitle, setLocalTitle] = useState(title);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const name = useEntityName(entityIdentifier);
|
||||
const typeName = useEntityTypeName(entityIdentifier.type);
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalTitle(e.target.value);
|
||||
}, []);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
const trimmedTitle = localTitle.trim();
|
||||
if (trimmedTitle.length === 0) {
|
||||
dispatch(entityNameChanged({ entityIdentifier, name: null }));
|
||||
} else if (trimmedTitle !== title) {
|
||||
dispatch(entityNameChanged({ entityIdentifier, name: trimmedTitle }));
|
||||
}
|
||||
isEditing.setFalse();
|
||||
}, [dispatch, entityIdentifier, isEditing, localTitle, title]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalTitle(title);
|
||||
isEditing.setFalse();
|
||||
}
|
||||
const onChange = useCallback(
|
||||
(name: string) => {
|
||||
dispatch(entityNameChanged({ entityIdentifier, name }));
|
||||
},
|
||||
[isEditing, onBlur, title]
|
||||
[dispatch, entityIdentifier]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing.isTrue) {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}
|
||||
}, [isEditing.isTrue]);
|
||||
const editable = useEditable({
|
||||
value: name || typeName,
|
||||
defaultValue: typeName,
|
||||
onChange,
|
||||
inputRef,
|
||||
});
|
||||
|
||||
if (!isEditing.isTrue) {
|
||||
return <CanvasEntityTitle cursor="text" onDoubleClick={isEditing.setTrue} />;
|
||||
if (!editable.isEditing) {
|
||||
return <CanvasEntityTitle cursor="text" onDoubleClick={editable.startEditing} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
value={localTitle}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={inputRef}
|
||||
{...editable.inputProps}
|
||||
variant="outline"
|
||||
_focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }}
|
||||
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,17 +15,17 @@ const createSelectName = (entityIdentifier: CanvasEntityIdentifier) =>
|
||||
return entity.name;
|
||||
});
|
||||
|
||||
export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const { t } = useTranslation();
|
||||
export const useEntityName = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const selectName = useMemo(() => createSelectName(entityIdentifier), [entityIdentifier]);
|
||||
const name = useAppSelector(selectName);
|
||||
return name;
|
||||
};
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
export const useEntityTypeName = (type: CanvasEntityIdentifier['type']) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (entityIdentifier.type) {
|
||||
const typeName = useMemo(() => {
|
||||
switch (type) {
|
||||
case 'inpaint_mask':
|
||||
return t('controlLayers.inpaintMask');
|
||||
case 'control_layer':
|
||||
@@ -39,7 +39,15 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
default:
|
||||
assert(false, 'Unexpected entity type');
|
||||
}
|
||||
}, [entityIdentifier.type, name, t]);
|
||||
}, [type, t]);
|
||||
|
||||
return typeName;
|
||||
};
|
||||
|
||||
export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const name = useEntityName(entityIdentifier);
|
||||
const typeName = useEntityTypeName(entityIdentifier.type);
|
||||
const title = useMemo(() => name || typeName, [name, typeName]);
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
@@ -59,11 +59,11 @@ export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase<
|
||||
this.syncOpacity();
|
||||
}
|
||||
if (!prevState || this.state.fill !== prevState.fill) {
|
||||
// On first render, we must force the update
|
||||
this.renderer.updateCompositingRectFill(!prevState);
|
||||
// On first render, or when the fill changes, we must force the update
|
||||
this.renderer.updateCompositingRectFill(true);
|
||||
}
|
||||
if (!prevState) {
|
||||
// On first render, we must force the updates
|
||||
if (!prevState || this.state.objects !== prevState.objects) {
|
||||
// On first render, or when the objects change, we must force the update
|
||||
this.renderer.updateCompositingRectSize(true);
|
||||
this.renderer.updateCompositingRectPosition(true);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase
|
||||
this.syncOpacity();
|
||||
}
|
||||
if (!prevState || this.state.fill !== prevState.fill) {
|
||||
// On first render, we must force the update
|
||||
this.renderer.updateCompositingRectFill(!prevState);
|
||||
// On first render, or when the fill changes, we must force the update
|
||||
this.renderer.updateCompositingRectFill(true);
|
||||
}
|
||||
if (!prevState) {
|
||||
// On first render, we must force the updates
|
||||
if (!prevState || this.state.objects !== prevState.objects) {
|
||||
// On first render, or when the objects change, we must force the update
|
||||
this.renderer.updateCompositingRectSize(true);
|
||||
this.renderer.updateCompositingRectPosition(true);
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@ export class CanvasEntityFilterer extends CanvasModuleBase {
|
||||
this.log.error({ error: serializeError(filterResult.error) }, 'Error filtering');
|
||||
this.$isProcessing.set(false);
|
||||
// Clean up the abort controller as needed
|
||||
if (!this.abortController.signal.aborted) {
|
||||
this.abortController.abort();
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
this.abortController = null;
|
||||
return;
|
||||
@@ -324,8 +324,8 @@ export class CanvasEntityFilterer extends CanvasModuleBase {
|
||||
this.$isProcessing.set(false);
|
||||
|
||||
// Clean up the abort controller as needed
|
||||
if (!this.abortController.signal.aborted) {
|
||||
this.abortController.abort();
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
this.abortController = null;
|
||||
|
||||
@@ -277,8 +277,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
|
||||
|
||||
let points: number[];
|
||||
|
||||
let isShiftDraw = false;
|
||||
|
||||
if (e.evt.shiftKey && lastLinePoint) {
|
||||
// Create a straight line from the last line point
|
||||
isShiftDraw = true;
|
||||
points = [
|
||||
lastLinePoint.x,
|
||||
lastLinePoint.y,
|
||||
@@ -298,15 +301,18 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
|
||||
points,
|
||||
strokeWidth: settings.brushWidth,
|
||||
color: this.manager.stateApi.getCurrentColor(),
|
||||
clip: this.parent.getClip(selectedEntity.state),
|
||||
// When shift is held, the line may extend beyond the clip region. No clip for these lines.
|
||||
clip: isShiftDraw ? null : this.parent.getClip(selectedEntity.state),
|
||||
});
|
||||
} else {
|
||||
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
|
||||
|
||||
let points: number[];
|
||||
let isShiftDraw = false;
|
||||
|
||||
if (e.evt.shiftKey && lastLinePoint) {
|
||||
// Create a straight line from the last line point
|
||||
isShiftDraw = true;
|
||||
points = [lastLinePoint.x, lastLinePoint.y, alignedPoint.x, alignedPoint.y];
|
||||
} else {
|
||||
// Create a new line with the current point
|
||||
@@ -319,7 +325,8 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
|
||||
points,
|
||||
strokeWidth: settings.brushWidth,
|
||||
color: this.manager.stateApi.getCurrentColor(),
|
||||
clip: this.parent.getClip(selectedEntity.state),
|
||||
// When shift is held, the line may extend beyond the clip region. No clip for these lines.
|
||||
clip: isShiftDraw ? null : this.parent.getClip(selectedEntity.state),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
|
||||
import { getColorAtCoordinate, getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { RgbColor } from 'features/controlLayers/store/types';
|
||||
import type { RgbaColor } from 'features/controlLayers/store/types';
|
||||
import { RGBA_BLACK } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
@@ -52,6 +52,39 @@ type CanvasColorPickerToolModuleConfig = {
|
||||
* The color of the crosshair line borders.
|
||||
*/
|
||||
CROSSHAIR_BORDER_COLOR: string;
|
||||
/**
|
||||
* The color of the RGBA value text.
|
||||
*/
|
||||
TEXT_COLOR: string;
|
||||
/**
|
||||
* The padding of the RGBA value text within the background rect.
|
||||
*/
|
||||
|
||||
TEXT_PADDING: number;
|
||||
/**
|
||||
* The font size of the RGBA value text.
|
||||
*/
|
||||
TEXT_FONT_SIZE: number;
|
||||
/**
|
||||
* The color of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_COLOR: string;
|
||||
/**
|
||||
* The width of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_WIDTH: number;
|
||||
/**
|
||||
* The height of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_HEIGHT: number;
|
||||
/**
|
||||
* The corner radius of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_CORNER_RADIUS: number;
|
||||
/**
|
||||
* The x offset of the RGBA value text background rect from the color picker ring.
|
||||
*/
|
||||
TEXT_BG_X_OFFSET: number;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: CanvasColorPickerToolModuleConfig = {
|
||||
@@ -65,6 +98,14 @@ const DEFAULT_CONFIG: CanvasColorPickerToolModuleConfig = {
|
||||
CROSSHAIR_LINE_LENGTH: 10,
|
||||
CROSSHAIR_LINE_COLOR: 'rgba(0,0,0,1)',
|
||||
CROSSHAIR_BORDER_COLOR: 'rgba(255,255,255,0.8)',
|
||||
TEXT_COLOR: 'rgba(255,255,255,1)',
|
||||
TEXT_BG_COLOR: 'rgba(0,0,0,0.8)',
|
||||
TEXT_BG_HEIGHT: 62,
|
||||
TEXT_BG_WIDTH: 62,
|
||||
TEXT_BG_CORNER_RADIUS: 7,
|
||||
TEXT_PADDING: 8,
|
||||
TEXT_FONT_SIZE: 12,
|
||||
TEXT_BG_X_OFFSET: 7,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -83,7 +124,7 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
/**
|
||||
* The color currently under the cursor. Only has a value when the color picker tool is active.
|
||||
*/
|
||||
$colorUnderCursor = atom<RgbColor>(RGBA_BLACK);
|
||||
$colorUnderCursor = atom<RgbaColor>(RGBA_BLACK);
|
||||
|
||||
/**
|
||||
* The Konva objects that make up the color picker tool preview:
|
||||
@@ -105,6 +146,9 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
crosshairSouthOuter: Konva.Line;
|
||||
crosshairWestInner: Konva.Line;
|
||||
crosshairWestOuter: Konva.Line;
|
||||
rgbaTextGroup: Konva.Group;
|
||||
rgbaText: Konva.Text;
|
||||
rgbaTextBackground: Konva.Rect;
|
||||
};
|
||||
|
||||
constructor(parent: CanvasToolModule) {
|
||||
@@ -202,8 +246,28 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
stroke: this.config.CROSSHAIR_BORDER_COLOR,
|
||||
perfectDrawEnabled: false,
|
||||
}),
|
||||
rgbaTextGroup: new Konva.Group({
|
||||
listening: false,
|
||||
name: `${this.type}:color_picker_text_group`,
|
||||
}),
|
||||
rgbaText: new Konva.Text({
|
||||
listening: false,
|
||||
name: `${this.type}:color_picker_text`,
|
||||
fill: this.config.TEXT_COLOR,
|
||||
fontFamily: 'monospace',
|
||||
align: 'left',
|
||||
fontStyle: 'bold',
|
||||
verticalAlign: 'middle',
|
||||
}),
|
||||
rgbaTextBackground: new Konva.Rect({
|
||||
listening: false,
|
||||
name: `${this.type}:color_picker_text_background`,
|
||||
fill: this.config.TEXT_BG_COLOR,
|
||||
}),
|
||||
};
|
||||
|
||||
this.konva.rgbaTextGroup.add(this.konva.rgbaTextBackground, this.konva.rgbaText);
|
||||
|
||||
this.konva.group.add(
|
||||
this.konva.ringCandidateColor,
|
||||
this.konva.ringCurrentColor,
|
||||
@@ -216,7 +280,8 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
this.konva.crosshairSouthOuter,
|
||||
this.konva.crosshairSouthInner,
|
||||
this.konva.crosshairWestOuter,
|
||||
this.konva.crosshairWestInner
|
||||
this.konva.crosshairWestInner,
|
||||
this.konva.rgbaTextGroup
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,11 +298,6 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.parent.getCanDraw()) {
|
||||
this.setVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPos = this.parent.$cursorPos.get();
|
||||
|
||||
if (!cursorPos) {
|
||||
@@ -283,6 +343,24 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
outerRadius: colorPickerOuterRadius + twoPixels,
|
||||
});
|
||||
|
||||
const textBgWidth = this.manager.stage.unscale(this.config.TEXT_BG_WIDTH);
|
||||
const textBgHeight = this.manager.stage.unscale(this.config.TEXT_BG_HEIGHT);
|
||||
|
||||
this.konva.rgbaTextBackground.setAttrs({
|
||||
width: textBgWidth,
|
||||
height: textBgHeight,
|
||||
cornerRadius: this.manager.stage.unscale(this.config.TEXT_BG_CORNER_RADIUS),
|
||||
});
|
||||
this.konva.rgbaText.setAttrs({
|
||||
padding: this.manager.stage.unscale(this.config.TEXT_PADDING),
|
||||
fontSize: this.manager.stage.unscale(this.config.TEXT_FONT_SIZE),
|
||||
text: `R: ${colorUnderCursor.r}\nG: ${colorUnderCursor.g}\nB: ${colorUnderCursor.b}\nA: ${colorUnderCursor.a}`,
|
||||
});
|
||||
this.konva.rgbaTextGroup.setAttrs({
|
||||
x: x + this.manager.stage.unscale(this.config.RING_OUTER_RADIUS + this.config.TEXT_BG_X_OFFSET),
|
||||
y: y - textBgHeight / 2,
|
||||
});
|
||||
|
||||
const size = this.manager.stage.unscale(this.config.CROSSHAIR_LINE_LENGTH);
|
||||
const space = this.manager.stage.unscale(this.config.CROSSHAIR_INNER_RADIUS);
|
||||
const innerThickness = this.manager.stage.unscale(this.config.CROSSHAIR_LINE_THICKNESS);
|
||||
@@ -329,11 +407,8 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
|
||||
onStagePointerUp = (_e: KonvaEventObject<PointerEvent>) => {
|
||||
const color = this.$colorUnderCursor.get();
|
||||
if (color) {
|
||||
const settings = this.manager.stateApi.getSettings();
|
||||
// This will update the color but not the alpha value
|
||||
this.manager.stateApi.setColor({ ...settings.color, ...color });
|
||||
}
|
||||
const settings = this.manager.stateApi.getSettings();
|
||||
this.manager.stateApi.setColor({ ...settings.color, ...color });
|
||||
};
|
||||
|
||||
onStagePointerMove = (_e: KonvaEventObject<PointerEvent>) => {
|
||||
@@ -346,7 +421,11 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the background layer so we can get the color under the cursor without the grid interfering
|
||||
this.manager.background.konva.layer.visible(false);
|
||||
const color = getColorAtCoordinate(this.manager.stage.konva.stage, cursorPos.absolute);
|
||||
this.manager.background.konva.layer.visible(true);
|
||||
|
||||
if (color) {
|
||||
this.$colorUnderCursor.set(color);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
Coordinate,
|
||||
Tool,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import { isRenderableEntityType } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { atom } from 'nanostores';
|
||||
@@ -177,24 +178,26 @@ export class CanvasToolModule extends CanvasModuleBase {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (tool === 'bbox') {
|
||||
this.tools.bbox.syncCursorStyle();
|
||||
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter?.$isDisabled.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter?.$isEntityTypeHidden.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter?.$isLocked.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (tool === 'brush') {
|
||||
this.tools.brush.syncCursorStyle();
|
||||
} else if (tool === 'eraser') {
|
||||
this.tools.eraser.syncCursorStyle();
|
||||
} else if (tool === 'colorPicker') {
|
||||
this.tools.colorPicker.syncCursorStyle();
|
||||
} else if (tool === 'move') {
|
||||
this.tools.move.syncCursorStyle();
|
||||
} else if (tool === 'rect') {
|
||||
this.tools.rect.syncCursorStyle();
|
||||
} else if (selectedEntityAdapter && isRenderableEntityType(selectedEntityAdapter.entityIdentifier.type)) {
|
||||
if (selectedEntityAdapter.$isDisabled.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter.$isEntityTypeHidden.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter.$isLocked.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (tool === 'brush') {
|
||||
this.tools.brush.syncCursorStyle();
|
||||
} else if (tool === 'eraser') {
|
||||
this.tools.eraser.syncCursorStyle();
|
||||
} else if (tool === 'move') {
|
||||
this.tools.move.syncCursorStyle();
|
||||
} else if (tool === 'rect') {
|
||||
this.tools.rect.syncCursorStyle();
|
||||
}
|
||||
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else {
|
||||
stage.setCursor('not-allowed');
|
||||
}
|
||||
@@ -387,15 +390,17 @@ export class CanvasToolModule extends CanvasModuleBase {
|
||||
try {
|
||||
this.$lastPointerType.set(e.evt.pointerType);
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = this.$tool.get();
|
||||
|
||||
if (tool === 'colorPicker') {
|
||||
this.tools.colorPicker.onStagePointerUp(e);
|
||||
} else if (tool === 'brush') {
|
||||
}
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'brush') {
|
||||
this.tools.brush.onStagePointerUp(e);
|
||||
} else if (tool === 'eraser') {
|
||||
this.tools.eraser.onStagePointerUp(e);
|
||||
@@ -416,15 +421,17 @@ export class CanvasToolModule extends CanvasModuleBase {
|
||||
this.$lastPointerType.set(e.evt.pointerType);
|
||||
this.syncCursorPositions();
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = this.$tool.get();
|
||||
|
||||
if (tool === 'colorPicker') {
|
||||
this.tools.colorPicker.onStagePointerMove(e);
|
||||
} else if (tool === 'brush') {
|
||||
}
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'brush') {
|
||||
await this.tools.brush.onStagePointerMove(e);
|
||||
} else if (tool === 'eraser') {
|
||||
await this.tools.eraser.onStagePointerMove(e);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Coordinate,
|
||||
CoordinateWithPressure,
|
||||
Rect,
|
||||
RgbaColor,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import type Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
@@ -15,7 +16,6 @@ import { clamp } from 'lodash-es';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import type { StrokeOptions } from 'perfect-freehand';
|
||||
import getStroke from 'perfect-freehand';
|
||||
import type { RgbColor } from 'react-colorful';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
/**
|
||||
@@ -484,9 +484,10 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
export const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
|
||||
|
||||
export function getPrefixedId(
|
||||
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>)
|
||||
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>),
|
||||
separator = ':'
|
||||
): string {
|
||||
return `${prefix}:${nanoid()}`;
|
||||
return `${prefix}${separator}${nanoid()}`;
|
||||
}
|
||||
|
||||
export const getEmptyRect = (): Rect => {
|
||||
@@ -723,7 +724,7 @@ export const getPointerType = (e: KonvaEventObject<PointerEvent>): 'mouse' | 'pe
|
||||
* @param coord The coordinate to get the color at. This must be the _absolute_ coordinate on the stage.
|
||||
* @returns The color under the coordinate, or null if there was a problem getting the color.
|
||||
*/
|
||||
export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): RgbColor | null => {
|
||||
export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): RgbaColor | null => {
|
||||
const ctx = stage
|
||||
.toCanvas({ x: coord.x, y: coord.y, width: 1, height: 1, imageSmoothingEnabled: false })
|
||||
.getContext('2d');
|
||||
@@ -732,13 +733,13 @@ export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): Rgb
|
||||
return null;
|
||||
}
|
||||
|
||||
const [r, g, b, _a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
if (r === undefined || g === undefined || b === undefined) {
|
||||
if (r === undefined || g === undefined || b === undefined || a === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
return { r, g, b, a };
|
||||
};
|
||||
|
||||
export const roundRect = (rect: Rect): Rect => {
|
||||
|
||||
@@ -9,7 +9,8 @@ import type { DndListTargetState } from 'features/dnd/types';
|
||||
*/
|
||||
const line = {
|
||||
thickness: 2,
|
||||
backgroundColor: 'base.500',
|
||||
backgroundColor: 'red',
|
||||
// backgroundColor: 'base.500',
|
||||
};
|
||||
|
||||
type DropIndicatorProps = {
|
||||
@@ -104,7 +105,7 @@ function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetState }) => {
|
||||
export const DndListDropIndicator = ({ dndState, gap }: { dndState: DndListTargetState; gap?: string }) => {
|
||||
if (dndState.type !== 'is-dragging-over') {
|
||||
return null;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetStat
|
||||
<DndDropIndicatorInternal
|
||||
edge={dndState.closestEdge}
|
||||
// This is the gap between items in the list, used to calculate the position of the drop indicator
|
||||
gap="var(--invoke-space-2)"
|
||||
gap={gap || 'var(--invoke-space-2)'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
setUpscaleInitialImage,
|
||||
} from 'features/imageActions/actions';
|
||||
import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
@@ -108,18 +108,6 @@ export const singleCanvasEntityDndSource: DndSource<SingleCanvasEntityDndSourceD
|
||||
getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type),
|
||||
};
|
||||
|
||||
const _singleWorkflowField = buildTypeAndKey('single-workflow-field');
|
||||
type SingleWorkflowFieldDndSourceData = DndData<
|
||||
typeof _singleWorkflowField.type,
|
||||
typeof _singleWorkflowField.key,
|
||||
{ fieldIdentifier: FieldIdentifier }
|
||||
>;
|
||||
export const singleWorkflowFieldDndSource: DndSource<SingleWorkflowFieldDndSourceData> = {
|
||||
..._singleWorkflowField,
|
||||
typeGuard: buildTypeGuard(_singleWorkflowField.key),
|
||||
getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type),
|
||||
};
|
||||
|
||||
type DndTarget<TargetData extends DndData, SourceData extends DndData> = {
|
||||
key: symbol;
|
||||
type: TargetData['type'];
|
||||
@@ -273,7 +261,7 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget<
|
||||
|
||||
const { fieldIdentifier } = targetData.payload;
|
||||
|
||||
const fieldInputInstance = selectFieldInputInstance(
|
||||
const fieldInputInstance = selectFieldInputInstanceSafe(
|
||||
selectNodesSlice(getState()),
|
||||
fieldIdentifier.nodeId,
|
||||
fieldIdentifier.fieldName
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Flex, IconButton, Input, Text } from '@invoke-ai/ui-library';
|
||||
import { useBoolean } from 'common/hooks/useBoolean';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { withResultAsync } from 'common/util/result';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPencilBold } from 'react-icons/pi';
|
||||
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
|
||||
@@ -16,85 +16,54 @@ type Props = {
|
||||
|
||||
export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = useBoolean(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [localTitle, setLocalTitle] = useState(board.board_name);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isHovering = useBoolean(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [updateBoard, updateBoardResult] = useUpdateBoardMutation();
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalTitle(e.target.value);
|
||||
}, []);
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
isEditing.setTrue();
|
||||
setIsHovering(false);
|
||||
}, [isEditing]);
|
||||
|
||||
const onBlur = useCallback(async () => {
|
||||
const trimmedTitle = localTitle.trim();
|
||||
isEditing.setFalse();
|
||||
if (trimmedTitle.length === 0) {
|
||||
setLocalTitle(board.board_name);
|
||||
} else if (trimmedTitle !== board.board_name) {
|
||||
setLocalTitle(trimmedTitle);
|
||||
const onChange = useCallback(
|
||||
async (board_name: string) => {
|
||||
const result = await withResultAsync(() =>
|
||||
updateBoard({ board_id: board.board_id, changes: { board_name: trimmedTitle } }).unwrap()
|
||||
updateBoard({ board_id: board.board_id, changes: { board_name } }).unwrap()
|
||||
);
|
||||
if (result.isErr()) {
|
||||
setLocalTitle(board.board_name);
|
||||
toast({
|
||||
status: 'error',
|
||||
title: t('boards.updateBoardError'),
|
||||
});
|
||||
} else {
|
||||
setLocalTitle(result.value.board_name);
|
||||
}
|
||||
}
|
||||
}, [board.board_id, board.board_name, isEditing, localTitle, updateBoard, t]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalTitle(board.board_name);
|
||||
isEditing.setFalse();
|
||||
}
|
||||
},
|
||||
[board.board_name, isEditing, onBlur]
|
||||
[board.board_id, t, updateBoard]
|
||||
);
|
||||
|
||||
const handleMouseOver = useCallback(() => {
|
||||
setIsHovering(true);
|
||||
}, []);
|
||||
const editable = useEditable({
|
||||
value: board.board_name,
|
||||
defaultValue: board.board_name,
|
||||
onChange,
|
||||
inputRef,
|
||||
onStartEditing: isHovering.setTrue,
|
||||
});
|
||||
|
||||
const handleMouseOut = useCallback(() => {
|
||||
setIsHovering(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing.isTrue) {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}
|
||||
}, [isEditing.isTrue]);
|
||||
|
||||
if (!isEditing.isTrue) {
|
||||
if (!editable.isEditing) {
|
||||
return (
|
||||
<Flex alignItems="center" gap={3} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut}>
|
||||
<Flex alignItems="center" gap={3} onMouseOver={isHovering.setTrue} onMouseOut={isHovering.setFalse}>
|
||||
<Text
|
||||
size="sm"
|
||||
fontWeight="semibold"
|
||||
userSelect="none"
|
||||
color={isSelected ? 'base.100' : 'base.300'}
|
||||
onDoubleClick={onEdit}
|
||||
onDoubleClick={editable.startEditing}
|
||||
cursor="text"
|
||||
>
|
||||
{localTitle}
|
||||
{editable.value}
|
||||
</Text>
|
||||
{isHovering && (
|
||||
<IconButton aria-label="edit name" icon={<PiPencilBold />} size="sm" variant="ghost" onClick={onEdit} />
|
||||
{isHovering.isTrue && (
|
||||
<IconButton
|
||||
aria-label="edit name"
|
||||
icon={<PiPencilBold />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={editable.startEditing}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
@@ -102,11 +71,8 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
value={localTitle}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={inputRef}
|
||||
{...editable.inputProps}
|
||||
variant="outline"
|
||||
isDisabled={updateBoardResult.isLoading}
|
||||
_focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }}
|
||||
|
||||
@@ -36,7 +36,13 @@ const DeleteBoardModal = () => {
|
||||
const boardToDelete = useStore($boardToDelete);
|
||||
const { t } = useTranslation();
|
||||
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } = useListAllImageNamesForBoardQuery(
|
||||
boardToDelete?.board_id ?? skipToken
|
||||
boardToDelete?.board_id
|
||||
? {
|
||||
board_id: boardToDelete?.board_id,
|
||||
categories: undefined,
|
||||
is_intermediate: undefined,
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
|
||||
const selectImageUsageSummary = useMemo(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useFocusRegion } from 'common/hooks/focus';
|
||||
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
|
||||
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
|
||||
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
|
||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { usePanel, type UsePanelOptions } from 'features/ui/hooks/usePanel';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
@@ -94,7 +94,7 @@ const GalleryPanelContent = () => {
|
||||
<BoardsListWrapper />
|
||||
</Flex>
|
||||
</Panel>
|
||||
<ResizeHandle id="gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
|
||||
<HorizontalResizeHandle id="gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
|
||||
<Panel id="gallery-wrapper-panel" minSize={20}>
|
||||
<Gallery />
|
||||
</Panel>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { IconMenuItem } from 'common/components/IconMenuItem';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { imageOpenedInNewTab } from 'features/gallery/store/actions';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowSquareOutBold } from 'react-icons/pi';
|
||||
@@ -7,9 +9,11 @@ import { PiArrowSquareOutBold } from 'react-icons/pi';
|
||||
export const ImageMenuItemOpenInNewTab = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
window.open(imageDTO.image_url, '_blank');
|
||||
}, [imageDTO.image_url]);
|
||||
dispatch(imageOpenedInNewTab());
|
||||
}, [imageDTO.image_url, dispatch]);
|
||||
|
||||
return (
|
||||
<IconMenuItem
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useBoolean } from 'common/hooks/useBoolean';
|
||||
import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
|
||||
import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage';
|
||||
import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage';
|
||||
@@ -178,7 +177,15 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
);
|
||||
}, [imageDTO, element, store, dndId]);
|
||||
|
||||
const isHovered = useBoolean(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const onMouseOver = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const onMouseOut = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback<MouseEventHandler<HTMLDivElement>>(
|
||||
(e) => {
|
||||
@@ -217,8 +224,8 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
<Flex
|
||||
role="button"
|
||||
className="gallery-image"
|
||||
onMouseOver={isHovered.setTrue}
|
||||
onMouseOut={isHovered.setFalse}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
data-selected={isSelected}
|
||||
@@ -234,7 +241,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
maxH="full"
|
||||
borderRadius="base"
|
||||
/>
|
||||
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered.isTrue} />
|
||||
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered} />
|
||||
</Flex>
|
||||
</Box>
|
||||
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}
|
||||
|
||||
@@ -17,79 +17,19 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const JumpTo = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const { goToPage, currentPage, pages } = useGalleryPagination();
|
||||
const [newPage, setNewPage] = useState(currentPage);
|
||||
const { isOpen, onToggle, onClose } = useDisclosure();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
setNewPage(currentPage);
|
||||
setTimeout(() => {
|
||||
const input = ref.current?.querySelector('input');
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}, 0);
|
||||
}, [currentPage]);
|
||||
|
||||
const onChangeJumpTo = useCallback((v: number) => {
|
||||
setNewPage(v - 1);
|
||||
}, []);
|
||||
|
||||
const onClickGo = useCallback(() => {
|
||||
goToPage(newPage);
|
||||
onClose();
|
||||
}, [newPage, goToPage, onClose]);
|
||||
|
||||
useHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
onClickGo();
|
||||
},
|
||||
{ enabled: isOpen, enableOnFormTags: ['input'] },
|
||||
[isOpen, onClickGo]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
setNewPage(currentPage);
|
||||
onClose();
|
||||
},
|
||||
{ enabled: isOpen, enableOnFormTags: ['input'] },
|
||||
[isOpen, onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNewPage(currentPage);
|
||||
}, [currentPage]);
|
||||
const disclosure = useDisclosure();
|
||||
|
||||
return (
|
||||
<Popover isOpen={isOpen} onClose={onClose} onOpen={onOpen} isLazy lazyBehavior="unmount">
|
||||
<Popover isOpen={disclosure.isOpen} onClose={disclosure.onClose} isLazy lazyBehavior="unmount">
|
||||
<PopoverTrigger>
|
||||
<Button aria-label={t('gallery.jump')} size="sm" onClick={onToggle} variant="outline">
|
||||
<Button aria-label={t('gallery.jump')} size="sm" onClick={disclosure.onToggle} variant="outline">
|
||||
{t('gallery.jump')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<FormControl>
|
||||
<CompositeNumberInput
|
||||
ref={ref}
|
||||
size="sm"
|
||||
maxW="60px"
|
||||
value={newPage + 1}
|
||||
min={1}
|
||||
max={pages}
|
||||
step={1}
|
||||
onChange={onChangeJumpTo}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button h="full" size="sm" onClick={onClickGo}>
|
||||
{t('gallery.go')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<JumpToContent disclosure={disclosure} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -97,3 +37,68 @@ export const JumpTo = memo(() => {
|
||||
});
|
||||
|
||||
JumpTo.displayName = 'JumpTo';
|
||||
|
||||
const JumpToContent = memo(({ disclosure }: { disclosure: ReturnType<typeof useDisclosure> }) => {
|
||||
const { t } = useTranslation();
|
||||
const { goToPage, currentPage, pages } = useGalleryPagination();
|
||||
const [newPage, setNewPage] = useState(currentPage);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChangeJumpTo = useCallback((v: number) => {
|
||||
setNewPage(v - 1);
|
||||
}, []);
|
||||
|
||||
const onClickGo = useCallback(() => {
|
||||
goToPage(newPage);
|
||||
disclosure.onClose();
|
||||
}, [goToPage, newPage, disclosure]);
|
||||
|
||||
useHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
onClickGo();
|
||||
},
|
||||
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
|
||||
[disclosure.isOpen, onClickGo]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
setNewPage(currentPage);
|
||||
disclosure.onClose();
|
||||
},
|
||||
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
|
||||
[disclosure.isOpen, disclosure.onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const input = ref.current?.querySelector('input');
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}, 0);
|
||||
setNewPage(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
return (
|
||||
<Flex gap={2} alignItems="center">
|
||||
<FormControl>
|
||||
<CompositeNumberInput
|
||||
ref={ref}
|
||||
size="sm"
|
||||
maxW="60px"
|
||||
value={newPage + 1}
|
||||
min={1}
|
||||
max={pages}
|
||||
step={1}
|
||||
onChange={onChangeJumpTo}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button h="full" size="sm" onClick={onClickGo}>
|
||||
{t('gallery.go')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
JumpToContent.displayName = 'JumpToContent';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FlexProps } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, IconButton, Tooltip, useShiftModifier } from '@invoke-ai/ui-library';
|
||||
import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
|
||||
import { useClipboard } from 'common/hooks/useClipboard';
|
||||
@@ -18,12 +19,12 @@ type Props = {
|
||||
withDownload?: boolean;
|
||||
withCopy?: boolean;
|
||||
extraCopyActions?: { label: string; getData: (data: unknown) => unknown }[];
|
||||
};
|
||||
} & FlexProps;
|
||||
|
||||
const overlayscrollbarsOptions = getOverlayScrollbarsParams('scroll', 'scroll').options;
|
||||
|
||||
const DataViewer = (props: Props) => {
|
||||
const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions } = props;
|
||||
const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions, ...rest } = props;
|
||||
const dataString = useMemo(() => (isString(data) ? data : formatter.Serialize(data)) ?? '', [data]);
|
||||
const shift = useShiftModifier();
|
||||
const clipboard = useClipboard();
|
||||
@@ -44,8 +45,8 @@ const DataViewer = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex layerStyle="second" borderRadius="base" flexGrow={1} w="full" h="full" position="relative">
|
||||
<Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="auto" p={4} fontSize="sm">
|
||||
<Flex bg="base.800" borderRadius="base" flexGrow={1} w="full" h="full" position="relative" {...rest}>
|
||||
<Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="auto" p={2} fontSize="sm">
|
||||
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayscrollbarsOptions}>
|
||||
<pre>{dataString}</pre>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Flex, Image } from '@invoke-ai/ui-library';
|
||||
import type { ComparisonProps } from 'features/gallery/components/ImageViewer/common';
|
||||
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
|
||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { VerticalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
|
||||
import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||
@@ -42,7 +42,7 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Comp
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Panel>
|
||||
<ResizeHandle id="image-comparison-side-by-side-handle" onDoubleClick={onDoubleClickHandle} />
|
||||
<VerticalResizeHandle id="image-comparison-side-by-side-handle" onDoubleClick={onDoubleClickHandle} />
|
||||
|
||||
<Panel minSize={20}>
|
||||
<Flex position="relative" w="full" h="full" alignItems="center" justifyContent="center">
|
||||
|
||||
@@ -115,19 +115,13 @@ export const useGalleryPagination = () => {
|
||||
},
|
||||
[throttledOnOffsetChanged, limit]
|
||||
);
|
||||
const goToFirst = useCallback(() => {
|
||||
throttledOnOffsetChanged({ offset: 0 });
|
||||
}, [throttledOnOffsetChanged]);
|
||||
const goToLast = useCallback(() => {
|
||||
throttledOnOffsetChanged({ offset: (pages - 1) * (limit || 0) });
|
||||
}, [throttledOnOffsetChanged, pages, limit]);
|
||||
|
||||
// handle when total/pages decrease and user is on high page number (ie bulk removing or deleting)
|
||||
useEffect(() => {
|
||||
if (pages && currentPage + 1 > pages) {
|
||||
goToLast();
|
||||
throttledOnOffsetChanged({ offset: (pages - 1) * (limit || 0) });
|
||||
}
|
||||
}, [currentPage, pages, goToLast]);
|
||||
}, [currentPage, pages, throttledOnOffsetChanged, limit]);
|
||||
|
||||
const pageButtons = useMemo(() => {
|
||||
if (pages > 7) {
|
||||
@@ -135,35 +129,16 @@ export const useGalleryPagination = () => {
|
||||
}
|
||||
return range(1, pages);
|
||||
}, [currentPage, pages]);
|
||||
const isFirstEnabled = useMemo(() => currentPage > 0, [currentPage]);
|
||||
const isLastEnabled = useMemo(() => currentPage < pages - 1, [currentPage, pages]);
|
||||
|
||||
const rangeDisplay = useMemo(() => {
|
||||
const startItem = currentPage * (limit || 0) + 1;
|
||||
const endItem = Math.min((currentPage + 1) * (limit || 0), total);
|
||||
return `${startItem}-${endItem} of ${total}`;
|
||||
}, [total, currentPage, limit]);
|
||||
|
||||
const numberOnPage = useMemo(() => {
|
||||
return Math.min((currentPage + 1) * (limit || 0), total);
|
||||
}, [currentPage, limit, total]);
|
||||
|
||||
return {
|
||||
count,
|
||||
total,
|
||||
currentPage,
|
||||
pages,
|
||||
isNextEnabled,
|
||||
isPrevEnabled,
|
||||
goNext,
|
||||
goPrev,
|
||||
goToPage,
|
||||
goToFirst,
|
||||
goToLast,
|
||||
goNext,
|
||||
isPrevEnabled,
|
||||
isNextEnabled,
|
||||
pageButtons,
|
||||
isFirstEnabled,
|
||||
isLastEnabled,
|
||||
rangeDisplay,
|
||||
numberOnPage,
|
||||
goToPage,
|
||||
currentPage,
|
||||
total,
|
||||
pages,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,3 +3,7 @@ import { createAction } from '@reduxjs/toolkit';
|
||||
export const sentImageToCanvas = createAction('gallery/sentImageToCanvas');
|
||||
|
||||
export const imageDownloaded = createAction('gallery/imageDownloaded');
|
||||
|
||||
export const imageCopiedToClipboard = createAction('gallery/imageCopiedToClipboard');
|
||||
|
||||
export const imageOpenedInNewTab = createAction('gallery/imageOpenedInNewTab');
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { useFocusRegion } from 'common/hooks/focus';
|
||||
|
||||
@@ -12,25 +12,27 @@ import {
|
||||
Text,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { EdgeChange, NodeChange } from '@xyflow/react';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { CommandEmpty, CommandItem, CommandList, CommandRoot } from 'cmdk';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { useBuildNode } from 'features/nodes/hooks/useBuildNode';
|
||||
import {
|
||||
$addNodeCmdk,
|
||||
$cursorPos,
|
||||
$edgePendingUpdate,
|
||||
$pendingConnection,
|
||||
$templates,
|
||||
edgesChanged,
|
||||
nodesChanged,
|
||||
useAddNodeCmdk,
|
||||
} from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition';
|
||||
import { getFirstValidConnection } from 'features/nodes/store/util/getFirstValidConnection';
|
||||
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
|
||||
import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes';
|
||||
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { toast } from 'features/toast/toast';
|
||||
@@ -38,34 +40,12 @@ import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { memoize } from 'lodash-es';
|
||||
import { computed } from 'nanostores';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCircuitryBold, PiFlaskBold, PiHammerBold, PiLightningFill } from 'react-icons/pi';
|
||||
import type { EdgeChange, NodeChange } from 'reactflow';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
const useThrottle = <T,>(value: T, limit: number) => {
|
||||
const [throttledValue, setThrottledValue] = useState(value);
|
||||
const lastRan = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(
|
||||
function () {
|
||||
if (Date.now() - lastRan.current >= limit) {
|
||||
setThrottledValue(value);
|
||||
lastRan.current = Date.now();
|
||||
}
|
||||
},
|
||||
limit - (Date.now() - lastRan.current)
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, limit]);
|
||||
|
||||
return throttledValue;
|
||||
};
|
||||
import { objectEntries } from 'tsafe';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
const useAddNode = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -95,8 +75,8 @@ const useAddNode = () => {
|
||||
node.selected = true;
|
||||
|
||||
// Deselect all other nodes and edges
|
||||
const nodeChanges: NodeChange[] = [{ type: 'add', item: node }];
|
||||
const edgeChanges: EdgeChange[] = [];
|
||||
const nodeChanges: NodeChange<AnyNode>[] = [{ type: 'add', item: node }];
|
||||
const edgeChanges: EdgeChange<AnyEdge>[] = [];
|
||||
nodes.forEach(({ id, selected }) => {
|
||||
if (selected) {
|
||||
nodeChanges.push({ type: 'select', id, selected: false });
|
||||
@@ -162,19 +142,26 @@ const cmdkRootSx: SystemStyleObject = {
|
||||
|
||||
export const AddNodeCmdk = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const addNodeCmdk = useAddNodeCmdk();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const addNode = useAddNode();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const throttledSearchTerm = useThrottle(searchTerm, 100);
|
||||
// Filtering the list is expensive - debounce the search term to avoid stutters
|
||||
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
|
||||
const isOpen = useStore($addNodeCmdk);
|
||||
const open = useCallback(() => {
|
||||
$addNodeCmdk.set(true);
|
||||
}, []);
|
||||
const close = useCallback(() => {
|
||||
$addNodeCmdk.set(false);
|
||||
}, []);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'addNode',
|
||||
category: 'workflows',
|
||||
callback: addNodeCmdk.setTrue,
|
||||
callback: open,
|
||||
options: { enabled: tab === 'workflows', preventDefault: true },
|
||||
dependencies: [addNodeCmdk.setTrue, tab],
|
||||
dependencies: [open, tab],
|
||||
});
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -182,10 +169,10 @@ export const AddNodeCmdk = memo(() => {
|
||||
}, []);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
addNodeCmdk.setFalse();
|
||||
close();
|
||||
setSearchTerm('');
|
||||
$pendingConnection.set(null);
|
||||
}, [addNodeCmdk]);
|
||||
}, [close]);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(value: string) => {
|
||||
@@ -196,14 +183,7 @@ export const AddNodeCmdk = memo(() => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={addNodeCmdk.isTrue}
|
||||
onClose={onClose}
|
||||
useInert={false}
|
||||
initialFocusRef={inputRef}
|
||||
size="xl"
|
||||
isCentered
|
||||
>
|
||||
<Modal isOpen={isOpen} onClose={onClose} useInert={false} initialFocusRef={inputRef} size="xl" isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent h="512" maxH="70%">
|
||||
<ModalBody p={2} h="full" sx={cmdkRootSx}>
|
||||
@@ -224,7 +204,7 @@ export const AddNodeCmdk = memo(() => {
|
||||
/>
|
||||
</CommandEmpty>
|
||||
<CommandList>
|
||||
<NodeCommandList searchTerm={throttledSearchTerm} onSelect={onSelect} />
|
||||
<NodeCommandList searchTerm={debouncedSearchTerm} onSelect={onSelect} />
|
||||
</CommandList>
|
||||
</ScrollableContent>
|
||||
</Box>
|
||||
@@ -381,11 +361,11 @@ const NodeCommandList = memo(({ searchTerm, onSelect }: { searchTerm: string; on
|
||||
if (filter(template, searchTerm)) {
|
||||
const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs;
|
||||
|
||||
for (const field of Object.values(candidateFields)) {
|
||||
for (const [_fieldName, fieldTemplate] of objectEntries(candidateFields)) {
|
||||
const sourceType =
|
||||
pendingConnection.handleType === 'source' ? field.type : pendingConnection.fieldTemplate.type;
|
||||
pendingConnection.handleType === 'source' ? pendingConnection.fieldTemplate.type : fieldTemplate.type;
|
||||
const targetType =
|
||||
pendingConnection.handleType === 'target' ? field.type : pendingConnection.fieldTemplate.type;
|
||||
pendingConnection.handleType === 'target' ? pendingConnection.fieldTemplate.type : fieldTemplate.type;
|
||||
|
||||
if (validateConnectionTypes(sourceType, targetType)) {
|
||||
_items.push({
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { useGlobalMenuClose, useToken } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type {
|
||||
EdgeChange,
|
||||
HandleType,
|
||||
NodeChange,
|
||||
OnEdgesChange,
|
||||
OnInit,
|
||||
OnMoveEnd,
|
||||
OnNodesChange,
|
||||
OnReconnect,
|
||||
ProOptions,
|
||||
ReactFlowProps,
|
||||
ReactFlowState,
|
||||
} from '@xyflow/react';
|
||||
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from '@xyflow/react';
|
||||
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useConnection } from 'features/nodes/hooks/useConnection';
|
||||
import { useCopyPaste } from 'features/nodes/hooks/useCopyPaste';
|
||||
import { useSyncExecutionState } from 'features/nodes/hooks/useExecutionState';
|
||||
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
|
||||
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
|
||||
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher';
|
||||
import {
|
||||
$addNodeCmdk,
|
||||
@@ -30,23 +44,12 @@ import {
|
||||
} from 'features/nodes/store/selectors';
|
||||
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
|
||||
import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
|
||||
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import type { CSSProperties, MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import type {
|
||||
EdgeChange,
|
||||
NodeChange,
|
||||
OnEdgesChange,
|
||||
OnEdgeUpdateFunc,
|
||||
OnInit,
|
||||
OnMoveEnd,
|
||||
OnNodesChange,
|
||||
ProOptions,
|
||||
ReactFlowProps,
|
||||
ReactFlowState,
|
||||
} from 'reactflow';
|
||||
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from 'reactflow';
|
||||
|
||||
import CustomConnectionLine from './connectionLines/CustomConnectionLine';
|
||||
import InvocationCollapsedEdge from './edges/InvocationCollapsedEdge';
|
||||
@@ -58,13 +61,13 @@ import NotesNode from './nodes/Notes/NotesNode';
|
||||
const edgeTypes = {
|
||||
collapsed: InvocationCollapsedEdge,
|
||||
default: InvocationDefaultEdge,
|
||||
};
|
||||
} as const;
|
||||
|
||||
const nodeTypes = {
|
||||
invocation: InvocationNodeWrapper,
|
||||
current_image: CurrentImageNode,
|
||||
notes: NotesNode,
|
||||
};
|
||||
} as const;
|
||||
|
||||
// TODO: can we support reactflow? if not, we could style the attribution so it matches the app
|
||||
const proOptions: ProOptions = { hideAttribution: true };
|
||||
@@ -97,7 +100,7 @@ export const Flow = memo(() => {
|
||||
const [borderRadius] = useToken('radii', ['base']);
|
||||
const flowStyles = useMemo<CSSProperties>(() => ({ borderRadius }), [borderRadius]);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
const onNodesChange: OnNodesChange<AnyNode> = useCallback(
|
||||
(nodeChanges) => {
|
||||
dispatch(nodesChanged(nodeChanges));
|
||||
const flow = $flow.get();
|
||||
@@ -112,7 +115,7 @@ export const Flow = memo(() => {
|
||||
[dispatch, needsFit]
|
||||
);
|
||||
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
const onEdgesChange: OnEdgesChange<AnyEdge> = useCallback(
|
||||
(changes) => {
|
||||
if (changes.length > 0) {
|
||||
dispatch(edgesChanged(changes));
|
||||
@@ -130,7 +133,7 @@ export const Flow = memo(() => {
|
||||
onCloseGlobal();
|
||||
}, [onCloseGlobal]);
|
||||
|
||||
const onInit: OnInit = useCallback((flow) => {
|
||||
const onInit: OnInit<AnyNode, AnyEdge> = useCallback((flow) => {
|
||||
$flow.set(flow);
|
||||
flow.fitView();
|
||||
}, []);
|
||||
@@ -158,13 +161,13 @@ export const Flow = memo(() => {
|
||||
* where the edge is deleted if you click it accidentally).
|
||||
*/
|
||||
|
||||
const onEdgeUpdateStart: NonNullable<ReactFlowProps['onEdgeUpdateStart']> = useCallback((e, edge, _handleType) => {
|
||||
const onReconnectStart = useCallback((event: MouseEvent, edge: AnyEdge, _handleType: HandleType) => {
|
||||
$edgePendingUpdate.set(edge);
|
||||
$didUpdateEdge.set(false);
|
||||
$lastEdgeUpdateMouseEvent.set(e);
|
||||
$lastEdgeUpdateMouseEvent.set(event);
|
||||
}, []);
|
||||
|
||||
const onEdgeUpdate: OnEdgeUpdateFunc = useCallback(
|
||||
const onReconnect: OnReconnect = useCallback(
|
||||
(oldEdge, newConnection) => {
|
||||
// This event is fired when an edge update is successful
|
||||
$didUpdateEdge.set(true);
|
||||
@@ -183,7 +186,7 @@ export const Flow = memo(() => {
|
||||
[dispatch, updateNodeInternals]
|
||||
);
|
||||
|
||||
const onEdgeUpdateEnd: NonNullable<ReactFlowProps['onEdgeUpdateEnd']> = useCallback(
|
||||
const onReconnectEnd: NonNullable<ReactFlowProps['onReconnectEnd']> = useCallback(
|
||||
(e, edge, _handleType) => {
|
||||
const didUpdateEdge = $didUpdateEdge.get();
|
||||
// Fall back to a reasonable default event
|
||||
@@ -208,7 +211,7 @@ export const Flow = memo(() => {
|
||||
|
||||
// #endregion
|
||||
|
||||
const { copySelection, pasteSelection, pasteSelectionWithEdges } = useCopyPaste();
|
||||
const { copySelection, pasteSelection, pasteSelectionWithEdges } = useNodeCopyPaste();
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'copySelection',
|
||||
@@ -220,8 +223,8 @@ export const Flow = memo(() => {
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
const { nodes, edges } = selectNodesSlice(store.getState());
|
||||
const nodeChanges: NodeChange[] = [];
|
||||
const edgeChanges: EdgeChange[] = [];
|
||||
const nodeChanges: NodeChange<AnyNode>[] = [];
|
||||
const edgeChanges: EdgeChange<AnyEdge>[] = [];
|
||||
nodes.forEach(({ id, selected }) => {
|
||||
if (!selected) {
|
||||
nodeChanges.push({ type: 'select', id, selected: true });
|
||||
@@ -294,8 +297,8 @@ export const Flow = memo(() => {
|
||||
|
||||
const deleteSelection = useCallback(() => {
|
||||
const { nodes, edges } = selectNodesSlice(store.getState());
|
||||
const nodeChanges: NodeChange[] = [];
|
||||
const edgeChanges: EdgeChange[] = [];
|
||||
const nodeChanges: NodeChange<AnyNode>[] = [];
|
||||
const edgeChanges: EdgeChange<AnyEdge>[] = [];
|
||||
nodes
|
||||
.filter((n) => n.selected)
|
||||
.forEach(({ id }) => {
|
||||
@@ -322,7 +325,7 @@ export const Flow = memo(() => {
|
||||
});
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
<ReactFlow<AnyNode, AnyEdge>
|
||||
id="workflow-editor"
|
||||
ref={flowWrapper}
|
||||
defaultViewport={viewport}
|
||||
@@ -334,9 +337,9 @@ export const Flow = memo(() => {
|
||||
onMouseMove={onMouseMove}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgeUpdate={onEdgeUpdate}
|
||||
onEdgeUpdateStart={onEdgeUpdateStart}
|
||||
onEdgeUpdateEnd={onEdgeUpdateEnd}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
@@ -354,6 +357,9 @@ export const Flow = memo(() => {
|
||||
selectionMode={selectionMode}
|
||||
elevateEdgesOnSelect
|
||||
nodeDragThreshold={1}
|
||||
noDragClassName={NO_DRAG_CLASS}
|
||||
noWheelClassName={NO_WHEEL_CLASS}
|
||||
noPanClassName={NO_PAN_CLASS}
|
||||
>
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { ConnectionLineComponentProps } from '@xyflow/react';
|
||||
import { getBezierPath } from '@xyflow/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
|
||||
@@ -6,8 +8,6 @@ import { $pendingConnection } from 'features/nodes/store/nodesSlice';
|
||||
import { selectShouldAnimateEdges, selectShouldColorEdges } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { ConnectionLineComponentProps } from 'reactflow';
|
||||
import { getBezierPath } from 'reactflow';
|
||||
|
||||
const pathStyles: CSSProperties = { opacity: 0.8 };
|
||||
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
import { Badge, Flex } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Badge, Box, chakra } from '@invoke-ai/ui-library';
|
||||
import type { EdgeProps } from '@xyflow/react';
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useChakraThemeTokens } from 'common/hooks/useChakraThemeTokens';
|
||||
import { getEdgeStyles } from 'features/nodes/components/flow/edges/util/getEdgeColor';
|
||||
import { makeEdgeSelector } from 'features/nodes/components/flow/edges/util/makeEdgeSelector';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { buildSelectAreConnectedNodesSelected } from 'features/nodes/components/flow/edges/util/buildEdgeSelectors';
|
||||
import { selectShouldAnimateEdges } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
|
||||
import type { CollapsedInvocationNodeEdge } from 'features/nodes/types/invocation';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { EdgeProps } from 'reactflow';
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from 'reactflow';
|
||||
|
||||
const ChakraBaseEdge = chakra(BaseEdge);
|
||||
|
||||
const baseEdgeSx: SystemStyleObject = {
|
||||
strokeWidth: '3px !important',
|
||||
stroke: 'base.500 !important',
|
||||
opacity: '0.5 !important',
|
||||
strokeDasharray: 'none',
|
||||
'&[data-selected="true"]': {
|
||||
opacity: '1 !important',
|
||||
},
|
||||
'&[data-selected="true"], &[data-are-connected-nodes-selected="true"]': {
|
||||
strokeDasharray: '5 !important',
|
||||
},
|
||||
'&[data-should-animate-edges="true"]': {
|
||||
animation: 'dashdraw 0.5s linear infinite !important',
|
||||
},
|
||||
};
|
||||
|
||||
const badgeSx: SystemStyleObject = {
|
||||
bg: 'base.500',
|
||||
opacity: 0.5,
|
||||
shadow: 'base',
|
||||
'&[data-selected="true"]': {
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const InvocationCollapsedEdge = ({
|
||||
sourceX,
|
||||
@@ -20,17 +47,15 @@ const InvocationCollapsedEdge = ({
|
||||
data,
|
||||
selected = false,
|
||||
source,
|
||||
sourceHandleId,
|
||||
target,
|
||||
targetHandleId,
|
||||
}: EdgeProps<{ count: number }>) => {
|
||||
const templates = useStore($templates);
|
||||
const selector = useMemo(
|
||||
() => makeEdgeSelector(templates, source, sourceHandleId, target, targetHandleId),
|
||||
[templates, source, sourceHandleId, target, targetHandleId]
|
||||
}: EdgeProps<CollapsedInvocationNodeEdge>) => {
|
||||
const shouldAnimateEdges = useAppSelector(selectShouldAnimateEdges);
|
||||
const selectAreConnectedNodesSelected = useMemo(
|
||||
() => buildSelectAreConnectedNodesSelected(source, target),
|
||||
[source, target]
|
||||
);
|
||||
|
||||
const { shouldAnimateEdges, areConnectedNodesSelected } = useAppSelector(selector);
|
||||
const areConnectedNodesSelected = useAppSelector(selectAreConnectedNodesSelected);
|
||||
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
@@ -41,31 +66,29 @@ const InvocationCollapsedEdge = ({
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const { base500 } = useChakraThemeTokens();
|
||||
|
||||
const edgeStyles = useMemo(
|
||||
() => getEdgeStyles(base500, selected, shouldAnimateEdges, areConnectedNodesSelected),
|
||||
[areConnectedNodesSelected, base500, selected, shouldAnimateEdges]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} markerEnd={markerEnd} style={edgeStyles} />
|
||||
{data?.count && data.count > 1 && (
|
||||
<ChakraBaseEdge
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
sx={baseEdgeSx}
|
||||
data-selected={selected}
|
||||
data-are-connected-nodes-selected={areConnectedNodesSelected}
|
||||
data-should-animate-edges={shouldAnimateEdges}
|
||||
/>
|
||||
{data?.count !== undefined && (
|
||||
<EdgeLabelRenderer>
|
||||
<Flex
|
||||
data-testid="asdfasdfasdf"
|
||||
<Box
|
||||
position="absolute"
|
||||
transform={`translate(-50%, -50%) translate(${labelX}px,${labelY}px)`}
|
||||
className="nodrag nopan"
|
||||
// Unfortunately edge labels do not get the same zIndex treatment as edges do, so we need to manage this ourselves
|
||||
className={`edge-label-renderer__custom-edge ${NO_DRAG_CLASS} ${NO_PAN_CLASS}`} // Unfortunately edge labels do not get the same zIndex treatment as edges do, so we need to manage this ourselves
|
||||
// See: https://github.com/xyflow/xyflow/issues/3658
|
||||
zIndex={1001}
|
||||
>
|
||||
<Badge variant="solid" bg="base.500" opacity={selected ? 0.8 : 0.5} boxShadow="base">
|
||||
<Badge variant="solid" sx={badgeSx} data-selected={selected}>
|
||||
{data.count}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Box>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,14 +1,62 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { chakra, Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { EdgeProps } from '@xyflow/react';
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { getEdgeStyles } from 'features/nodes/components/flow/edges/util/getEdgeColor';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectShouldShowEdgeLabels } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { selectShouldAnimateEdges, selectShouldShowEdgeLabels } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
|
||||
import type { DefaultInvocationNodeEdge } from 'features/nodes/types/invocation';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { EdgeProps } from 'reactflow';
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from 'reactflow';
|
||||
|
||||
import { makeEdgeSelector } from './util/makeEdgeSelector';
|
||||
import {
|
||||
buildSelectAreConnectedNodesSelected,
|
||||
buildSelectEdgeColor,
|
||||
buildSelectEdgeLabel,
|
||||
} from './util/buildEdgeSelectors';
|
||||
|
||||
const ChakraBaseEdge = chakra(BaseEdge);
|
||||
|
||||
const baseEdgeSx: SystemStyleObject = {
|
||||
strokeWidth: '3px !important',
|
||||
opacity: '0.5 !important',
|
||||
strokeDasharray: 'none',
|
||||
'&[data-selected="true"]': {
|
||||
opacity: '1 !important',
|
||||
},
|
||||
'&[data-should-animate-edges="true"]': {
|
||||
animation: 'dashdraw 0.5s linear infinite !important',
|
||||
'&[data-selected="true"], &[data-are-connected-nodes-selected="true"]': {
|
||||
strokeDasharray: '5 !important',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const edgeLabelWrapperSx: SystemStyleObject = {
|
||||
pointerEvents: 'all',
|
||||
position: 'absolute',
|
||||
bg: 'base.800',
|
||||
borderRadius: 'base',
|
||||
borderWidth: 1,
|
||||
opacity: 0.5,
|
||||
borderColor: 'transparent',
|
||||
py: 1,
|
||||
px: 3,
|
||||
shadow: 'md',
|
||||
'&[data-selected="true"]': {
|
||||
opacity: 1,
|
||||
borderColor: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const edgeLabelTextSx: SystemStyleObject = {
|
||||
fontWeight: 'semibold',
|
||||
color: 'base.300',
|
||||
'&[data-selected="true"]': {
|
||||
color: 'base.100',
|
||||
},
|
||||
};
|
||||
|
||||
const InvocationDefaultEdge = ({
|
||||
sourceX,
|
||||
@@ -23,15 +71,26 @@ const InvocationDefaultEdge = ({
|
||||
target,
|
||||
sourceHandleId,
|
||||
targetHandleId,
|
||||
}: EdgeProps) => {
|
||||
}: EdgeProps<DefaultInvocationNodeEdge>) => {
|
||||
const templates = useStore($templates);
|
||||
const selector = useMemo(
|
||||
() => makeEdgeSelector(templates, source, sourceHandleId, target, targetHandleId),
|
||||
const shouldAnimateEdges = useAppSelector(selectShouldAnimateEdges);
|
||||
const shouldShowEdgeLabels = useAppSelector(selectShouldShowEdgeLabels);
|
||||
|
||||
const selectAreConnectedNodesSelected = useMemo(
|
||||
() => buildSelectAreConnectedNodesSelected(source, target),
|
||||
[source, target]
|
||||
);
|
||||
const selectStrokeColor = useMemo(
|
||||
() => buildSelectEdgeColor(templates, source, sourceHandleId, target, targetHandleId),
|
||||
[templates, source, sourceHandleId, target, targetHandleId]
|
||||
);
|
||||
|
||||
const { shouldAnimateEdges, areConnectedNodesSelected, stroke, label } = useAppSelector(selector);
|
||||
const shouldShowEdgeLabels = useAppSelector(selectShouldShowEdgeLabels);
|
||||
const selectEdgeLabel = useMemo(
|
||||
() => buildSelectEdgeLabel(templates, source, sourceHandleId, target, targetHandleId),
|
||||
[templates, source, sourceHandleId, target, targetHandleId]
|
||||
);
|
||||
const areConnectedNodesSelected = useAppSelector(selectAreConnectedNodesSelected);
|
||||
const stroke = useAppSelector(selectStrokeColor);
|
||||
const label = useAppSelector(selectEdgeLabel);
|
||||
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
@@ -42,31 +101,26 @@ const InvocationDefaultEdge = ({
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const edgeStyles = useMemo(
|
||||
() => getEdgeStyles(stroke, selected, shouldAnimateEdges, areConnectedNodesSelected),
|
||||
[areConnectedNodesSelected, stroke, selected, shouldAnimateEdges]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} markerEnd={markerEnd} style={edgeStyles} />
|
||||
<ChakraBaseEdge
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
sx={baseEdgeSx}
|
||||
stroke={`${stroke} !important`}
|
||||
data-selected={selected}
|
||||
data-are-connected-nodes-selected={areConnectedNodesSelected}
|
||||
data-should-animate-edges={shouldAnimateEdges}
|
||||
/>
|
||||
{label && shouldShowEdgeLabels && (
|
||||
<EdgeLabelRenderer>
|
||||
<Flex
|
||||
className="nodrag nopan"
|
||||
pointerEvents="all"
|
||||
position="absolute"
|
||||
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS}`}
|
||||
transform={`translate(-50%, -50%) translate(${labelX}px,${labelY}px)`}
|
||||
bg="base.800"
|
||||
borderRadius="base"
|
||||
borderWidth={1}
|
||||
borderColor={selected ? 'undefined' : 'transparent'}
|
||||
opacity={selected ? 1 : 0.5}
|
||||
py={1}
|
||||
px={3}
|
||||
shadow="md"
|
||||
data-selected={selected}
|
||||
sx={edgeLabelWrapperSx}
|
||||
>
|
||||
<Text size="sm" fontWeight="semibold" color={selected ? 'base.100' : 'base.300'}>
|
||||
<Text size="sm" sx={edgeLabelTextSx} data-selected={selected}>
|
||||
{label}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import type { Templates } from 'features/nodes/store/types';
|
||||
import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
|
||||
import { getFieldColor } from './getEdgeColor';
|
||||
|
||||
export const buildSelectAreConnectedNodesSelected = (source: string, target: string) =>
|
||||
createSelector(selectNodesSlice, (nodes): boolean => {
|
||||
const sourceNode = nodes.nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.nodes.find((node) => node.id === target);
|
||||
|
||||
return Boolean(sourceNode?.selected || targetNode?.selected);
|
||||
});
|
||||
|
||||
export const buildSelectEdgeColor = (
|
||||
templates: Templates,
|
||||
source: string,
|
||||
sourceHandleId: string | null | undefined,
|
||||
target: string,
|
||||
targetHandleId: string | null | undefined
|
||||
) =>
|
||||
createSelector(selectNodesSlice, selectWorkflowSettingsSlice, (nodes, workflowSettings): string => {
|
||||
const { shouldColorEdges } = workflowSettings;
|
||||
const sourceNode = nodes.nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.nodes.find((node) => node.id === target);
|
||||
|
||||
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
|
||||
return colorTokenToCssVar('base.500');
|
||||
}
|
||||
|
||||
const sourceNodeTemplate = templates[sourceNode.data.type];
|
||||
|
||||
const isInvocationToInvocationEdge = isInvocationNode(sourceNode) && isInvocationNode(targetNode);
|
||||
const outputFieldTemplate = sourceNodeTemplate?.outputs[sourceHandleId];
|
||||
const sourceType = isInvocationToInvocationEdge ? outputFieldTemplate?.type : undefined;
|
||||
|
||||
return sourceType && shouldColorEdges ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
|
||||
});
|
||||
|
||||
export const buildSelectEdgeLabel = (
|
||||
templates: Templates,
|
||||
source: string,
|
||||
sourceHandleId: string | null | undefined,
|
||||
target: string,
|
||||
targetHandleId: string | null | undefined
|
||||
) =>
|
||||
createSelector(selectNodesSlice, (nodes): string | null => {
|
||||
const sourceNode = nodes.nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.nodes.find((node) => node.id === target);
|
||||
|
||||
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceNodeTemplate = templates[sourceNode.data.type];
|
||||
const targetNodeTemplate = templates[targetNode.data.type];
|
||||
|
||||
return `${sourceNodeTemplate?.title || sourceNode.data?.label} -> ${targetNodeTemplate?.title || targetNode.data?.label}`;
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { FIELD_COLORS } from 'features/nodes/types/constants';
|
||||
import type { FieldType } from 'features/nodes/types/field';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const getFieldColor = (fieldType: FieldType | null): string => {
|
||||
if (!fieldType) {
|
||||
@@ -11,16 +10,3 @@ export const getFieldColor = (fieldType: FieldType | null): string => {
|
||||
|
||||
return color ? colorTokenToCssVar(color) : colorTokenToCssVar('base.500');
|
||||
};
|
||||
|
||||
export const getEdgeStyles = (
|
||||
stroke: string,
|
||||
selected: boolean,
|
||||
shouldAnimateEdges: boolean,
|
||||
areConnectedNodesSelected: boolean
|
||||
): CSSProperties => ({
|
||||
strokeWidth: 3,
|
||||
stroke,
|
||||
opacity: selected ? 1 : 0.5,
|
||||
animation: shouldAnimateEdges ? 'dashdraw 0.5s linear infinite' : undefined,
|
||||
strokeDasharray: selected || areConnectedNodesSelected ? 5 : 'none',
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import type { Templates } from 'features/nodes/store/types';
|
||||
import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
|
||||
import { getFieldColor } from './getEdgeColor';
|
||||
|
||||
const defaultReturnValue = {
|
||||
areConnectedNodesSelected: false,
|
||||
shouldAnimateEdges: false,
|
||||
stroke: colorTokenToCssVar('base.500'),
|
||||
label: '',
|
||||
};
|
||||
|
||||
export const makeEdgeSelector = (
|
||||
templates: Templates,
|
||||
source: string,
|
||||
sourceHandleId: string | null | undefined,
|
||||
target: string,
|
||||
targetHandleId: string | null | undefined
|
||||
) =>
|
||||
createMemoizedSelector(
|
||||
selectNodesSlice,
|
||||
selectWorkflowSettingsSlice,
|
||||
(
|
||||
nodes,
|
||||
workflowSettings
|
||||
): { areConnectedNodesSelected: boolean; shouldAnimateEdges: boolean; stroke: string; label: string } => {
|
||||
const { shouldAnimateEdges, shouldColorEdges } = workflowSettings;
|
||||
const sourceNode = nodes.nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.nodes.find((node) => node.id === target);
|
||||
|
||||
const returnValue = deepClone(defaultReturnValue);
|
||||
returnValue.shouldAnimateEdges = shouldAnimateEdges;
|
||||
|
||||
const isInvocationToInvocationEdge = isInvocationNode(sourceNode) && isInvocationNode(targetNode);
|
||||
|
||||
returnValue.areConnectedNodesSelected = Boolean(sourceNode?.selected || targetNode?.selected);
|
||||
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
const sourceNodeTemplate = templates[sourceNode.data.type];
|
||||
const targetNodeTemplate = templates[targetNode.data.type];
|
||||
|
||||
const outputFieldTemplate = sourceNodeTemplate?.outputs[sourceHandleId];
|
||||
const sourceType = isInvocationToInvocationEdge ? outputFieldTemplate?.type : undefined;
|
||||
|
||||
returnValue.stroke = sourceType && shouldColorEdges ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
|
||||
|
||||
returnValue.label = `${sourceNodeTemplate?.title || sourceNode.data?.label} -> ${targetNodeTemplate?.title || targetNode.data?.label}`;
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Flex, Image, Text } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { NodeProps } from '@xyflow/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
@@ -12,7 +13,6 @@ import { motion } from 'framer-motion';
|
||||
import type { CSSProperties, PropsWithChildren } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NodeProps } from 'reactflow';
|
||||
import { $lastProgressEvent } from 'services/events/stores';
|
||||
|
||||
const CurrentImageNode = (props: NodeProps) => {
|
||||
|
||||
@@ -1,83 +1,124 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, Grid, GridItem } from '@invoke-ai/ui-library';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
|
||||
import { useFieldNames } from 'features/nodes/hooks/useFieldNames';
|
||||
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
|
||||
import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate';
|
||||
import { OutputFieldNodesEditorView } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView';
|
||||
import {
|
||||
useInputFieldNamesAnyOrDirect,
|
||||
useInputFieldNamesConnection,
|
||||
useInputFieldNamesMissing,
|
||||
} from 'features/nodes/hooks/useInputFieldNamesByStatus';
|
||||
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
|
||||
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
|
||||
import { memo } from 'react';
|
||||
|
||||
import InputField from './fields/InputField';
|
||||
import OutputField from './fields/OutputField';
|
||||
import { InputFieldEditModeNodes } from './fields/InputFieldEditModeNodes';
|
||||
import InvocationNodeFooter from './InvocationNodeFooter';
|
||||
import InvocationNodeHeader from './InvocationNodeHeader';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
type: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
|
||||
const fieldNames = useFieldNames(nodeId);
|
||||
const sx: SystemStyleObject = {
|
||||
flexDirection: 'column',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
py: 2,
|
||||
gap: 1,
|
||||
borderBottomRadius: 'base',
|
||||
'&[data-with-footer="true"]': {
|
||||
borderBottomRadius: 0,
|
||||
},
|
||||
'&[data-with-footer="false"]': {
|
||||
pb: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const InvocationNode = ({ nodeId, isOpen }: Props) => {
|
||||
const withFooter = useWithFooter(nodeId);
|
||||
const outputFieldNames = useOutputFieldNames(nodeId);
|
||||
|
||||
return (
|
||||
<NodeWrapper nodeId={nodeId} selected={selected}>
|
||||
<InvocationNodeHeader nodeId={nodeId} isOpen={isOpen} label={label} selected={selected} type={type} />
|
||||
<>
|
||||
<InvocationNodeHeader nodeId={nodeId} isOpen={isOpen} />
|
||||
{isOpen && (
|
||||
<>
|
||||
<Flex
|
||||
layerStyle="nodeBody"
|
||||
flexDirection="column"
|
||||
w="full"
|
||||
h="full"
|
||||
py={2}
|
||||
gap={1}
|
||||
borderBottomRadius={withFooter ? 0 : 'base'}
|
||||
>
|
||||
<Flex layerStyle="nodeBody" sx={sx} data-with-footer={withFooter}>
|
||||
<Flex flexDir="column" px={2} w="full" h="full">
|
||||
<Grid gridTemplateColumns="1fr auto" gridAutoRows="1fr">
|
||||
{fieldNames.connectionFields.map((fieldName, i) => (
|
||||
<GridItem gridColumnStart={1} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.input-field`}>
|
||||
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
|
||||
<InputField nodeId={nodeId} fieldName={fieldName} />
|
||||
</InvocationInputFieldCheck>
|
||||
</GridItem>
|
||||
))}
|
||||
{outputFieldNames.map((fieldName, i) => (
|
||||
<GridItem gridColumnStart={2} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.output-field`}>
|
||||
<OutputField nodeId={nodeId} fieldName={fieldName} />
|
||||
</GridItem>
|
||||
))}
|
||||
<ConnectionFields nodeId={nodeId} />
|
||||
<OutputFields nodeId={nodeId} />
|
||||
</Grid>
|
||||
{fieldNames.anyOrDirectFields.map((fieldName) => (
|
||||
<InvocationInputFieldCheck
|
||||
key={`${nodeId}.${fieldName}.input-field`}
|
||||
nodeId={nodeId}
|
||||
fieldName={fieldName}
|
||||
>
|
||||
<InputField nodeId={nodeId} fieldName={fieldName} />
|
||||
</InvocationInputFieldCheck>
|
||||
))}
|
||||
{fieldNames.missingFields.map((fieldName) => (
|
||||
<InvocationInputFieldCheck
|
||||
key={`${nodeId}.${fieldName}.input-field`}
|
||||
nodeId={nodeId}
|
||||
fieldName={fieldName}
|
||||
>
|
||||
<InputField nodeId={nodeId} fieldName={fieldName} />
|
||||
</InvocationInputFieldCheck>
|
||||
))}
|
||||
<AnyOrDirectFields nodeId={nodeId} />
|
||||
<MissingFields nodeId={nodeId} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
{withFooter && <InvocationNodeFooter nodeId={nodeId} />}
|
||||
</>
|
||||
)}
|
||||
</NodeWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(InvocationNode);
|
||||
|
||||
const ConnectionFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useInputFieldNamesConnection(nodeId);
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName, i) => (
|
||||
<GridItem gridColumnStart={1} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.input-field`}>
|
||||
<InputFieldGate nodeId={nodeId} fieldName={fieldName}>
|
||||
<InputFieldEditModeNodes nodeId={nodeId} fieldName={fieldName} />
|
||||
</InputFieldGate>
|
||||
</GridItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
ConnectionFields.displayName = 'ConnectionFields';
|
||||
|
||||
const AnyOrDirectFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useInputFieldNamesAnyOrDirect(nodeId);
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
<InputFieldGate key={`${nodeId}.${fieldName}.input-field`} nodeId={nodeId} fieldName={fieldName}>
|
||||
<InputFieldEditModeNodes nodeId={nodeId} fieldName={fieldName} />
|
||||
</InputFieldGate>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
AnyOrDirectFields.displayName = 'AnyOrDirectFields';
|
||||
|
||||
const MissingFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useInputFieldNamesMissing(nodeId);
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
<InputFieldGate key={`${nodeId}.${fieldName}.input-field`} nodeId={nodeId} fieldName={fieldName}>
|
||||
<InputFieldEditModeNodes nodeId={nodeId} fieldName={fieldName} />
|
||||
</InputFieldGate>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
MissingFields.displayName = 'MissingFields';
|
||||
|
||||
const OutputFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useOutputFieldNames(nodeId);
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName, i) => (
|
||||
<GridItem gridColumnStart={2} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.output-field`}>
|
||||
<OutputFieldGate nodeId={nodeId} fieldName={fieldName}>
|
||||
<OutputFieldNodesEditorView nodeId={nodeId} fieldName={fieldName} />
|
||||
</OutputFieldGate>
|
||||
</GridItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
OutputFields.displayName = 'OutputFields';
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
import { useChakraThemeTokens } from 'common/hooks/useChakraThemeTokens';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
|
||||
import { map } from 'lodash-es';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position } from 'reactflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const hiddenHandleStyles: CSSProperties = { visibility: 'hidden' };
|
||||
const collapsedHandleStyles: CSSProperties = {
|
||||
borderWidth: 0,
|
||||
borderRadius: '3px',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
backgroundColor: 'var(--invoke-colors-base-600)',
|
||||
zIndex: -1,
|
||||
};
|
||||
|
||||
const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
|
||||
const template = useNodeTemplate(nodeId);
|
||||
const { base600 } = useChakraThemeTokens();
|
||||
|
||||
const dummyHandleStyles: CSSProperties = useMemo(
|
||||
() => ({
|
||||
borderWidth: 0,
|
||||
borderRadius: '3px',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
backgroundColor: base600,
|
||||
zIndex: -1,
|
||||
}),
|
||||
[base600]
|
||||
);
|
||||
|
||||
const collapsedTargetStyles: CSSProperties = useMemo(
|
||||
() => ({ ...dummyHandleStyles, left: '-0.5rem' }),
|
||||
[dummyHandleStyles]
|
||||
);
|
||||
const collapsedSourceStyles: CSSProperties = useMemo(
|
||||
() => ({ ...dummyHandleStyles, right: '-0.5rem' }),
|
||||
[dummyHandleStyles]
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
@@ -47,7 +32,7 @@ const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
|
||||
id={`${nodeId}-collapsed-target`}
|
||||
isConnectable={false}
|
||||
position={Position.Left}
|
||||
style={collapsedTargetStyles}
|
||||
style={collapsedHandleStyles}
|
||||
/>
|
||||
{map(template.inputs, (input) => (
|
||||
<Handle
|
||||
@@ -64,7 +49,7 @@ const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
|
||||
id={`${nodeId}-collapsed-source`}
|
||||
isConnectable={false}
|
||||
position={Position.Right}
|
||||
style={collapsedSourceStyles}
|
||||
style={collapsedHandleStyles}
|
||||
/>
|
||||
{map(template.outputs, (output) => (
|
||||
<Handle
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChakraProps } from '@invoke-ai/ui-library';
|
||||
import { Flex, FormControlGroup } from '@invoke-ai/ui-library';
|
||||
import { useHasImageOutput } from 'features/nodes/hooks/useHasImageOutput';
|
||||
import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode';
|
||||
import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
|
||||
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { memo } from 'react';
|
||||
@@ -15,7 +16,8 @@ type Props = {
|
||||
const props: ChakraProps = { w: 'unset' };
|
||||
|
||||
const InvocationNodeFooter = ({ nodeId }: Props) => {
|
||||
const hasImageOutput = useHasImageOutput(nodeId);
|
||||
const hasImageOutput = useNodeHasImageOutput(nodeId);
|
||||
const isExecutableNode = useIsExecutableNode(nodeId);
|
||||
const isCacheEnabled = useFeatureStatus('invocationCache');
|
||||
return (
|
||||
<Flex
|
||||
@@ -30,8 +32,8 @@ const InvocationNodeFooter = ({ nodeId }: Props) => {
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<FormControlGroup formControlProps={props} formLabelProps={props}>
|
||||
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
|
||||
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
|
||||
{isExecutableNode && isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
|
||||
{isExecutableNode && hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
|
||||
</FormControlGroup>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
|
||||
import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle';
|
||||
import InvocationNodeClassificationIcon from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeClassificationIcon';
|
||||
import { useNodeIsInvalid } from 'features/nodes/hooks/useNodeIsInvalid';
|
||||
import { memo } from 'react';
|
||||
|
||||
import InvocationNodeCollapsedHandles from './InvocationNodeCollapsedHandles';
|
||||
import InvocationNodeInfoIcon from './InvocationNodeInfoIcon';
|
||||
import { InvocationNodeInfoIcon } from './InvocationNodeInfoIcon';
|
||||
import InvocationNodeStatusIndicator from './InvocationNodeStatusIndicator';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
type: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
borderTopRadius: 'base',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
h: 8,
|
||||
textAlign: 'center',
|
||||
color: 'base.200',
|
||||
borderBottomRadius: 'base',
|
||||
'&[data-is-open="true"]': {
|
||||
borderBottomRadius: 0,
|
||||
},
|
||||
'&[data-is-invalid="true"]': {
|
||||
color: 'error.300',
|
||||
},
|
||||
};
|
||||
|
||||
const InvocationNodeHeader = ({ nodeId, isOpen }: Props) => {
|
||||
const isInvalid = useNodeIsInvalid(nodeId);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
layerStyle="nodeHeader"
|
||||
borderTopRadius="base"
|
||||
borderBottomRadius={isOpen ? 0 : 'base'}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
h={8}
|
||||
textAlign="center"
|
||||
color="base.200"
|
||||
>
|
||||
<Flex layerStyle="nodeHeader" sx={sx} data-is-open={isOpen} data-is-invalid={isInvalid}>
|
||||
<NodeCollapseButton nodeId={nodeId} isOpen={isOpen} />
|
||||
<InvocationNodeClassificationIcon nodeId={nodeId} />
|
||||
<NodeTitle nodeId={nodeId} />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { compare } from 'compare-versions';
|
||||
import { useNode } from 'features/nodes/hooks/useNode';
|
||||
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
|
||||
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
|
||||
import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes';
|
||||
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiInfoBold } from 'react-icons/pi';
|
||||
@@ -12,7 +13,7 @@ interface Props {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const InvocationNodeInfoIcon = ({ nodeId }: Props) => {
|
||||
export const InvocationNodeInfoIcon = memo(({ nodeId }: Props) => {
|
||||
const needsUpdate = useNodeNeedsUpdate(nodeId);
|
||||
|
||||
return (
|
||||
@@ -20,96 +21,66 @@ const InvocationNodeInfoIcon = ({ nodeId }: Props) => {
|
||||
<Icon as={PiInfoBold} display="block" boxSize={4} w={8} color={needsUpdate ? 'error.400' : 'base.400'} />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default memo(InvocationNodeInfoIcon);
|
||||
InvocationNodeInfoIcon.displayName = 'InvocationNodeInfoIcon';
|
||||
|
||||
const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const node = useNode(nodeId);
|
||||
const notes = useInvocationNodeNotes(nodeId);
|
||||
const label = useNodeLabel(nodeId);
|
||||
const version = useNodeVersion(nodeId);
|
||||
const nodeTemplate = useNodeTemplate(nodeId);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (node.data?.label && nodeTemplate?.title) {
|
||||
return `${node.data.label} (${nodeTemplate.title})`;
|
||||
if (label) {
|
||||
return `${label} (${nodeTemplate.title})`;
|
||||
}
|
||||
|
||||
if (node.data?.label && !nodeTemplate) {
|
||||
return node.data.label;
|
||||
}
|
||||
|
||||
if (!node.data?.label && nodeTemplate) {
|
||||
return nodeTemplate.title;
|
||||
}
|
||||
|
||||
return t('nodes.unknownNode');
|
||||
}, [node.data.label, nodeTemplate, t]);
|
||||
|
||||
const versionComponent = useMemo(() => {
|
||||
if (!isInvocationNode(node) || !nodeTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!node.data.version) {
|
||||
return (
|
||||
<Text as="span" color="error.500">
|
||||
{t('nodes.versionUnknown')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!nodeTemplate.version) {
|
||||
return (
|
||||
<Text as="span" color="error.500">
|
||||
{t('nodes.version')} {node.data.version} ({t('nodes.unknownTemplate')})
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (compare(node.data.version, nodeTemplate.version, '<')) {
|
||||
return (
|
||||
<Text as="span" color="error.500">
|
||||
{t('nodes.version')} {node.data.version} ({t('nodes.updateNode')})
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (compare(node.data.version, nodeTemplate.version, '>')) {
|
||||
return (
|
||||
<Text as="span" color="error.500">
|
||||
{t('nodes.version')} {node.data.version} ({t('nodes.updateApp')})
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text as="span">
|
||||
{t('nodes.version')} {node.data.version}
|
||||
</Text>
|
||||
);
|
||||
}, [node, nodeTemplate, t]);
|
||||
|
||||
if (!isInvocationNode(node)) {
|
||||
return <Text fontWeight="semibold">{t('nodes.unknownNode')}</Text>;
|
||||
}
|
||||
return nodeTemplate.title;
|
||||
}, [label, nodeTemplate.title]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Text as="span" fontWeight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
{nodeTemplate?.nodePack && (
|
||||
<Text opacity={0.7}>
|
||||
{t('nodes.nodePack')}: {nodeTemplate.nodePack}
|
||||
</Text>
|
||||
)}
|
||||
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||
{nodeTemplate?.description}
|
||||
<Text opacity={0.7}>
|
||||
{t('nodes.nodePack')}: {nodeTemplate.nodePack}
|
||||
</Text>
|
||||
{versionComponent}
|
||||
{node.data?.notes && <Text>{node.data.notes}</Text>}
|
||||
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||
{nodeTemplate.description}
|
||||
</Text>
|
||||
<Version nodeVersion={version} templateVersion={nodeTemplate.version} />
|
||||
{notes && <Text>{notes}</Text>}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipContent.displayName = 'TooltipContent';
|
||||
|
||||
const Version = ({ nodeVersion, templateVersion }: { nodeVersion: string; templateVersion: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (compare(nodeVersion, templateVersion, '<')) {
|
||||
return (
|
||||
<Text as="span" color="error.500">
|
||||
{t('nodes.version')} {nodeVersion} ({t('nodes.updateNode')})
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (compare(nodeVersion, templateVersion, '>')) {
|
||||
return (
|
||||
<Text as="span" color="error.500">
|
||||
{t('nodes.version')} {nodeVersion} ({t('nodes.updateApp')})
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text as="span">
|
||||
{t('nodes.version')} {nodeVersion}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { FormControl, FormLabel, Textarea } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useNode } from 'features/nodes/hooks/useNode';
|
||||
import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes';
|
||||
import { nodeNotesChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NotesTextarea = ({ nodeId }: { nodeId: string }) => {
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
};
|
||||
|
||||
export const InvocationNodeNotesTextarea = memo(({ nodeId }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const node = useNode(nodeId);
|
||||
const { t } = useTranslation();
|
||||
const notes = useInvocationNodeNotes(nodeId);
|
||||
const handleNotesChanged = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
dispatch(nodeNotesChanged({ nodeId, notes: e.target.value }));
|
||||
},
|
||||
[dispatch, nodeId]
|
||||
);
|
||||
if (!isInvocationNode(node)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FormControl orientation="vertical" h="full">
|
||||
<FormLabel>{t('nodes.notes')}</FormLabel>
|
||||
<Textarea value={node.data?.notes} onChange={handleNotesChanged} rows={10} resize="none" />
|
||||
<Textarea value={notes} onChange={handleNotesChanged} rows={10} resize="none" variant="darkFilled" />
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default memo(NotesTextarea);
|
||||
InvocationNodeNotesTextarea.displayName = 'InvocationNodeNotesTextarea';
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Badge, CircularProgress, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useExecutionState } from 'features/nodes/hooks/useExecutionState';
|
||||
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
|
||||
import type { NodeExecutionState } from 'features/nodes/types/invocation';
|
||||
import { zNodeStatus } from 'features/nodes/types/invocation';
|
||||
@@ -22,7 +22,7 @@ const circleStyles: SystemStyleObject = {
|
||||
};
|
||||
|
||||
const InvocationNodeStatusIndicator = ({ nodeId }: Props) => {
|
||||
const nodeExecutionState = useExecutionState(nodeId);
|
||||
const nodeExecutionState = useNodeExecutionState(nodeId);
|
||||
|
||||
if (!nodeExecutionState) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import { useNodePack } from 'features/nodes/hooks/useNodePack';
|
||||
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
|
||||
import { memo } from 'react';
|
||||
@@ -11,14 +10,13 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
type: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
const InvocationNodeUnknownFallback = ({ nodeId, isOpen, label, type, selected }: Props) => {
|
||||
const InvocationNodeUnknownFallback = ({ nodeId, isOpen, label, type }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const nodePack = useNodePack(nodeId);
|
||||
return (
|
||||
<NodeWrapper nodeId={nodeId} selected={selected}>
|
||||
<>
|
||||
<Flex
|
||||
className={DRAG_HANDLE_CLASSNAME}
|
||||
layerStyle="nodeHeader"
|
||||
@@ -64,7 +62,7 @@ const InvocationNodeUnknownFallback = ({ nodeId, isOpen, label, type, selected }
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</NodeWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { Node, NodeProps } from '@xyflow/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import InvocationNode from 'features/nodes/components/flow/nodes/Invocation/InvocationNode';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodes } from 'features/nodes/store/selectors';
|
||||
import type { InvocationNodeData } from 'features/nodes/types/invocation';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { NodeProps } from 'reactflow';
|
||||
|
||||
import InvocationNodeUnknownFallback from './InvocationNodeUnknownFallback';
|
||||
|
||||
const InvocationNodeWrapper = (props: NodeProps<InvocationNodeData>) => {
|
||||
const InvocationNodeWrapper = (props: NodeProps<Node<InvocationNodeData>>) => {
|
||||
const { data, selected } = props;
|
||||
const { id: nodeId, type, isOpen, label } = data;
|
||||
const templates = useStore($templates);
|
||||
@@ -27,11 +28,17 @@ const InvocationNodeWrapper = (props: NodeProps<InvocationNodeData>) => {
|
||||
|
||||
if (!hasTemplate) {
|
||||
return (
|
||||
<InvocationNodeUnknownFallback nodeId={nodeId} isOpen={isOpen} label={label} type={type} selected={selected} />
|
||||
<NodeWrapper nodeId={nodeId} selected={selected}>
|
||||
<InvocationNodeUnknownFallback nodeId={nodeId} isOpen={isOpen} label={label} type={type} />
|
||||
</NodeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <InvocationNode nodeId={nodeId} isOpen={isOpen} label={label} type={type} selected={selected} />;
|
||||
return (
|
||||
<NodeWrapper nodeId={nodeId} selected={selected}>
|
||||
<InvocationNode nodeId={nodeId} isOpen={isOpen} />
|
||||
</NodeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(InvocationNodeWrapper);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useHasImageOutput } from 'features/nodes/hooks/useHasImageOutput';
|
||||
import { useIsIntermediate } from 'features/nodes/hooks/useIsIntermediate';
|
||||
import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
|
||||
import { useNodeIsIntermediate } from 'features/nodes/hooks/useNodeIsIntermediate';
|
||||
import { nodeIsIntermediateChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { NO_PAN_CLASS } from 'features/nodes/types/constants';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -10,8 +11,8 @@ import { useTranslation } from 'react-i18next';
|
||||
const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const hasImageOutput = useHasImageOutput(nodeId);
|
||||
const isIntermediate = useIsIntermediate(nodeId);
|
||||
const hasImageOutput = useNodeHasImageOutput(nodeId);
|
||||
const isIntermediate = useNodeIsIntermediate(nodeId);
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(
|
||||
@@ -29,8 +30,8 @@ const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl className="nopan">
|
||||
<FormLabel>{t('nodes.saveToGallery')} </FormLabel>
|
||||
<FormControl className={NO_PAN_CLASS}>
|
||||
<FormLabel m={0}>{t('nodes.saveToGallery')} </FormLabel>
|
||||
<Checkbox onChange={handleChange} isChecked={!isIntermediate} />
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useUseCache } from 'features/nodes/hooks/useUseCache';
|
||||
import { nodeUseCacheChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -22,9 +23,9 @@ const UseCacheCheckbox = ({ nodeId }: { nodeId: string }) => {
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel>{t('invocationCache.useCache')}</FormLabel>
|
||||
<Checkbox className="nopan" onChange={handleChange} isChecked={useCache} />
|
||||
<FormControl className={NO_FIT_ON_DOUBLE_CLICK_CLASS}>
|
||||
<FormLabel m={0}>{t('invocationCache.useCache')}</FormLabel>
|
||||
<Checkbox className={NO_PAN_CLASS} onChange={handleChange} isChecked={useCache} />
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
Editable,
|
||||
EditableInput,
|
||||
EditablePreview,
|
||||
Flex,
|
||||
forwardRef,
|
||||
Tooltip,
|
||||
useEditableControls,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel';
|
||||
import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle';
|
||||
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FieldTooltipContent from './FieldTooltipContent';
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
kind: 'inputs' | 'outputs';
|
||||
isInvalid?: boolean;
|
||||
withTooltip?: boolean;
|
||||
shouldDim?: boolean;
|
||||
}
|
||||
|
||||
const EditableFieldTitle = forwardRef((props: Props, ref) => {
|
||||
const { nodeId, fieldName, kind, isInvalid = false, withTooltip = false, shouldDim = false } = props;
|
||||
const label = useFieldLabel(nodeId, fieldName);
|
||||
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const [localTitle, setLocalTitle] = useState(label || fieldTemplateTitle || t('nodes.unknownField'));
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(newTitleRaw: string) => {
|
||||
const newTitle = newTitleRaw.trim();
|
||||
const finalTitle = newTitle || fieldTemplateTitle || t('nodes.unknownField');
|
||||
setLocalTitle(finalTitle);
|
||||
dispatch(fieldLabelChanged({ nodeId, fieldName, label: finalTitle }));
|
||||
},
|
||||
[fieldTemplateTitle, dispatch, nodeId, fieldName, t]
|
||||
);
|
||||
|
||||
const handleChange = useCallback((newTitle: string) => {
|
||||
setLocalTitle(newTitle);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Another component may change the title; sync local title with global state
|
||||
setLocalTitle(label || fieldTemplateTitle || t('nodes.unknownField'));
|
||||
}, [label, fieldTemplateTitle, t]);
|
||||
|
||||
return (
|
||||
<Editable
|
||||
value={localTitle}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
as={Flex}
|
||||
ref={ref}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
gap={1}
|
||||
w="full"
|
||||
>
|
||||
<Tooltip
|
||||
label={withTooltip ? <FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="inputs" /> : undefined}
|
||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||
>
|
||||
<EditablePreview
|
||||
fontWeight="semibold"
|
||||
sx={editablePreviewStyles}
|
||||
noOfLines={1}
|
||||
color={isInvalid ? 'error.300' : 'base.300'}
|
||||
opacity={shouldDim ? 0.5 : 1}
|
||||
/>
|
||||
</Tooltip>
|
||||
<EditableInput className="nodrag" sx={editableInputStyles} />
|
||||
<EditableControls />
|
||||
</Editable>
|
||||
);
|
||||
});
|
||||
|
||||
const editableInputStyles: SystemStyleObject = {
|
||||
p: 0,
|
||||
w: 'full',
|
||||
fontWeight: 'semibold',
|
||||
color: 'base.100',
|
||||
_focusVisible: {
|
||||
p: 0,
|
||||
textAlign: 'left',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
};
|
||||
const editablePreviewStyles: SystemStyleObject = {
|
||||
p: 0,
|
||||
textAlign: 'left',
|
||||
_hover: {
|
||||
fontWeight: 'semibold !important',
|
||||
},
|
||||
};
|
||||
|
||||
export default memo(EditableFieldTitle);
|
||||
|
||||
const EditableControls = memo(() => {
|
||||
const { isEditing, getEditButtonProps } = useEditableControls();
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
const { onClick } = getEditButtonProps();
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
onClick(e);
|
||||
e.preventDefault();
|
||||
},
|
||||
[getEditButtonProps]
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
onClick={handleClick}
|
||||
position="absolute"
|
||||
w="min-content"
|
||||
h="full"
|
||||
top={0}
|
||||
insetInlineStart={0}
|
||||
cursor="text"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
EditableControls.displayName = 'EditableControls';
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Tooltip } from '@invoke-ai/ui-library';
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
|
||||
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
|
||||
import type { ValidationResult } from 'features/nodes/store/util/validateConnection';
|
||||
import { HANDLE_TOOLTIP_OPEN_DELAY, MODEL_TYPES } from 'features/nodes/types/constants';
|
||||
import { type FieldInputTemplate, type FieldOutputTemplate, isSingle } from 'features/nodes/types/field';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HandleType } from 'reactflow';
|
||||
import { Handle, Position } from 'reactflow';
|
||||
|
||||
type FieldHandleProps = {
|
||||
fieldTemplate: FieldInputTemplate | FieldOutputTemplate;
|
||||
handleType: HandleType;
|
||||
isConnectionInProgress: boolean;
|
||||
isConnectionStartField: boolean;
|
||||
validationResult: ValidationResult;
|
||||
};
|
||||
|
||||
const FieldHandle = (props: FieldHandleProps) => {
|
||||
const { fieldTemplate, handleType, isConnectionInProgress, isConnectionStartField, validationResult } = props;
|
||||
const { t } = useTranslation();
|
||||
const { name } = fieldTemplate;
|
||||
const type = fieldTemplate.type;
|
||||
const fieldTypeName = useFieldTypeName(type);
|
||||
const styles: CSSProperties = useMemo(() => {
|
||||
const isModelType = MODEL_TYPES.some((t) => t === type.name);
|
||||
const color = getFieldColor(type);
|
||||
const s: CSSProperties = {
|
||||
backgroundColor: !isSingle(type) ? colorTokenToCssVar('base.900') : color,
|
||||
position: 'absolute',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
borderWidth: !isSingle(type) ? 4 : 0,
|
||||
borderStyle: 'solid',
|
||||
borderColor: color,
|
||||
borderRadius: isModelType || type.batch ? 4 : '100%',
|
||||
zIndex: 1,
|
||||
transformOrigin: 'center',
|
||||
};
|
||||
|
||||
if (type.batch) {
|
||||
s.transform = 'rotate(45deg) translateX(-0.3rem) translateY(-0.3rem)';
|
||||
}
|
||||
|
||||
if (handleType === 'target') {
|
||||
s.insetInlineStart = '-1rem';
|
||||
} else {
|
||||
s.insetInlineEnd = '-1rem';
|
||||
}
|
||||
|
||||
if (isConnectionInProgress && !isConnectionStartField && !validationResult.isValid) {
|
||||
s.filter = 'opacity(0.4) grayscale(0.7)';
|
||||
}
|
||||
|
||||
if (isConnectionInProgress && !validationResult.isValid) {
|
||||
if (isConnectionStartField) {
|
||||
s.cursor = 'grab';
|
||||
} else {
|
||||
s.cursor = 'not-allowed';
|
||||
}
|
||||
} else {
|
||||
s.cursor = 'crosshair';
|
||||
}
|
||||
|
||||
return s;
|
||||
}, [handleType, isConnectionInProgress, isConnectionStartField, type, validationResult.isValid]);
|
||||
|
||||
const tooltip = useMemo(() => {
|
||||
if (isConnectionInProgress && validationResult.messageTKey) {
|
||||
return t(validationResult.messageTKey);
|
||||
}
|
||||
return fieldTypeName;
|
||||
}, [fieldTypeName, isConnectionInProgress, t, validationResult.messageTKey]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={tooltip}
|
||||
placement={handleType === 'target' ? 'start' : 'end'}
|
||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||
>
|
||||
<Handle
|
||||
type={handleType}
|
||||
id={name}
|
||||
position={handleType === 'target' ? Position.Left : Position.Right}
|
||||
style={styles}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(FieldHandle);
|
||||
@@ -1,68 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
|
||||
import {
|
||||
selectWorkflowSlice,
|
||||
workflowExposedFieldAdded,
|
||||
workflowExposedFieldRemoved,
|
||||
} from 'features/nodes/store/workflowSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiMinusBold, PiPlusBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const value = useFieldValue(nodeId, fieldName);
|
||||
const selectIsExposed = useMemo(
|
||||
() =>
|
||||
createSelector(selectWorkflowSlice, (workflow) => {
|
||||
return Boolean(workflow.exposedFields.find((f) => f.nodeId === nodeId && f.fieldName === fieldName));
|
||||
}),
|
||||
[fieldName, nodeId]
|
||||
);
|
||||
|
||||
const isExposed = useAppSelector(selectIsExposed);
|
||||
|
||||
const handleExposeField = useCallback(() => {
|
||||
dispatch(workflowExposedFieldAdded({ nodeId, fieldName, value }));
|
||||
}, [dispatch, fieldName, nodeId, value]);
|
||||
|
||||
const handleUnexposeField = useCallback(() => {
|
||||
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
||||
}, [dispatch, fieldName, nodeId]);
|
||||
|
||||
if (!isExposed) {
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tooltip={t('nodes.addLinearView')}
|
||||
aria-label={t('nodes.addLinearView')}
|
||||
icon={<PiPlusBold />}
|
||||
onClick={handleExposeField}
|
||||
pointerEvents="auto"
|
||||
size="xs"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tooltip={t('nodes.removeLinearView')}
|
||||
aria-label={t('nodes.removeLinearView')}
|
||||
icon={<PiMinusBold />}
|
||||
onClick={handleUnexposeField}
|
||||
pointerEvents="auto"
|
||||
size="xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(FieldLinearViewToggle);
|
||||
@@ -1,42 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
|
||||
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
|
||||
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
const FieldResetToDefaultValueButton = ({ nodeId, fieldName }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const value = useFieldValue(nodeId, fieldName);
|
||||
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
|
||||
const isDisabled = useMemo(() => {
|
||||
return isEqual(value, fieldTemplate.default);
|
||||
}, [value, fieldTemplate.default]);
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(fieldValueReset({ nodeId, fieldName, value: fieldTemplate.default }));
|
||||
}, [dispatch, fieldName, fieldTemplate.default, nodeId]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tooltip={t('nodes.resetToDefaultValue')}
|
||||
aria-label={t('nodes.resetToDefaultValue')}
|
||||
icon={<PiArrowCounterClockwiseBold />}
|
||||
onClick={onClick}
|
||||
isDisabled={isDisabled}
|
||||
pointerEvents="auto"
|
||||
size="xs"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(FieldResetToDefaultValueButton);
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useFieldInputInstance } from 'features/nodes/hooks/useFieldInputInstance';
|
||||
import { useFieldTemplate } from 'features/nodes/hooks/useFieldTemplate';
|
||||
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
|
||||
import { isFieldInputInstance, isFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { startCase } from 'lodash-es';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
kind: 'inputs' | 'outputs';
|
||||
}
|
||||
|
||||
const FieldTooltipContent = ({ nodeId, fieldName, kind }: Props) => {
|
||||
const field = useFieldInputInstance(nodeId, fieldName);
|
||||
const fieldTemplate = useFieldTemplate(nodeId, fieldName, kind);
|
||||
const isInputTemplate = isFieldInputTemplate(fieldTemplate);
|
||||
const fieldTypeName = useFieldTypeName(fieldTemplate?.type);
|
||||
const { t } = useTranslation();
|
||||
const fieldTitle = useMemo(() => {
|
||||
if (isFieldInputInstance(field)) {
|
||||
if (field.label && fieldTemplate?.title) {
|
||||
return `${field.label} (${fieldTemplate.title})`;
|
||||
}
|
||||
|
||||
if (field.label && !fieldTemplate) {
|
||||
return field.label;
|
||||
}
|
||||
|
||||
if (!field.label && fieldTemplate) {
|
||||
return fieldTemplate.title;
|
||||
}
|
||||
|
||||
return t('nodes.unknownField');
|
||||
} else {
|
||||
return fieldTemplate?.title || t('nodes.unknownField');
|
||||
}
|
||||
}, [field, fieldTemplate, t]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Text fontWeight="semibold">{fieldTitle}</Text>
|
||||
{fieldTemplate && (
|
||||
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||
{fieldTemplate.description}
|
||||
</Text>
|
||||
)}
|
||||
{fieldTypeName && (
|
||||
<Text>
|
||||
{t('parameters.type')}: {fieldTypeName}
|
||||
</Text>
|
||||
)}
|
||||
{isInputTemplate && (
|
||||
<Text>
|
||||
{t('common.input')}: {startCase(fieldTemplate.input)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(FieldTooltipContent);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CompositeNumberInput } from '@invoke-ai/ui-library';
|
||||
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
|
||||
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
|
||||
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
|
||||
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const FloatFieldInput = memo((props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
|
||||
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
|
||||
|
||||
return (
|
||||
<CompositeNumberInput
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className={NO_DRAG_CLASS}
|
||||
flex="1 1 0"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
FloatFieldInput.displayName = 'FloatFieldInput ';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CompositeNumberInput, CompositeSlider } from '@invoke-ai/ui-library';
|
||||
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
|
||||
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
|
||||
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
|
||||
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const FloatFieldInputAndSlider = memo(
|
||||
(props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
|
||||
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompositeSlider
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className={NO_DRAG_CLASS}
|
||||
marks
|
||||
withThumbTooltip
|
||||
flex="1 1 0"
|
||||
/>
|
||||
<CompositeNumberInput
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className={NO_DRAG_CLASS}
|
||||
flex="1 1 0"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FloatFieldInputAndSlider.displayName = 'FloatFieldInputAndSlider ';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CompositeSlider } from '@invoke-ai/ui-library';
|
||||
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
|
||||
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
|
||||
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
|
||||
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const FloatFieldSlider = memo((props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
|
||||
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
|
||||
|
||||
return (
|
||||
<CompositeSlider
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className={NO_DRAG_CLASS}
|
||||
marks
|
||||
withThumbTooltip
|
||||
flex="1 1 0"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
FloatFieldSlider.displayName = 'FloatFieldSlider ';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NUMPY_RAND_MAX } from 'app/constants';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
|
||||
import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export const useFloatField = (props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
|
||||
const { nodeId, field, fieldTemplate } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: number) => {
|
||||
dispatch(fieldFloatValueChanged({ nodeId, fieldName: field.name, value }));
|
||||
},
|
||||
[dispatch, field.name, nodeId]
|
||||
);
|
||||
|
||||
const min = useMemo(() => {
|
||||
let min = -NUMPY_RAND_MAX;
|
||||
if (!isNil(fieldTemplate.minimum)) {
|
||||
min = fieldTemplate.minimum;
|
||||
}
|
||||
if (!isNil(fieldTemplate.exclusiveMinimum)) {
|
||||
min = fieldTemplate.exclusiveMinimum + 0.01;
|
||||
}
|
||||
return min;
|
||||
}, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum]);
|
||||
|
||||
const max = useMemo(() => {
|
||||
let max = NUMPY_RAND_MAX;
|
||||
if (!isNil(fieldTemplate.maximum)) {
|
||||
max = fieldTemplate.maximum;
|
||||
}
|
||||
if (!isNil(fieldTemplate.exclusiveMaximum)) {
|
||||
max = fieldTemplate.exclusiveMaximum - 0.01;
|
||||
}
|
||||
return max;
|
||||
}, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum]);
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (isNil(fieldTemplate.multipleOf)) {
|
||||
return 0.1;
|
||||
}
|
||||
return fieldTemplate.multipleOf;
|
||||
}, [fieldTemplate.multipleOf]);
|
||||
|
||||
const fineStep = useMemo(() => {
|
||||
if (isNil(fieldTemplate.multipleOf)) {
|
||||
return 0.01;
|
||||
}
|
||||
return fieldTemplate.multipleOf;
|
||||
}, [fieldTemplate.multipleOf]);
|
||||
|
||||
return {
|
||||
defaultValue: fieldTemplate.default,
|
||||
onChange,
|
||||
value: field.value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
fineStep,
|
||||
};
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Flex, FormControl } from '@invoke-ai/ui-library';
|
||||
import FieldResetToDefaultValueButton from 'features/nodes/components/flow/nodes/Invocation/fields/FieldResetToDefaultValueButton';
|
||||
import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
|
||||
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
|
||||
import { useFieldIsInvalid } from 'features/nodes/hooks/useFieldIsInvalid';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
|
||||
import EditableFieldTitle from './EditableFieldTitle';
|
||||
import FieldHandle from './FieldHandle';
|
||||
import FieldLinearViewToggle from './FieldLinearViewToggle';
|
||||
import InputFieldRenderer from './InputFieldRenderer';
|
||||
import { InputFieldWrapper } from './InputFieldWrapper';
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
const InputField = ({ nodeId, fieldName }: Props) => {
|
||||
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isInvalid = useFieldIsInvalid(nodeId, fieldName);
|
||||
|
||||
const { isConnected, isConnectionInProgress, isConnectionStartField, validationResult, shouldDim } =
|
||||
useConnectionState({ nodeId, fieldName, kind: 'inputs' });
|
||||
|
||||
const onMouseEnter = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
if (fieldTemplate.input === 'connection' || isConnected) {
|
||||
return (
|
||||
<InputFieldWrapper shouldDim={shouldDim}>
|
||||
<FormControl isInvalid={isInvalid} isDisabled={isConnected} px={2}>
|
||||
<EditableFieldTitle
|
||||
nodeId={nodeId}
|
||||
fieldName={fieldName}
|
||||
kind="inputs"
|
||||
isInvalid={isInvalid}
|
||||
withTooltip
|
||||
shouldDim
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FieldHandle
|
||||
fieldTemplate={fieldTemplate}
|
||||
handleType="target"
|
||||
isConnectionInProgress={isConnectionInProgress}
|
||||
isConnectionStartField={isConnectionStartField}
|
||||
validationResult={validationResult}
|
||||
/>
|
||||
</InputFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputFieldWrapper shouldDim={shouldDim}>
|
||||
<FormControl
|
||||
isInvalid={isInvalid}
|
||||
isDisabled={isConnected}
|
||||
// Without pointerEvents prop, disabled inputs don't trigger reactflow events. For example, when making a
|
||||
// connection, the mouse up to end the connection won't fire, leaving the connection in-progress.
|
||||
pointerEvents={isConnected ? 'none' : 'auto'}
|
||||
orientation="vertical"
|
||||
px={2}
|
||||
>
|
||||
<Flex flexDir="column" w="full" gap={1} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
<Flex gap={1}>
|
||||
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="inputs" isInvalid={isInvalid} withTooltip />
|
||||
{isHovered && <FieldResetToDefaultValueButton nodeId={nodeId} fieldName={fieldName} />}
|
||||
{isHovered && <FieldLinearViewToggle nodeId={nodeId} fieldName={fieldName} />}
|
||||
</Flex>
|
||||
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
{fieldTemplate.input !== 'direct' && (
|
||||
<FieldHandle
|
||||
fieldTemplate={fieldTemplate}
|
||||
handleType="target"
|
||||
isConnectionInProgress={isConnectionInProgress}
|
||||
isConnectionStartField={isConnectionStartField}
|
||||
validationResult={validationResult}
|
||||
/>
|
||||
)}
|
||||
</InputFieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(InputField);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAddNodeFieldToRoot } from 'features/nodes/components/sidePanel/builder/use-add-node-field-to-root';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export const InputFieldAddToFormRoot = memo(({ nodeId, fieldName }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const addToRoot = useAddNodeFieldToRoot(nodeId, fieldName);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tooltip={t('workflows.builder.addToForm')}
|
||||
aria-label={t('workflows.builder.addToForm')}
|
||||
icon={<PiPlusBold />}
|
||||
pointerEvents="auto"
|
||||
size="xs"
|
||||
onClick={addToRoot}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldAddToFormRoot.displayName = 'InputFieldAddToFormRoot';
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Textarea,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
|
||||
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiNoteBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export const InputFieldDescriptionPopover = memo(({ nodeId, fieldName }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover isLazy lazyBehavior="unmount">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tooltip={t('nodes.description')}
|
||||
aria-label={t('nodes.description')}
|
||||
icon={<PiNoteBold />}
|
||||
pointerEvents="auto"
|
||||
size="xs"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent p={2} w={256}>
|
||||
<Content nodeId={nodeId} fieldName={fieldName} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover';
|
||||
|
||||
const Content = memo(({ nodeId, fieldName }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const description = useInputFieldDescription(nodeId, fieldName);
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value }));
|
||||
},
|
||||
[dispatch, fieldName, nodeId]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl orientation="vertical">
|
||||
<FormLabel>{t('nodes.description')}</FormLabel>
|
||||
<Textarea
|
||||
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS} ${NO_WHEEL_CLASS}`}
|
||||
fontSize="sm"
|
||||
value={description ?? ''}
|
||||
onChange={onChange}
|
||||
p={2}
|
||||
resize="none"
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
Content.displayName = 'Content';
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { InputFieldAddToFormRoot } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldAddToFormRoot';
|
||||
import { InputFieldDescriptionPopover } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover';
|
||||
import { InputFieldHandle } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle';
|
||||
import { InputFieldResetToDefaultValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldResetToDefaultValueIconButton';
|
||||
import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
|
||||
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
|
||||
import type { FieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
import { InputFieldRenderer } from './InputFieldRenderer';
|
||||
import { InputFieldTitle } from './InputFieldTitle';
|
||||
import { InputFieldWrapper } from './InputFieldWrapper';
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export const InputFieldEditModeNodes = memo(({ nodeId, fieldName }: Props) => {
|
||||
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
|
||||
const isInvalid = useInputFieldIsInvalid(nodeId, fieldName);
|
||||
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
|
||||
|
||||
if (fieldTemplate.input === 'connection' || isConnected) {
|
||||
return (
|
||||
<ConnectedOrConnectionField
|
||||
nodeId={nodeId}
|
||||
fieldName={fieldName}
|
||||
isInvalid={isInvalid}
|
||||
isConnected={isConnected}
|
||||
fieldTemplate={fieldTemplate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DirectField
|
||||
nodeId={nodeId}
|
||||
fieldName={fieldName}
|
||||
isInvalid={isInvalid}
|
||||
isConnected={isConnected}
|
||||
fieldTemplate={fieldTemplate}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldEditModeNodes.displayName = 'InputFieldEditModeNodes';
|
||||
|
||||
type CommonProps = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
isInvalid: boolean;
|
||||
isConnected: boolean;
|
||||
fieldTemplate: FieldInputTemplate;
|
||||
};
|
||||
|
||||
const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid }: CommonProps) => {
|
||||
return (
|
||||
<InputFieldWrapper>
|
||||
<Flex px={2}>
|
||||
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} />
|
||||
</Flex>
|
||||
<InputFieldHandle nodeId={nodeId} fieldName={fieldName} />
|
||||
</InputFieldWrapper>
|
||||
);
|
||||
});
|
||||
ConnectedOrConnectionField.displayName = 'ConnectedOrConnectionField';
|
||||
|
||||
const directFieldSx: SystemStyleObject = {
|
||||
w: 'full',
|
||||
px: 2,
|
||||
flexDir: 'column',
|
||||
gap: 1,
|
||||
'&[data-is-dragging="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
// Without pointerEvents prop, disabled inputs don't trigger reactflow events. For example, when making a
|
||||
// connection, the mouse up to end the connection won't fire, leaving the connection in-progress.
|
||||
pointerEvents: 'auto',
|
||||
'&[data-is-connected="true"]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// The action buttons are hidden by default and shown on hover
|
||||
'& .direct-field-action-buttons': {
|
||||
display: 'none',
|
||||
},
|
||||
_hover: {
|
||||
'& .direct-field-action-buttons': {
|
||||
display: 'inline-flex',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemplate }: CommonProps) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef);
|
||||
|
||||
return (
|
||||
<InputFieldWrapper>
|
||||
<Flex ref={draggableRef} sx={directFieldSx} data-is-connected={isConnected} data-is-dragging={isDragging}>
|
||||
<Flex gap={1}>
|
||||
<Flex className={NO_DRAG_CLASS} ref={dragHandleRef}>
|
||||
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} isDragging={isDragging} />
|
||||
</Flex>
|
||||
<Spacer />
|
||||
<Flex className="direct-field-action-buttons">
|
||||
<InputFieldDescriptionPopover nodeId={nodeId} fieldName={fieldName} />
|
||||
<InputFieldResetToDefaultValueIconButton nodeId={nodeId} fieldName={fieldName} />
|
||||
<InputFieldAddToFormRoot nodeId={nodeId} fieldName={fieldName} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||
</Flex>
|
||||
{fieldTemplate.input !== 'direct' && <InputFieldHandle nodeId={nodeId} fieldName={fieldName} />}
|
||||
</InputFieldWrapper>
|
||||
);
|
||||
});
|
||||
DirectField.displayName = 'DirectField';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { InputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldUnknownPlaceholder';
|
||||
import { useInputFieldInstanceExists } from 'features/nodes/hooks/useInputFieldInstanceExists';
|
||||
import { useInputFieldTemplateExists } from 'features/nodes/hooks/useInputFieldTemplateExists';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
}>;
|
||||
|
||||
export const InputFieldGate = memo(({ nodeId, fieldName, children }: Props) => {
|
||||
const hasInstance = useInputFieldInstanceExists(nodeId, fieldName);
|
||||
const hasTemplate = useInputFieldTemplateExists(nodeId, fieldName);
|
||||
|
||||
if (!hasTemplate || !hasInstance) {
|
||||
return <InputFieldUnknownPlaceholder nodeId={nodeId} fieldName={fieldName} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
|
||||
InputFieldGate.displayName = 'InputFieldGate';
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Box, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
|
||||
import {
|
||||
useConnectionErrorTKey,
|
||||
useIsConnectionInProgress,
|
||||
useIsConnectionStartField,
|
||||
} from 'features/nodes/hooks/useFieldConnectionState';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
|
||||
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
||||
import type { FieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { isModelFieldType } from 'features/nodes/types/field';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
const sx = {
|
||||
position: 'relative',
|
||||
width: 'full',
|
||||
height: 'full',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 4,
|
||||
pointerEvents: 'none',
|
||||
'&[data-cardinality="SINGLE"]': {
|
||||
borderWidth: 0,
|
||||
},
|
||||
borderRadius: '100%',
|
||||
'&[data-is-model-field="true"], &[data-is-batch-field="true"]': {
|
||||
borderRadius: 4,
|
||||
},
|
||||
'&[data-is-batch-field="true"]': {
|
||||
transform: 'rotate(45deg)',
|
||||
},
|
||||
'&[data-is-connection-in-progress="true"][data-is-connection-start-field="false"][data-is-connection-valid="false"]':
|
||||
{
|
||||
filter: 'opacity(0.4) grayscale(0.7)',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
'&[data-is-connection-in-progress="true"][data-is-connection-start-field="true"][data-is-connection-valid="false"]': {
|
||||
cursor: 'grab',
|
||||
},
|
||||
'&[data-is-connection-in-progress="false"] &[data-is-connection-valid="true"]': {
|
||||
cursor: 'crosshair',
|
||||
},
|
||||
} satisfies SystemStyleObject;
|
||||
|
||||
const handleStyles = {
|
||||
position: 'absolute',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
zIndex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
insetInlineStart: '-0.5rem',
|
||||
} satisfies CSSProperties;
|
||||
|
||||
export const InputFieldHandle = memo(({ nodeId, fieldName }: Props) => {
|
||||
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
|
||||
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
|
||||
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
|
||||
const isModelField = useMemo(() => isModelFieldType(fieldTemplate.type), [fieldTemplate.type]);
|
||||
const isConnectionInProgress = useIsConnectionInProgress();
|
||||
|
||||
if (isConnectionInProgress) {
|
||||
return (
|
||||
<ConnectionInProgressHandle
|
||||
nodeId={nodeId}
|
||||
fieldName={fieldName}
|
||||
fieldTemplate={fieldTemplate}
|
||||
fieldTypeName={fieldTypeName}
|
||||
fieldColor={fieldColor}
|
||||
isModelField={isModelField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdleHandle
|
||||
nodeId={nodeId}
|
||||
fieldName={fieldName}
|
||||
fieldTemplate={fieldTemplate}
|
||||
fieldTypeName={fieldTypeName}
|
||||
fieldColor={fieldColor}
|
||||
isModelField={isModelField}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldHandle.displayName = 'InputFieldHandle';
|
||||
|
||||
type HandleCommonProps = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
fieldTemplate: FieldInputTemplate;
|
||||
fieldTypeName: string;
|
||||
fieldColor: string;
|
||||
isModelField: boolean;
|
||||
};
|
||||
|
||||
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
|
||||
return (
|
||||
<Tooltip label={fieldTypeName} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
|
||||
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
|
||||
<Box
|
||||
sx={sx}
|
||||
data-cardinality={fieldTemplate.type.cardinality}
|
||||
data-is-batch-field={fieldTemplate.type.batch}
|
||||
data-is-model-field={isModelField}
|
||||
data-is-connection-in-progress={false}
|
||||
data-is-connection-start-field={false}
|
||||
data-is-connection-valid={false}
|
||||
backgroundColor={fieldTemplate.type.cardinality === 'SINGLE' ? fieldColor : 'base.900'}
|
||||
borderColor={fieldColor}
|
||||
/>
|
||||
</Handle>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
IdleHandle.displayName = 'IdleHandle';
|
||||
|
||||
const ConnectionInProgressHandle = memo(
|
||||
({ nodeId, fieldName, fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
|
||||
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');
|
||||
|
||||
const tooltip = useMemo(() => {
|
||||
if (connectionError !== null) {
|
||||
return t(connectionError);
|
||||
}
|
||||
return fieldTypeName;
|
||||
}, [fieldTypeName, t, connectionError]);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
|
||||
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
|
||||
<Box
|
||||
sx={sx}
|
||||
data-cardinality={fieldTemplate.type.cardinality}
|
||||
data-is-batch-field={fieldTemplate.type.batch}
|
||||
data-is-model-field={isModelField}
|
||||
data-is-connection-in-progress={true}
|
||||
data-is-connection-start-field={isConnectionStartField}
|
||||
data-is-connection-valid={connectionError === null}
|
||||
backgroundColor={fieldTemplate.type.cardinality === 'SINGLE' ? fieldColor : 'base.900'}
|
||||
borderColor={fieldColor}
|
||||
/>
|
||||
</Handle>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
ConnectionInProgressHandle.displayName = 'ConnectionInProgressHandle';
|
||||
@@ -1,12 +1,22 @@
|
||||
import { FloatFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput';
|
||||
import { FloatFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider';
|
||||
import { FloatFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldSlider';
|
||||
import { FloatFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatFieldCollectionInputComponent';
|
||||
import { FloatGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorFieldComponent';
|
||||
import { ImageFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent';
|
||||
import { ImageGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageGeneratorFieldComponent';
|
||||
import { IntegerFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerFieldCollectionInputComponent';
|
||||
import { IntegerGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerGeneratorFieldComponent';
|
||||
import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent';
|
||||
import { NumberFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/NumberFieldCollectionInputComponent';
|
||||
import { StringFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringFieldCollectionInputComponent';
|
||||
import { StringGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringGeneratorFieldComponent';
|
||||
import { useFieldInputInstance } from 'features/nodes/hooks/useFieldInputInstance';
|
||||
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
|
||||
import { IntegerFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput';
|
||||
import { IntegerFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider';
|
||||
import { IntegerFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider';
|
||||
import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput';
|
||||
import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea';
|
||||
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import {
|
||||
isBoardFieldInputInstance,
|
||||
isBoardFieldInputTemplate,
|
||||
@@ -40,6 +50,8 @@ import {
|
||||
isImageFieldCollectionInputTemplate,
|
||||
isImageFieldInputInstance,
|
||||
isImageFieldInputTemplate,
|
||||
isImageGeneratorFieldInputInstance,
|
||||
isImageGeneratorFieldInputTemplate,
|
||||
isIntegerFieldCollectionInputInstance,
|
||||
isIntegerFieldCollectionInputTemplate,
|
||||
isIntegerFieldInputInstance,
|
||||
@@ -77,6 +89,7 @@ import {
|
||||
isVAEModelFieldInputInstance,
|
||||
isVAEModelFieldInputTemplate,
|
||||
} from 'features/nodes/types/field';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
import BoardFieldInputComponent from './inputs/BoardFieldInputComponent';
|
||||
@@ -94,174 +107,302 @@ import ImageFieldInputComponent from './inputs/ImageFieldInputComponent';
|
||||
import IPAdapterModelFieldInputComponent from './inputs/IPAdapterModelFieldInputComponent';
|
||||
import LoRAModelFieldInputComponent from './inputs/LoRAModelFieldInputComponent';
|
||||
import MainModelFieldInputComponent from './inputs/MainModelFieldInputComponent';
|
||||
import NumberFieldInputComponent from './inputs/NumberFieldInputComponent';
|
||||
import RefinerModelFieldInputComponent from './inputs/RefinerModelFieldInputComponent';
|
||||
import SchedulerFieldInputComponent from './inputs/SchedulerFieldInputComponent';
|
||||
import SD3MainModelFieldInputComponent from './inputs/SD3MainModelFieldInputComponent';
|
||||
import SDXLMainModelFieldInputComponent from './inputs/SDXLMainModelFieldInputComponent';
|
||||
import SpandrelImageToImageModelFieldInputComponent from './inputs/SpandrelImageToImageModelFieldInputComponent';
|
||||
import StringFieldInputComponent from './inputs/StringFieldInputComponent';
|
||||
import T2IAdapterModelFieldInputComponent from './inputs/T2IAdapterModelFieldInputComponent';
|
||||
import T5EncoderModelFieldInputComponent from './inputs/T5EncoderModelFieldInputComponent';
|
||||
import VAEModelFieldInputComponent from './inputs/VAEModelFieldInputComponent';
|
||||
|
||||
type InputFieldProps = {
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
settings?: NodeFieldElement['data']['settings'];
|
||||
};
|
||||
|
||||
const InputFieldRenderer = ({ nodeId, fieldName }: InputFieldProps) => {
|
||||
const fieldInstance = useFieldInputInstance(nodeId, fieldName);
|
||||
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
|
||||
export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) => {
|
||||
const field = useInputFieldInstance(nodeId, fieldName);
|
||||
const template = useInputFieldTemplate(nodeId, fieldName);
|
||||
|
||||
if (isStringFieldCollectionInputInstance(fieldInstance) && isStringFieldCollectionInputTemplate(fieldTemplate)) {
|
||||
return <StringFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
// When deciding which component to render, first we check the type of the template, which is more efficient than the
|
||||
// instance type check. The instance type check uses zod and is slower.
|
||||
|
||||
if (isStringFieldCollectionInputTemplate(template)) {
|
||||
if (!isStringFieldCollectionInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <StringFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isStringFieldInputInstance(fieldInstance) && isStringFieldInputTemplate(fieldTemplate)) {
|
||||
return <StringFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isStringFieldInputTemplate(template)) {
|
||||
if (!isStringFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
if (settings?.type !== 'string-field-config') {
|
||||
if (template.ui_component === 'textarea') {
|
||||
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else {
|
||||
return <StringFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
}
|
||||
if (settings.component === 'input') {
|
||||
return <StringFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (settings.component === 'textarea') {
|
||||
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBooleanFieldInputInstance(fieldInstance) && isBooleanFieldInputTemplate(fieldTemplate)) {
|
||||
return <BooleanFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isBooleanFieldInputTemplate(template)) {
|
||||
if (!isBooleanFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <BooleanFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isIntegerFieldInputInstance(fieldInstance) && isIntegerFieldInputTemplate(fieldTemplate)) {
|
||||
return <NumberFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isIntegerFieldInputTemplate(template)) {
|
||||
if (!isIntegerFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
if (settings?.type !== 'integer-field-config') {
|
||||
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
if (settings.component === 'number-input') {
|
||||
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (settings.component === 'slider') {
|
||||
return <IntegerFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (settings.component === 'number-input-and-slider') {
|
||||
return <IntegerFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFloatFieldInputInstance(fieldInstance) && isFloatFieldInputTemplate(fieldTemplate)) {
|
||||
return <NumberFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isFloatFieldInputTemplate(template)) {
|
||||
if (!isFloatFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
if (settings?.type !== 'float-field-config') {
|
||||
return <FloatFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
if (settings.component === 'number-input') {
|
||||
return <FloatFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (settings.component === 'slider') {
|
||||
return <FloatFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (settings.component === 'number-input-and-slider') {
|
||||
return <FloatFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isIntegerFieldCollectionInputInstance(fieldInstance) && isIntegerFieldCollectionInputTemplate(fieldTemplate)) {
|
||||
return <NumberFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isIntegerFieldCollectionInputTemplate(template)) {
|
||||
if (!isIntegerFieldCollectionInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <IntegerFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isFloatFieldCollectionInputInstance(fieldInstance) && isFloatFieldCollectionInputTemplate(fieldTemplate)) {
|
||||
return <NumberFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isFloatFieldCollectionInputTemplate(template)) {
|
||||
if (!isFloatFieldCollectionInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <FloatFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isEnumFieldInputInstance(fieldInstance) && isEnumFieldInputTemplate(fieldTemplate)) {
|
||||
return <EnumFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isEnumFieldInputTemplate(template)) {
|
||||
if (!isEnumFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <EnumFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isImageFieldCollectionInputInstance(fieldInstance) && isImageFieldCollectionInputTemplate(fieldTemplate)) {
|
||||
return <ImageFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isImageFieldCollectionInputTemplate(template)) {
|
||||
if (!isImageFieldCollectionInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ImageFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isImageFieldInputInstance(fieldInstance) && isImageFieldInputTemplate(fieldTemplate)) {
|
||||
return <ImageFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isImageFieldInputTemplate(template)) {
|
||||
if (!isImageFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ImageFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isBoardFieldInputInstance(fieldInstance) && isBoardFieldInputTemplate(fieldTemplate)) {
|
||||
return <BoardFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isBoardFieldInputTemplate(template)) {
|
||||
if (!isBoardFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <BoardFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isMainModelFieldInputInstance(fieldInstance) && isMainModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <MainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isMainModelFieldInputTemplate(template)) {
|
||||
if (!isMainModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <MainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isModelIdentifierFieldInputInstance(fieldInstance) && isModelIdentifierFieldInputTemplate(fieldTemplate)) {
|
||||
return <ModelIdentifierFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isModelIdentifierFieldInputTemplate(template)) {
|
||||
if (!isModelIdentifierFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ModelIdentifierFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isSDXLRefinerModelFieldInputInstance(fieldInstance) && isSDXLRefinerModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <RefinerModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isSDXLRefinerModelFieldInputTemplate(template)) {
|
||||
if (!isSDXLRefinerModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <RefinerModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isVAEModelFieldInputInstance(fieldInstance) && isVAEModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <VAEModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isVAEModelFieldInputTemplate(template)) {
|
||||
if (!isVAEModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <VAEModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isT5EncoderModelFieldInputInstance(fieldInstance) && isT5EncoderModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <T5EncoderModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isT5EncoderModelFieldInputTemplate(template)) {
|
||||
if (!isT5EncoderModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <T5EncoderModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
if (isCLIPEmbedModelFieldInputInstance(fieldInstance) && isCLIPEmbedModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <CLIPEmbedModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isCLIPEmbedModelFieldInputTemplate(template)) {
|
||||
if (!isCLIPEmbedModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <CLIPEmbedModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isCLIPLEmbedModelFieldInputInstance(fieldInstance) && isCLIPLEmbedModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <CLIPLEmbedModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isCLIPLEmbedModelFieldInputTemplate(template)) {
|
||||
if (!isCLIPLEmbedModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <CLIPLEmbedModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isCLIPGEmbedModelFieldInputInstance(fieldInstance) && isCLIPGEmbedModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <CLIPGEmbedModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isCLIPGEmbedModelFieldInputTemplate(template)) {
|
||||
if (!isCLIPGEmbedModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <CLIPGEmbedModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isControlLoRAModelFieldInputInstance(fieldInstance) && isControlLoRAModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <ControlLoRAModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isControlLoRAModelFieldInputTemplate(template)) {
|
||||
if (!isControlLoRAModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ControlLoRAModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isFluxVAEModelFieldInputInstance(fieldInstance) && isFluxVAEModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <FluxVAEModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isFluxVAEModelFieldInputTemplate(template)) {
|
||||
if (!isFluxVAEModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <FluxVAEModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isLoRAModelFieldInputInstance(fieldInstance) && isLoRAModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <LoRAModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isLoRAModelFieldInputTemplate(template)) {
|
||||
if (!isLoRAModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <LoRAModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isControlNetModelFieldInputInstance(fieldInstance) && isControlNetModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <ControlNetModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isControlNetModelFieldInputTemplate(template)) {
|
||||
if (!isControlNetModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ControlNetModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isIPAdapterModelFieldInputInstance(fieldInstance) && isIPAdapterModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <IPAdapterModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isIPAdapterModelFieldInputTemplate(template)) {
|
||||
if (!isIPAdapterModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <IPAdapterModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isT2IAdapterModelFieldInputInstance(fieldInstance) && isT2IAdapterModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <T2IAdapterModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isT2IAdapterModelFieldInputTemplate(template)) {
|
||||
if (!isT2IAdapterModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <T2IAdapterModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (
|
||||
isSpandrelImageToImageModelFieldInputInstance(fieldInstance) &&
|
||||
isSpandrelImageToImageModelFieldInputTemplate(fieldTemplate)
|
||||
) {
|
||||
return (
|
||||
<SpandrelImageToImageModelFieldInputComponent
|
||||
nodeId={nodeId}
|
||||
field={fieldInstance}
|
||||
fieldTemplate={fieldTemplate}
|
||||
/>
|
||||
);
|
||||
if (isSpandrelImageToImageModelFieldInputTemplate(template)) {
|
||||
if (!isSpandrelImageToImageModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <SpandrelImageToImageModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isColorFieldInputInstance(fieldInstance) && isColorFieldInputTemplate(fieldTemplate)) {
|
||||
return <ColorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isColorFieldInputTemplate(template)) {
|
||||
if (!isColorFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ColorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isFluxMainModelFieldInputInstance(fieldInstance) && isFluxMainModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <FluxMainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isFluxMainModelFieldInputTemplate(template)) {
|
||||
if (!isFluxMainModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <FluxMainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isSD3MainModelFieldInputInstance(fieldInstance) && isSD3MainModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <SD3MainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isSD3MainModelFieldInputTemplate(template)) {
|
||||
if (!isSD3MainModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <SD3MainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isSDXLMainModelFieldInputInstance(fieldInstance) && isSDXLMainModelFieldInputTemplate(fieldTemplate)) {
|
||||
return <SDXLMainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isSDXLMainModelFieldInputTemplate(template)) {
|
||||
if (!isSDXLMainModelFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <SDXLMainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isSchedulerFieldInputInstance(fieldInstance) && isSchedulerFieldInputTemplate(fieldTemplate)) {
|
||||
return <SchedulerFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isSchedulerFieldInputTemplate(template)) {
|
||||
if (!isSchedulerFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <SchedulerFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isFloatGeneratorFieldInputInstance(fieldInstance) && isFloatGeneratorFieldInputTemplate(fieldTemplate)) {
|
||||
return <FloatGeneratorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isFloatGeneratorFieldInputTemplate(template)) {
|
||||
if (!isFloatGeneratorFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <FloatGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isIntegerGeneratorFieldInputInstance(fieldInstance) && isIntegerGeneratorFieldInputTemplate(fieldTemplate)) {
|
||||
return <IntegerGeneratorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isIntegerGeneratorFieldInputTemplate(template)) {
|
||||
if (!isIntegerGeneratorFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <IntegerGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isStringGeneratorFieldInputInstance(fieldInstance) && isStringGeneratorFieldInputTemplate(fieldTemplate)) {
|
||||
return <StringGeneratorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
|
||||
if (isStringGeneratorFieldInputTemplate(template)) {
|
||||
if (!isStringGeneratorFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <StringGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (fieldTemplate) {
|
||||
// Fallback for when there is no component for the type
|
||||
return null;
|
||||
if (isImageGeneratorFieldInputTemplate(template)) {
|
||||
if (!isImageGeneratorFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ImageGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(InputFieldRenderer);
|
||||
return null;
|
||||
});
|
||||
|
||||
InputFieldRenderer.displayName = 'InputFieldRenderer';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useInputFieldDefaultValue } from 'features/nodes/hooks/useInputFieldDefaultValue';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export const InputFieldResetToDefaultValueIconButton = memo(({ nodeId, fieldName }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isValueChanged, resetToDefaultValue } = useInputFieldDefaultValue(nodeId, fieldName);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
tooltip={t('nodes.resetToDefaultValue')}
|
||||
aria-label={t('nodes.resetToDefaultValue')}
|
||||
icon={<PiArrowCounterClockwiseBold />}
|
||||
pointerEvents="auto"
|
||||
size="xs"
|
||||
onClick={resetToDefaultValue}
|
||||
isDisabled={!isValueChanged}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldResetToDefaultValueIconButton.displayName = 'InputFieldResetToDefaultValueIconButton';
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Input, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { InputFieldTooltipContent } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent';
|
||||
import {
|
||||
useConnectionErrorTKey,
|
||||
useIsConnectionInProgress,
|
||||
useIsConnectionStartField,
|
||||
} from 'features/nodes/hooks/useFieldConnectionState';
|
||||
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
|
||||
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
|
||||
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
|
||||
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const labelSx: SystemStyleObject = {
|
||||
p: 0,
|
||||
fontWeight: 'semibold',
|
||||
textAlign: 'left',
|
||||
color: 'base.300',
|
||||
_hover: {
|
||||
fontWeight: 'semibold !important',
|
||||
},
|
||||
'&[data-is-invalid="true"]': {
|
||||
color: 'error.300',
|
||||
},
|
||||
'&[data-is-disabled="true"]': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
isInvalid?: boolean;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export const InputFieldTitle = memo((props: Props) => {
|
||||
const { nodeId, fieldName, isInvalid, isDragging } = props;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const label = useInputFieldLabel(nodeId, fieldName);
|
||||
const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName);
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
|
||||
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
|
||||
const isConnectionInProgress = useIsConnectionInProgress();
|
||||
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const defaultTitle = useMemo(() => fieldTemplateTitle || t('nodes.unknownField'), [fieldTemplateTitle, t]);
|
||||
const onChange = useCallback(
|
||||
(label: string) => {
|
||||
dispatch(fieldLabelChanged({ nodeId, fieldName, label }));
|
||||
},
|
||||
[dispatch, nodeId, fieldName]
|
||||
);
|
||||
const editable = useEditable({
|
||||
value: label || defaultTitle,
|
||||
defaultValue: defaultTitle,
|
||||
onChange,
|
||||
inputRef,
|
||||
});
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => (isConnectionInProgress && connectionError !== null && !isConnectionStartField) || isConnected,
|
||||
[isConnectionInProgress, connectionError, isConnectionStartField, isConnected]
|
||||
);
|
||||
|
||||
const onDoubleClick = useCallback(
|
||||
(e: MouseEvent<HTMLParagraphElement>) => {
|
||||
e.stopPropagation();
|
||||
editable.startEditing();
|
||||
},
|
||||
[editable]
|
||||
);
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={<InputFieldTooltipContent nodeId={nodeId} fieldName={fieldName} />}
|
||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||
placement="top"
|
||||
isDisabled={isDragging}
|
||||
>
|
||||
<Text
|
||||
className={NO_FIT_ON_DOUBLE_CLICK_CLASS}
|
||||
sx={labelSx}
|
||||
noOfLines={1}
|
||||
data-is-invalid={isInvalid}
|
||||
data-is-disabled={isDisabled}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{editable.value}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
variant="outline"
|
||||
{...editable.inputProps}
|
||||
_focusVisible={{ borderRadius: 'base', h: 'unset', px: 2 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldTitle.displayName = 'InputFieldTitle';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
|
||||
import { startCase } from 'lodash-es';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export const InputFieldTooltipContent = memo(({ nodeId, fieldName }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fieldInstance = useInputFieldInstance(nodeId, fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
|
||||
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
|
||||
|
||||
const fieldTitle = useMemo(() => {
|
||||
if (fieldInstance.label && fieldTemplate.title) {
|
||||
return `${fieldInstance.label} (${fieldTemplate.title})`;
|
||||
}
|
||||
|
||||
if (fieldInstance.label && !fieldTemplate.title) {
|
||||
return fieldInstance.label;
|
||||
}
|
||||
|
||||
return fieldTemplate.title;
|
||||
}, [fieldInstance, fieldTemplate]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Text fontWeight="semibold">{fieldTitle}</Text>
|
||||
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||
{fieldTemplate.description}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('parameters.type')}: {fieldTypeName}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('common.input')}: {startCase(fieldTemplate.input)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldTooltipContent.displayName = 'InputFieldTooltipContent';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper';
|
||||
import { useInputFieldName } from 'features/nodes/hooks/useInputFieldName';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export const InputFieldUnknownPlaceholder = memo(({ nodeId, fieldName }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const name = useInputFieldName(nodeId, fieldName);
|
||||
|
||||
return (
|
||||
<InputFieldWrapper>
|
||||
<FormControl isInvalid={true} alignItems="stretch" justifyContent="center" gap={2} h="full" w="full">
|
||||
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
|
||||
{t('nodes.unknownInput', { name })}
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
</InputFieldWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
InputFieldUnknownPlaceholder.displayName = 'InputFieldUnknownPlaceholder';
|
||||
@@ -1,27 +1,19 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
type InputFieldWrapperProps = PropsWithChildren<{
|
||||
shouldDim: boolean;
|
||||
}>;
|
||||
const sx = {
|
||||
position: 'relative',
|
||||
minH: 8,
|
||||
py: 0.5,
|
||||
alignItems: 'center',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
} satisfies SystemStyleObject;
|
||||
|
||||
export const InputFieldWrapper = memo(({ shouldDim, children }: InputFieldWrapperProps) => {
|
||||
return (
|
||||
<Flex
|
||||
position="relative"
|
||||
minH={8}
|
||||
py={0.5}
|
||||
alignItems="center"
|
||||
opacity={shouldDim ? 0.5 : 1}
|
||||
transitionProperty="opacity"
|
||||
transitionDuration="0.1s"
|
||||
w="full"
|
||||
h="full"
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
export const InputFieldWrapper = memo(({ children }: PropsWithChildren) => {
|
||||
return <Flex sx={sx}>{children}</Flex>;
|
||||
});
|
||||
|
||||
InputFieldWrapper.displayName = 'InputFieldWrapper';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user