Compare commits

..

48 Commits

Author SHA1 Message Date
987dce801c tidy(ui): organise tool module 2024-08-29 21:46:00 +10:00
f0f6813979 fix(ui): staging hotkeys enabled at wrong times 2024-08-29 21:37:38 +10:00
28b86d3917 fix(ui): incorrect batch origin preventing progress/staging 2024-08-29 21:37:11 +10:00
38e25c45bf feat(ui): restore minimal HUD 2024-08-29 21:30:52 +10:00
978a869a70 feat(ui): remove unused asPreview for StageComponent 2024-08-29 21:27:14 +10:00
2acc5c8317 chore(ui): lint 2024-08-29 19:01:42 +10:00
3fe4b770b8 chore: release v4.2.9.dev8 2024-08-29 18:52:14 +10:00
c800f84c50 feat(ui): revise generation mode logic
- Canvas generation mode is replace with a boolean `sendToCanvas` flag. When off, images generated on the canvas go to the gallery. When on, they get added to the staging area.
- When an image result is received, if its destination is the canvas, staging is automatically started.
- Updated queue list to show the destination column.
- Added `IconSwitch` component to represent binary choices, used for the new `sendToCanvas` flag and image viewer toggle.
- Remove the queue actions menu in `QueueControls`. Move the queue count badge to the cancel button.
- Redo layout of `QueueControls` to prevent duplicate queue count badges.
- Fix issue where gallery and options panels could show thru transparent regions of queue tab.
- Disable panel hotkeys when on mm/queue tabs.
2024-08-29 17:58:02 +10:00
a64dd129f1 chore(ui): typegen 2024-08-29 17:49:15 +10:00
cd4cc56add feat(app): add destination column to session_queue
The frontend needs to know where queue items came from (i.e. which tab), and where results are going to (i.e. send images to gallery or canvas). The `origin` column is not quite enough to represent this cleanly.

A `destination` column provides the frontend what it needs to handle incoming generations.
2024-08-29 17:49:06 +10:00
c19aa0389a tidy(ui): ViewerToggleMenu -> ViewerToggle 2024-08-29 10:58:14 +10:00
fdb125b294 feat(ui): alt quick switches to color picker 2024-08-29 10:52:17 +10:00
686856d111 feat(ui): tweak add entity button layout 2024-08-29 10:43:55 +10:00
fec61ce1ff feat(ui): restore context menu for entity list 2024-08-29 10:35:49 +10:00
bd5780de4d feat(ui): add delete button to each layer 2024-08-29 10:27:55 +10:00
db4782a632 feat(ui): add + buttons to entity categories 2024-08-29 10:15:35 +10:00
c4b2099c4f feat(ui): tweak brush fill UI 2024-08-29 09:07:20 +10:00
ec745f663d feat(ui): do not select layer on staging accept 2024-08-29 09:00:53 +10:00
4f47577076 fix(ui): more fiddly queue count layout stuff 2024-08-29 08:54:07 +10:00
6ee8e13632 fix(ui): floating params panel invoke button loading state 2024-08-29 08:42:22 +10:00
d469b1771e feat(ui): move canvas undo/redo to hook 2024-08-29 08:33:03 +10:00
e79f9782ab fix(ui): queue count badge positioning 2024-08-29 08:13:16 +10:00
ded72e0362 fix(ui): add node cmdk only enabled on workflows tab 2024-08-29 07:47:30 +10:00
37ed567aad chore: release v4.2.9.dev7 2024-08-28 22:05:15 +10:00
9692a5f996 fix(ui): pending node connection stuck 2024-08-28 22:05:03 +10:00
5db7b48cd8 chore(ui): lint 2024-08-28 21:38:57 +10:00
ea014c66ac chore: release v4.2.9.dev6 2024-08-28 21:33:12 +10:00
25918c28aa feat(ui): migrate add node popover to cmdk
Put this together as a way to figure out the library before moving on to the full app cmdk. Works great.
2024-08-28 21:32:10 +10:00
0c60469401 fix(ui): schema parsing now that node_pack is guaranteed to be present 2024-08-28 21:31:15 +10:00
f1aa50f447 chore(ui): typegen 2024-08-28 21:06:45 +10:00
a413b261f0 fix(app): node_pack not added to openapi schema correctly 2024-08-28 21:06:33 +10:00
4a1a6639f6 fix(ui): unnecessary z-index on invoke button 2024-08-28 16:51:44 +10:00
201c370ca1 feat(ui): split settings modal 2024-08-28 16:51:38 +10:00
d070c7c726 perf(ui): disable useInert on modals
This hook forcibly updates _all_ portals with `data-hidden=true` when the modal opens - then reverts it when the modal closes. It's intended to help screen readers. Unfortunately, this absolutely tanks performance because we have many portals. React needs to do alot of layout calculations (not re-renders).

IMO this behaviour is a bug in chakra. The modals which generated the portals are hidden by default, so this data attr should really be set by default. Dunno why it isn't.
2024-08-28 16:49:19 +10:00
e38e20a992 feat(ui): fix queue item count badge positioning
Previously this badge, floating over the queue menu button next to the invoke button, was rendered within the existing layout. When I initially positioned it, the app layout interfered - it would extend into an area reserved for a flex gap, which cut off the badge.

As a (bad) workaround, I had shifted the whole app down a few pixels to make room for it. What I should have done is what I've done in this commit - render the badge in a portal to take it out of the layout so we don't need that extra vertical padding.

Sleekified some styling a bit too.
2024-08-28 16:37:50 +10:00
39a94ec70e fix(ui): transparency effect not updating 2024-08-28 16:08:12 +10:00
c7bfae2d1e feat(ui): tidy canvas toolbar buttons 2024-08-28 16:04:37 +10:00
e7944c427d feat(ui): revised viewer toggle @joshistoast 2024-08-28 16:04:25 +10:00
48ed4e120d fix(ui): opacity reset value incorrect 2024-08-28 16:03:48 +10:00
a5b038a1b1 revert(ui): roll back flip, doesn't work with rotate yet 2024-08-28 12:03:38 +10:00
dc752c98b0 fix(ui): disable opacity slider fully when no valid entity selected 2024-08-28 09:00:55 +10:00
85a47cc6fe fix(ui): layer preview image sometimes not rendering
The canvas size was dynamic based on the container div's size. When the div was hidden (e.g. when selecting another tab), the container's effective size is 0. This resulted in the preview image canvas being drawn at a scale of 0.

Fixed by using an absolute size for the canvas container.
2024-08-28 08:59:38 +10:00
6450f42cfa feat(ui): tweak regional prompt box styles 2024-08-28 08:44:04 +10:00
3876f71ff4 feat(ui): tweak enabled/locked toggle styles 2024-08-28 08:25:37 +10:00
cf819e8eab feat(ui): tweak filter styling 2024-08-28 08:07:36 +10:00
2217fb8485 feat(ui): add flip & reset to transform 2024-08-28 07:57:15 +10:00
43652e830a tidy(ui): use helper to sync scaled bbox size on model change 2024-08-28 07:11:05 +10:00
a3417bf81d fix(ui): randomize seed toggle linked to prompt concat 2024-08-28 07:05:14 +10:00
123 changed files with 2303 additions and 1347 deletions

View File

@ -20,7 +20,6 @@ from typing import (
Type,
TypeVar,
Union,
cast,
)
import semver
@ -80,7 +79,7 @@ class UIConfigBase(BaseModel):
version: str = Field(
description='The node\'s version. Should be a valid semver string e.g. "1.0.0" or "3.8.13".',
)
node_pack: Optional[str] = Field(default=None, description="Whether or not this is a custom node")
node_pack: str = Field(description="The node pack that this node belongs to, will be 'invokeai' for built-in nodes")
classification: Classification = Field(default=Classification.Stable, description="The node's classification")
model_config = ConfigDict(
@ -230,18 +229,16 @@ class BaseInvocation(ABC, BaseModel):
@staticmethod
def json_schema_extra(schema: dict[str, Any], model_class: Type[BaseInvocation]) -> None:
"""Adds various UI-facing attributes to the invocation's OpenAPI schema."""
uiconfig = cast(UIConfigBase | None, getattr(model_class, "UIConfig", None))
if uiconfig is not None:
if uiconfig.title is not None:
schema["title"] = uiconfig.title
if uiconfig.tags is not None:
schema["tags"] = uiconfig.tags
if uiconfig.category is not None:
schema["category"] = uiconfig.category
if uiconfig.node_pack is not None:
schema["node_pack"] = uiconfig.node_pack
schema["classification"] = uiconfig.classification
schema["version"] = uiconfig.version
if title := model_class.UIConfig.title:
schema["title"] = title
if tags := model_class.UIConfig.tags:
schema["tags"] = tags
if category := model_class.UIConfig.category:
schema["category"] = category
if node_pack := model_class.UIConfig.node_pack:
schema["node_pack"] = node_pack
schema["classification"] = model_class.UIConfig.classification
schema["version"] = model_class.UIConfig.version
if "required" not in schema or not isinstance(schema["required"], list):
schema["required"] = []
schema["class"] = "invocation"
@ -312,7 +309,7 @@ class BaseInvocation(ABC, BaseModel):
json_schema_extra={"field_kind": FieldKind.NodeAttribute},
)
UIConfig: ClassVar[Type[UIConfigBase]]
UIConfig: ClassVar[UIConfigBase]
model_config = ConfigDict(
protected_namespaces=(),
@ -441,30 +438,25 @@ def invocation(
validate_fields(cls.model_fields, invocation_type)
# Add OpenAPI schema extras
uiconfig_name = cls.__qualname__ + ".UIConfig"
if not hasattr(cls, "UIConfig") or cls.UIConfig.__qualname__ != uiconfig_name:
cls.UIConfig = type(uiconfig_name, (UIConfigBase,), {})
cls.UIConfig.title = title
cls.UIConfig.tags = tags
cls.UIConfig.category = category
cls.UIConfig.classification = classification
# Grab the node pack's name from the module name, if it's a custom node
is_custom_node = cls.__module__.rsplit(".", 1)[0] == "invokeai.app.invocations"
if is_custom_node:
cls.UIConfig.node_pack = cls.__module__.split(".")[0]
else:
cls.UIConfig.node_pack = None
uiconfig: dict[str, Any] = {}
uiconfig["title"] = title
uiconfig["tags"] = tags
uiconfig["category"] = category
uiconfig["classification"] = classification
# The node pack is the module name - will be "invokeai" for built-in nodes
uiconfig["node_pack"] = cls.__module__.split(".")[0]
if version is not None:
try:
semver.Version.parse(version)
except ValueError as e:
raise InvalidVersionError(f'Invalid version string for node "{invocation_type}": "{version}"') from e
cls.UIConfig.version = version
uiconfig["version"] = version
else:
logger.warn(f'No version specified for node "{invocation_type}", using "1.0.0"')
cls.UIConfig.version = "1.0.0"
uiconfig["version"] = "1.0.0"
cls.UIConfig = UIConfigBase(**uiconfig)
if use_cache is not None:
cls.model_fields["use_cache"].default = use_cache

View File

@ -88,7 +88,8 @@ class QueueItemEventBase(QueueEventBase):
item_id: int = Field(description="The ID of the queue item")
batch_id: str = Field(description="The ID of the queue batch")
origin: str | None = Field(default=None, description="The origin of the batch")
origin: str | None = Field(default=None, description="The origin of the queue item")
destination: str | None = Field(default=None, description="The destination of the queue item")
class InvocationEventBase(QueueItemEventBase):
@ -114,6 +115,7 @@ class InvocationStartedEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@ -148,6 +150,7 @@ class InvocationDenoiseProgressEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@ -186,6 +189,7 @@ class InvocationCompleteEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@ -219,6 +223,7 @@ class InvocationErrorEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@ -257,6 +262,7 @@ class QueueItemStatusChangedEvent(QueueItemEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
status=queue_item.status,
error_type=queue_item.error_type,

View File

@ -77,7 +77,14 @@ BatchDataCollection: TypeAlias = list[list[BatchDatum]]
class Batch(BaseModel):
batch_id: str = Field(default_factory=uuid_string, description="The ID of the batch")
origin: str | None = Field(default=None, description="The origin of this batch.")
origin: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
)
destination: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
)
data: Optional[BatchDataCollection] = Field(default=None, description="The batch data collection.")
graph: Graph = Field(description="The graph to initialize the session with")
workflow: Optional[WorkflowWithoutID] = Field(
@ -196,7 +203,14 @@ class SessionQueueItemWithoutGraph(BaseModel):
status: QUEUE_ITEM_STATUS = Field(default="pending", description="The status of this queue item")
priority: int = Field(default=0, description="The priority of this queue item")
batch_id: str = Field(description="The ID of the batch associated with this queue item")
origin: str | None = Field(default=None, description="The origin of this queue item. ")
origin: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
)
destination: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
)
session_id: str = Field(
description="The ID of the session associated with this queue item. The session doesn't exist in graph_executions until the queue item is executed."
)
@ -297,6 +311,7 @@ class BatchStatus(BaseModel):
queue_id: str = Field(..., description="The ID of the queue")
batch_id: str = Field(..., description="The ID of the batch")
origin: str | None = Field(..., description="The origin of the batch")
destination: str | None = Field(..., description="The destination of the batch")
pending: int = Field(..., description="Number of queue items with status 'pending'")
in_progress: int = Field(..., description="Number of queue items with status 'in_progress'")
completed: int = Field(..., description="Number of queue items with status 'complete'")
@ -443,6 +458,7 @@ class SessionQueueValueToInsert(NamedTuple):
priority: int # priority
workflow: Optional[str] # workflow json
origin: str | None
destination: str | None
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
@ -464,6 +480,7 @@ def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new
priority, # priority
json.dumps(workflow, default=to_jsonable_python) if workflow else None, # workflow (json)
batch.origin, # origin
batch.destination, # destination
)
)
return values_to_insert

View File

@ -128,8 +128,8 @@ class SqliteSessionQueue(SessionQueueBase):
self.__cursor.executemany(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values_to_insert,
)
@ -579,7 +579,8 @@ class SqliteSessionQueue(SessionQueueBase):
session_id,
batch_id,
queue_id,
origin
origin,
destination
FROM session_queue
WHERE queue_id = ?
"""
@ -659,7 +660,7 @@ class SqliteSessionQueue(SessionQueueBase):
self.__lock.acquire()
self.__cursor.execute(
"""--sql
SELECT status, count(*), origin
SELECT status, count(*), origin, destination
FROM session_queue
WHERE
queue_id = ?
@ -672,6 +673,7 @@ class SqliteSessionQueue(SessionQueueBase):
total = sum(row[1] for row in result)
counts: dict[str, int] = {row[0]: row[1] for row in result}
origin = result[0]["origin"] if result else None
destination = result[0]["destination"] if result else None
except Exception:
self.__conn.rollback()
raise
@ -681,6 +683,7 @@ class SqliteSessionQueue(SessionQueueBase):
return BatchStatus(
batch_id=batch_id,
origin=origin,
destination=destination,
queue_id=queue_id,
pending=counts.get("pending", 0),
in_progress=counts.get("in_progress", 0),

View File

@ -10,9 +10,11 @@ class Migration15Callback:
def _add_origin_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN origin TEXT;")
cursor.execute("ALTER TABLE session_queue ADD COLUMN destination TEXT;")
def build_migration_15() -> Migration:
@ -21,6 +23,7 @@ def build_migration_15() -> Migration:
This migration does the following:
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
migration_15 = Migration(
from_version=14,

View File

@ -64,6 +64,7 @@
"@roarr/browser-log-writer": "^1.3.0",
"async-mutex": "^0.5.0",
"chakra-react-select": "^4.9.1",
"cmdk": "^1.0.0",
"compare-versions": "^6.1.1",
"dateformat": "^5.0.3",
"fracturedjsonjs": "^4.0.2",
@ -92,7 +93,6 @@
"react-icons": "^5.2.1",
"react-redux": "9.1.2",
"react-resizable-panels": "^2.0.23",
"react-select": "5.8.0",
"react-use": "^17.5.1",
"react-virtuoso": "^4.9.0",
"reactflow": "^11.11.4",

View File

@ -41,6 +41,9 @@ dependencies:
chakra-react-select:
specifier: ^4.9.1
version: 4.9.1(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/layout@2.3.1)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@emotion/react@11.13.3)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
cmdk:
specifier: ^1.0.0
version: 1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
compare-versions:
specifier: ^6.1.1
version: 6.1.1
@ -125,9 +128,6 @@ dependencies:
react-resizable-panels:
specifier: ^2.0.23
version: 2.0.23(react-dom@18.3.1)(react@18.3.1)
react-select:
specifier: 5.8.0
version: 5.8.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
react-use:
specifier: ^17.5.1
version: 17.5.1(react-dom@18.3.1)(react@18.3.1)
@ -2053,7 +2053,7 @@ packages:
dependencies:
'@chakra-ui/dom-utils': 2.1.0
react: 18.3.1
react-focus-lock: 2.12.1(@types/react@18.3.3)(react@18.3.1)
react-focus-lock: 2.13.0(@types/react@18.3.3)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
dev: false
@ -3784,6 +3784,288 @@ packages:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/@radix-ui/primitive@1.0.1:
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
dependencies:
'@babel/runtime': 7.25.4
dev: false
/@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-context@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-context': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-id': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-slot': 1.0.2(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
aria-hidden: 1.2.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.5.5(@types/react@18.3.3)(react@18.3.1)
dev: false
/@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-id@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-slot': 1.0.2(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-slot@1.0.2(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.25.4
'@types/react': 18.3.3
react: 18.3.1
dev: false
/@reactflow/background@11.3.14(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
peerDependencies:
@ -5210,7 +5492,6 @@ packages:
resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==}
dependencies:
'@types/react': 18.3.3
dev: true
/@types/react-transition-group@4.4.10:
resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==}
@ -6268,6 +6549,21 @@ packages:
requiresBuild: true
dev: true
/cmdk@1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependencies:
'@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
dev: false
/color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
@ -9712,8 +10008,8 @@ packages:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
dev: false
/react-focus-lock@2.12.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-lfp8Dve4yJagkHiFrC1bGtib3mF2ktqwPJw4/WGcgPW+pJ/AVQA5X2vI7xgp13FcxFEpYBBHpXai/N2DBNC0Jw==}
/react-focus-lock@2.13.0(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-w7aIcTwZwNzUp2fYQDMICy+khFwVmKmOrLF8kNsPS+dz4Oi/oxoVJ2wCMVvX6rWGriM/+mYaTyp1MRmkcs2amw==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
@ -9867,6 +10163,25 @@ packages:
use-sidecar: 1.1.2(@types/react@18.3.3)(react@18.3.1)
dev: false
/react-remove-scroll@2.5.5(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.3.3
react: 18.3.1
react-remove-scroll-bar: 2.3.6(@types/react@18.3.3)(react@18.3.1)
react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.3.1)
tslib: 2.7.0
use-callback-ref: 1.3.2(@types/react@18.3.3)(react@18.3.1)
use-sidecar: 1.1.2(@types/react@18.3.3)(react@18.3.1)
dev: false
/react-resizable-panels@2.0.23(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-8ZKTwTU11t/FYwiwhMdtZYYyFxic5U5ysRu2YwfkAgDbUJXFvnWSJqhnzkSlW+mnDoNAzDCrJhdOSXBPA76wug==}
peerDependencies:

View File

@ -164,10 +164,10 @@
"alpha": "Alpha",
"selected": "Selected",
"tab": "Tab",
"viewing": "Viewing",
"viewingDesc": "Review images in a large gallery view",
"editing": "Editing",
"editingDesc": "Edit on the Control Layers canvas",
"view": "View",
"viewDesc": "Review images in a large gallery view",
"edit": "Edit",
"editDesc": "Edit on the Canvas",
"comparing": "Comparing",
"comparingDesc": "Comparing two images",
"enabled": "Enabled",
@ -328,9 +328,13 @@
"completedIn": "Completed in",
"batch": "Batch",
"origin": "Origin",
"originCanvas": "Canvas",
"originWorkflows": "Workflows",
"originOther": "Other",
"destination": "Destination",
"upscaling": "Upscaling",
"canvas": "Canvas",
"generation": "Generation",
"workflows": "Workflows",
"other": "Other",
"gallery": "Gallery",
"batchFieldValues": "Batch Field Values",
"item": "Item",
"session": "Session",
@ -1675,32 +1679,44 @@
"deletePrompt": "Delete Prompt",
"resetRegion": "Reset Region",
"debugLayers": "Debug Layers",
"showHUD": "Show HUD",
"rectangle": "Rectangle",
"maskFill": "Mask Fill",
"addPositivePrompt": "Add $t(common.positivePrompt)",
"addNegativePrompt": "Add $t(common.negativePrompt)",
"addIPAdapter": "Add $t(common.ipAdapter)",
"addRasterLayer": "Add $t(controlLayers.rasterLayer)",
"addControlLayer": "Add $t(controlLayers.controlLayer)",
"addInpaintMask": "Add $t(controlLayers.inpaintMask)",
"addRegionalGuidance": "Add $t(controlLayers.regionalGuidance)",
"regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)",
"raster": "Raster",
"rasterLayer_one": "Raster Layer",
"controlLayer_one": "Control Layer",
"inpaintMask_one": "Inpaint Mask",
"regionalGuidance_one": "Regional Guidance",
"ipAdapter_one": "IP Adapter",
"rasterLayer_other": "Raster Layers",
"controlLayer_other": "Control Layers",
"inpaintMask_other": "Inpaint Masks",
"regionalGuidance_other": "Regional Guidance",
"ipAdapter_other": "IP Adapters",
"rasterLayer": "Raster Layer",
"controlLayer": "Control Layer",
"inpaintMask": "Inpaint Mask",
"regionalGuidance": "Regional Guidance",
"ipAdapter": "IP Adapter",
"sendToGallery": "Send To Gallery",
"sendToGalleryDesc": "Generations will be sent to the gallery.",
"sendToCanvas": "Send To Canvas",
"sendToCanvasDesc": "Generations will be staged onto the canvas.",
"rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)",
"controlLayer_withCount_one": "$t(controlLayers.controlLayer)",
"inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)",
"regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)",
"ipAdapter_withCount_one": "$t(controlLayers.ipAdapter)",
"rasterLayer_withCount_other": "Raster Layers",
"controlLayer_withCount_other": "Control Layers",
"inpaintMask_withCount_other": "Inpaint Masks",
"regionalGuidance_withCount_other": "Regional Guidance",
"ipAdapter_withCount_other": "IP Adapters",
"opacity": "Opacity",
"regionalGuidance_withCount_hidden": "Regional Guidance ({{count}} hidden)",
"controlAdapters_withCount_hidden": "Control Adapters ({{count}} hidden)",
"controlLayers_withCount_hidden": "Control Layers ({{count}} hidden)",
"rasterLayers_withCount_hidden": "Raster Layers ({{count}} hidden)",
"ipAdapters_withCount_hidden": "IP Adapters ({{count}} hidden)",
"inpaintMasks_withCount_hidden": "Inpaint Masks ({{count}} hidden)",
"regionalGuidance_withCount_visible": "Regional Guidance ({{count}})",
"controlAdapters_withCount_visible": "Control Adapters ({{count}})",
"controlLayers_withCount_visible": "Control Layers ({{count}})",
"rasterLayers_withCount_visible": "Raster Layers ({{count}})",
"ipAdapters_withCount_visible": "IP Adapters ({{count}})",
@ -1734,7 +1750,10 @@
"unlocked": "Unlocked",
"deleteSelected": "Delete Selected",
"deleteAll": "Delete All",
"flipHorizontal": "Flip Horizontal",
"flipVertical": "Flip Vertical",
"fill": {
"fillColor": "Fill Color",
"fillStyle": "Fill Style",
"solid": "Solid",
"grid": "Grid",

View File

@ -16,6 +16,8 @@ import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicP
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
import SettingsModal from 'features/system/components/SettingsModal/SettingsModal';
import { configChanged } from 'features/system/store/configSlice';
import { selectLanguage } from 'features/system/store/systemSelectors';
import { AppContent } from 'features/ui/components/AppContent';
@ -121,6 +123,8 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, desti
<StylePresetModal />
<ClearQueueConfirmationsAlertDialog />
<PreselectedImage selectedImage={selectedImage} />
<SettingsModal />
<RefreshAfterResetModal />
</ErrorBoundary>
);
};

View File

@ -68,7 +68,7 @@ export const addStagingListeners = (startAppListening: AppStartListening) => {
objects: [imageObject],
};
api.dispatch(rasterLayerAdded({ overrides, isSelected: true }));
api.dispatch(rasterLayerAdded({ overrides, isSelected: false }));
api.dispatch(sessionStagingAreaReset());
},
});

View File

@ -31,7 +31,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
let didStartStaging = false;
if (!state.canvasSession.isStaging && state.canvasSession.mode === 'compose') {
if (!state.canvasSession.isStaging && state.canvasSession.sendToCanvas) {
dispatch(sessionStartedStaging());
didStartStaging = true;
}
@ -70,7 +70,11 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
const { g, noise, posCond } = buildGraphResult.value;
const prepareBatchResult = withResult(() => prepareLinearUIBatch(state, g, prepend, noise, posCond));
const destination = state.canvasSession.sendToCanvas ? 'canvas' : 'gallery';
const prepareBatchResult = withResult(() =>
prepareLinearUIBatch(state, g, prepend, noise, posCond, 'generation', destination)
);
if (isErr(prepareBatchResult)) {
log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch');

View File

@ -32,6 +32,7 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
workflow: builtWorkflow,
runs: state.params.iterations,
origin: 'workflows',
destination: 'gallery',
},
prepend: action.payload.prepend,
};

View File

@ -16,7 +16,7 @@ export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening)
const { g, noise, posCond } = await buildMultidiffusionUpscaleGraph(state);
const batchConfig = prepareLinearUIBatch(state, g, prepend, noise, posCond);
const batchConfig = prepareLinearUIBatch(state, g, prepend, noise, posCond, 'upscaling', 'gallery');
const req = dispatch(
queueApi.endpoints.enqueueBatch.initiate(batchConfig, {

View File

@ -0,0 +1,104 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, IconButton, Tooltip, useToken } from '@invoke-ai/ui-library';
import type { ReactElement, ReactNode } from 'react';
import { memo, useCallback, useMemo } from 'react';
type IconSwitchProps = {
isChecked: boolean;
onChange: (checked: boolean) => void;
iconChecked: ReactElement;
tooltipChecked?: ReactNode;
iconUnchecked: ReactElement;
tooltipUnchecked?: ReactNode;
ariaLabel: string;
};
const getSx = (padding: string | number): SystemStyleObject => ({
transition: 'left 0.1s ease-in-out, transform 0.1s ease-in-out',
'&[data-checked="true"]': {
left: `calc(100% - ${padding})`,
transform: 'translateX(-100%)',
},
'&[data-checked="false"]': {
left: padding,
transform: 'translateX(0)',
},
});
export const IconSwitch = memo(
({
isChecked,
onChange,
iconChecked,
tooltipChecked,
iconUnchecked,
tooltipUnchecked,
ariaLabel,
}: IconSwitchProps) => {
const onUncheck = useCallback(() => {
onChange(false);
}, [onChange]);
const onCheck = useCallback(() => {
onChange(true);
}, [onChange]);
const gap = useToken('space', 1.5);
const sx = useMemo(() => getSx(gap), [gap]);
return (
<Flex
position="relative"
bg="base.800"
borderRadius="base"
alignItems="center"
justifyContent="center"
h="full"
p={gap}
gap={gap}
>
<Box
position="absolute"
borderRadius="base"
bg="invokeBlue.400"
w={12}
top={gap}
bottom={gap}
data-checked={isChecked}
sx={sx}
/>
<Tooltip hasArrow label={tooltipUnchecked}>
<IconButton
size="sm"
fontSize={16}
icon={iconUnchecked}
onClick={onUncheck}
variant={!isChecked ? 'solid' : 'ghost'}
colorScheme={!isChecked ? 'invokeBlue' : 'base'}
aria-label={ariaLabel}
data-checked={!isChecked}
w={12}
alignSelf="stretch"
h="auto"
/>
</Tooltip>
<Tooltip hasArrow label={tooltipChecked}>
<IconButton
size="sm"
fontSize={16}
icon={iconChecked}
onClick={onCheck}
variant={isChecked ? 'solid' : 'ghost'}
colorScheme={isChecked ? 'invokeBlue' : 'base'}
aria-label={ariaLabel}
data-checked={isChecked}
w={12}
alignSelf="stretch"
h="auto"
/>
</Tooltip>
</Flex>
);
}
);
IconSwitch.displayName = 'IconSwitch';

View File

@ -1,7 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { addScope, removeScope, setScopes } from 'common/hooks/interactionScopes';
import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
import { useQueueFront } from 'features/queue/hooks/useQueueFront';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';

View File

@ -83,6 +83,7 @@ const ChangeBoardModal = () => {
acceptCallback={handleChangeBoard}
acceptButtonText={t('boards.move')}
cancelButtonText={t('boards.cancel')}
useInert={false}
>
<Flex flexDir="column" gap={4}>
<Text>

View File

@ -31,22 +31,22 @@ export const CanvasAddEntityButtons = memo(() => {
}, [dispatch]);
return (
<Flex flexDir="column" w="full" h="full" alignItems="center" justifyContent="center">
<ButtonGroup orientation="vertical" isAttached={false}>
<Flex flexDir="column" w="full" h="full" alignItems="center">
<ButtonGroup position="relative" orientation="vertical" isAttached={false} top="20%">
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addInpaintMask}>
{t('controlLayers.inpaintMask', { count: 1 })}
{t('controlLayers.inpaintMask')}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addRegionalGuidance}>
{t('controlLayers.regionalGuidance', { count: 1 })}
{t('controlLayers.regionalGuidance')}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addRasterLayer}>
{t('controlLayers.rasterLayer', { count: 1 })}
{t('controlLayers.rasterLayer')}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addControlLayer}>
{t('controlLayers.controlLayer', { count: 1 })}
{t('controlLayers.controlLayer')}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addIPAdapter}>
{t('controlLayers.ipAdapter', { count: 1 })}
{t('controlLayers.ipAdapter')}
</Button>
</ButtonGroup>
</Flex>

View File

@ -33,19 +33,19 @@ export const CanvasEntityListMenuItems = memo(() => {
return (
<>
<MenuItem icon={<PiPlusBold />} onClick={addInpaintMask}>
{t('controlLayers.inpaintMask', { count: 1 })}
{t('controlLayers.inpaintMask')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addRegionalGuidance}>
{t('controlLayers.regionalGuidance', { count: 1 })}
{t('controlLayers.regionalGuidance')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addRasterLayer}>
{t('controlLayers.rasterLayer', { count: 1 })}
{t('controlLayers.rasterLayer')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addControlLayer}>
{t('controlLayers.controlLayer', { count: 1 })}
{t('controlLayers.controlLayer')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addIPAdapter}>
{t('controlLayers.ipAdapter', { count: 1 })}
{t('controlLayers.ipAdapter')}
</MenuItem>
</>
);

View File

@ -57,7 +57,7 @@ const marks = [
mapOpacityToSliderValue(1),
];
const sliderDefaultValue = mapOpacityToSliderValue(100);
const sliderDefaultValue = mapOpacityToSliderValue(1);
const snapCandidates = marks.slice(1, marks.length - 1);
@ -134,7 +134,11 @@ export const SelectedEntityOpacity = memo(() => {
return (
<Popover>
<FormControl w="min-content" gap={2}>
<FormControl
w="min-content"
gap={2}
isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'ip_adapter'}
>
<FormLabel m={0}>{t('controlLayers.opacity')}</FormLabel>
<PopoverAnchor>
<NumberInput
@ -152,7 +156,6 @@ export const SelectedEntityOpacity = memo(() => {
onKeyDown={onKeyDown}
clampValueOnBlur={false}
variant="outline"
isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'ip_adapter'}
>
<NumberInputField paddingInlineEnd={7} />
<PopoverTrigger>
@ -164,6 +167,7 @@ export const SelectedEntityOpacity = memo(() => {
position="absolute"
insetInlineEnd={0}
h="full"
isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'ip_adapter'}
/>
</PopoverTrigger>
</NumberInput>
@ -181,6 +185,7 @@ export const SelectedEntityOpacity = memo(() => {
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'ip_adapter'}
/>
</PopoverBody>
</PopoverContent>

View File

@ -1,29 +0,0 @@
import { Button, ButtonGroup } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSessionSlice, sessionModeChanged } from 'features/controlLayers/store/canvasSessionSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const selectCanvasMode = createSelector(selectCanvasSessionSlice, (canvasSession) => canvasSession.mode);
export const CanvasModeSwitcher = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const mode = useAppSelector(selectCanvasMode);
const onClickGenerate = useCallback(() => dispatch(sessionModeChanged({ mode: 'generate' })), [dispatch]);
const onClickCompose = useCallback(() => dispatch(sessionModeChanged({ mode: 'compose' })), [dispatch]);
return (
<ButtonGroup variant="outline">
<Button onClick={onClickGenerate} colorScheme={mode === 'generate' ? 'invokeBlue' : 'base'}>
{t('controlLayers.generateMode')}
</Button>
<Button onClick={onClickCompose} colorScheme={mode === 'compose' ? 'invokeBlue' : 'base'}>
{t('controlLayers.composeMode')}
</Button>
</ButtonGroup>
);
});
CanvasModeSwitcher.displayName = 'CanvasModeSwitcher';

View File

@ -1,21 +1,37 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { Box, ContextMenu, Divider, Flex, MenuList } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasAddEntityButtons } from 'features/controlLayers/components/CanvasAddEntityButtons';
import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityList';
import { EntityListActionBar } from 'features/controlLayers/components/CanvasEntityList/EntityListActionBar';
import { CanvasEntityListMenuItems } from 'features/controlLayers/components/CanvasEntityList/EntityListActionBarAddLayerMenuItems';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectHasEntities } from 'features/controlLayers/store/selectors';
import { memo } from 'react';
import { memo, useCallback } from 'react';
export const CanvasPanelContent = memo(() => {
const hasEntities = useAppSelector(selectHasEntities);
const renderMenu = useCallback(
() => (
<MenuList>
<CanvasEntityListMenuItems />
</MenuList>
),
[]
);
return (
<CanvasManagerProviderGate>
<Flex flexDir="column" gap={2} w="full" h="full">
<EntityListActionBar />
<Divider py={0} />
{!hasEntities && <CanvasAddEntityButtons />}
{hasEntities && <CanvasEntityList />}
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} stopImmediatePropagation stopPropagation>
{(ref) => (
<Box ref={ref} w="full" h="full">
{!hasEntities && <CanvasAddEntityButtons />}
{hasEntities && <CanvasEntityList />}
</Box>
)}
</ContextMenu>
</Flex>
</CanvasManagerProviderGate>
);

View File

@ -0,0 +1,59 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IconSwitch } from 'common/components/IconSwitch';
import { selectIsComposing, sessionSendToCanvasChanged } from 'features/controlLayers/store/canvasSessionSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold, PiPaintBrushBold } from 'react-icons/pi';
const TooltipSendToGallery = memo(() => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{t('controlLayers.sendToGallery')}</Text>
<Text fontWeight="normal">{t('controlLayers.sendToGalleryDesc')}</Text>
</Flex>
);
});
TooltipSendToGallery.displayName = 'TooltipSendToGallery';
const TooltipSendToCanvas = memo(() => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{t('controlLayers.sendToCanvas')}</Text>
<Text fontWeight="normal">{t('controlLayers.sendToCanvasDesc')}</Text>
</Flex>
);
});
TooltipSendToCanvas.displayName = 'TooltipSendToCanvas';
export const CanvasSendToToggle = memo(() => {
const dispatch = useAppDispatch();
const isComposing = useAppSelector(selectIsComposing);
const onChange = useCallback(
(isChecked: boolean) => {
dispatch(sessionSendToCanvasChanged(isChecked));
},
[dispatch]
);
return (
<IconSwitch
isChecked={isComposing}
onChange={onChange}
iconUnchecked={<PiImageBold />}
tooltipUnchecked={<TooltipSendToGallery />}
iconChecked={<PiPaintBrushBold />}
tooltipChecked={<TooltipSendToCanvas />}
ariaLabel="Toggle canvas mode"
/>
);
});
CanvasSendToToggle.displayName = 'CanvasSendToToggle';

View File

@ -1,8 +1,7 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
@ -29,8 +28,7 @@ export const ControlLayer = memo(({ id }: Props) => {
<CanvasEntityEditableTitle />
<Spacer />
<ControlLayerBadges />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<CanvasEntitySettingsWrapper>
<ControlLayerControlAdapter />

View File

@ -19,8 +19,6 @@ export const CanvasEditor = memo(() => {
<Flex
tabIndex={-1}
ref={ref}
layerStyle="first"
p={2}
borderRadius="base"
position="relative"
flexDirection="column"
@ -32,14 +30,14 @@ export const CanvasEditor = memo(() => {
>
<ControlLayersToolbar />
<StageComponent />
<Flex position="absolute" bottom={16} gap={2} align="center" justify="center">
<Flex position="absolute" bottom={8} gap={2} align="center" justify="center">
<CanvasManagerProviderGate>
<StagingAreaIsStagingGate>
<StagingAreaToolbar />
</StagingAreaIsStagingGate>
</CanvasManagerProviderGate>
</Flex>
<Flex position="absolute" bottom={16}>
<Flex position="absolute" bottom={8}>
<CanvasManagerProviderGate>
<Filter />
<Transform />

View File

@ -1,19 +1,20 @@
/* eslint-disable i18next/no-literal-string */
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { CanvasModeSwitcher } from 'features/controlLayers/components/CanvasModeSwitcher';
import { CanvasResetViewButton } from 'features/controlLayers/components/CanvasResetViewButton';
import { CanvasScale } from 'features/controlLayers/components/CanvasScale';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useCanvasUndoRedo } from 'features/controlLayers/hooks/useCanvasUndoRedo';
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { ViewerToggleMenu } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
import { ViewerToggle } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
import { memo } from 'react';
export const ControlLayersToolbar = memo(() => {
useCanvasUndoRedo();
return (
<CanvasManagerProviderGate>
<Flex w="full" gap={2} alignItems="center">
@ -26,10 +27,8 @@ export const ControlLayersToolbar = memo(() => {
<CanvasResetViewButton />
<Spacer />
<ToolFillColorPicker />
<CanvasModeSwitcher />
<UndoRedoButtonGroup />
<CanvasSettingsPopover />
<ViewerToggleMenu />
<ViewerToggle />
</Flex>
</CanvasManagerProviderGate>
);

View File

@ -15,18 +15,6 @@ export const Filter = memo(() => {
const isFiltering = useStore(canvasManager.filter.$isFiltering);
const isProcessing = useStore(canvasManager.filter.$isProcessing);
const previewFilter = useCallback(() => {
canvasManager.filter.previewFilter();
}, [canvasManager.filter]);
const applyFilter = useCallback(() => {
canvasManager.filter.applyFilter();
}, [canvasManager.filter]);
const cancelFilter = useCallback(() => {
canvasManager.filter.cancelFilter();
}, [canvasManager.filter]);
const onChangeFilterConfig = useCallback(
(filterConfig: FilterConfig) => {
canvasManager.filter.$config.set(filterConfig);
@ -65,24 +53,27 @@ export const Filter = memo(() => {
<FilterSettings filterConfig={config} onChange={onChangeFilterConfig} />
<ButtonGroup isAttached={false} size="sm" alignSelf="self-end">
<Button
variant="ghost"
leftIcon={<PiShootingStarBold />}
onClick={previewFilter}
onClick={canvasManager.filter.previewFilter}
isLoading={isProcessing}
loadingText={t('controlLayers.filter.preview')}
>
{t('controlLayers.filter.preview')}
</Button>
<Button
variant="ghost"
leftIcon={<PiCheckBold />}
onClick={applyFilter}
onClick={canvasManager.filter.applyFilter}
isLoading={isProcessing}
loadingText={t('controlLayers.filter.apply')}
>
{t('controlLayers.filter.apply')}
</Button>
<Button
variant="ghost"
leftIcon={<PiXBold />}
onClick={cancelFilter}
onClick={canvasManager.filter.cancelFilter}
isLoading={isProcessing}
loadingText={t('controlLayers.filter.cancel')}
>

View File

@ -1,53 +1,27 @@
import { Box, Flex, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { Grid, GridItem, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { round } from 'lodash-es';
import { memo } from 'react';
const selectBbox = createSelector(selectCanvasSlice, (canvas) => canvas.bbox);
export const HeadsUpDisplay = memo(() => {
const canvasManager = useCanvasManager();
const stageAttrs = useStore(canvasManager.stateApi.$stageAttrs);
const cursorPos = useStore(canvasManager.stateApi.$lastCursorPos);
const isDrawing = useStore(canvasManager.stateApi.$isDrawing);
const isMouseDown = useStore(canvasManager.stateApi.$isMouseDown);
const lastMouseDownPos = useStore(canvasManager.stateApi.$lastMouseDownPos);
const lastAddedPoint = useStore(canvasManager.stateApi.$lastAddedPoint);
const bbox = useAppSelector(selectBbox);
return (
<Flex flexDir="column" bg="blackAlpha.400" borderBottomEndRadius="base" p={2} minW={64} gap={2}>
<HUDItem label="Zoom" value={`${round(stageAttrs.scale * 100, 2)}%`} />
<HUDItem label="Stage Pos" value={`${round(stageAttrs.x, 3)}, ${round(stageAttrs.y, 3)}`} />
<HUDItem
label="Stage Size"
value={`${round(stageAttrs.width / stageAttrs.scale, 2)}×${round(stageAttrs.height / stageAttrs.scale, 2)} px`}
/>
<HUDItem label="BBox Size" value={`${bbox.rect.width}×${bbox.rect.height} px`} />
<HUDItem label="BBox Position" value={`${bbox.rect.x}, ${bbox.rect.y}`} />
<HUDItem label="BBox Width % 8" value={round(bbox.rect.width % 8, 2)} />
<HUDItem label="BBox Height % 8" value={round(bbox.rect.height % 8, 2)} />
<HUDItem label="BBox X % 8" value={round(bbox.rect.x % 8, 2)} />
<HUDItem label="BBox Y % 8" value={round(bbox.rect.y % 8, 2)} />
<HUDItem
label="Cursor Position"
value={cursorPos ? `${round(cursorPos.x, 2)}, ${round(cursorPos.y, 2)}` : '?, ?'}
/>
<HUDItem label="Is Drawing" value={isDrawing ? 'True' : 'False'} />
<HUDItem label="Is Mouse Down" value={isMouseDown ? 'True' : 'False'} />
<HUDItem
label="Last Mouse Down Pos"
value={lastMouseDownPos ? `${round(lastMouseDownPos.x, 2)}, ${round(lastMouseDownPos.y, 2)}` : '?, ?'}
/>
<HUDItem
label="Last Added Point"
value={lastAddedPoint ? `${round(lastAddedPoint.x, 2)}, ${round(lastAddedPoint.y, 2)}` : '?, ?'}
/>
</Flex>
<Grid
bg="base.900"
borderBottomEndRadius="base"
p={2}
gap={2}
borderRadius="base"
templateColumns="auto auto"
opacity={0.6}
>
<HUDItem label="BBox" value={`${bbox.rect.width}×${bbox.rect.height} px`} />
<HUDItem label="Scaled BBox" value={`${bbox.scaledSize.width}×${bbox.scaledSize.height} px`} />
</Grid>
);
});
@ -55,12 +29,14 @@ HeadsUpDisplay.displayName = 'HeadsUpDisplay';
const HUDItem = memo(({ label, value }: { label: string; value: string | number }) => {
return (
<Box display="inline-block" lineHeight={1}>
<Text as="span">{label}: </Text>
<Text as="span" fontWeight="semibold">
{value}
</Text>
</Box>
<>
<GridItem>
<Text textAlign="end">{label}: </Text>
</GridItem>
<GridItem fontWeight="semibold">
<Text>{value}</Text>
</GridItem>
</>
);
});

View File

@ -1,7 +1,7 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
@ -18,10 +18,10 @@ export const IPAdapter = memo(({ id }: Props) => {
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader ps={4}>
<CanvasEntityHeader ps={4} py={5}>
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityEnabledToggle />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<IPAdapterSettings />
</CanvasEntityContainer>

View File

@ -1,8 +1,7 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { EntityMaskAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
@ -25,8 +24,7 @@ export const InpaintMask = memo(({ id }: Props) => {
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
</CanvasEntityContainer>
</EntityMaskAdapterGate>

View File

@ -1,8 +1,7 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { EntityLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
@ -25,8 +24,7 @@ export const RasterLayer = memo(({ id }: Props) => {
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
</CanvasEntityContainer>
</EntityLayerAdapterGate>

View File

@ -1,8 +1,7 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { RegionalGuidanceBadges } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges';
@ -28,8 +27,7 @@ export const RegionalGuidance = memo(({ id }: Props) => {
<CanvasEntityEditableTitle />
<Spacer />
<RegionalGuidanceBadges />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<RegionalGuidanceSettings />
</CanvasEntityContainer>

View File

@ -12,10 +12,13 @@ export const RegionalGuidanceDeletePromptButton = memo(({ onDelete }: Props) =>
return (
<Tooltip label={t('controlLayers.deletePrompt')}>
<IconButton
variant="promptOverlay"
variant="link"
aria-label={t('controlLayers.deletePrompt')}
icon={<PiTrashSimpleBold />}
onClick={onDelete}
flexGrow={0}
size="sm"
p={0}
/>
</Tooltip>
);

View File

@ -1,17 +1,21 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Textarea } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { RegionalGuidanceDeletePromptButton } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceDeletePromptButton';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { rgNegativePromptChanged } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
const _focusVisible: SystemStyleObject = {
outline: 'none',
};
export const RegionalGuidanceNegativePrompt = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const selectPrompt = useMemo(
@ -49,14 +53,25 @@ export const RegionalGuidanceNegativePrompt = memo(() => {
placeholder={t('parameters.negativePromptPlaceholder')}
onChange={onChange}
onKeyDown={onKeyDown}
variant="darkFilled"
paddingRight={30}
variant="outline"
paddingInlineStart={2}
paddingInlineEnd={8}
fontSize="sm"
zIndex="0 !important"
_focusVisible={_focusVisible}
/>
<PromptOverlayButtonWrapper>
<Flex
flexDir="column"
gap={2}
position="absolute"
insetBlockStart={2}
insetInlineEnd={0}
alignItems="center"
justifyContent="center"
>
<RegionalGuidanceDeletePromptButton onDelete={onDeletePrompt} />
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
</Flex>
</Box>
</PromptPopover>
);

View File

@ -1,17 +1,21 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Textarea } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { RegionalGuidanceDeletePromptButton } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceDeletePromptButton';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { rgPositivePromptChanged } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
const _focusVisible: SystemStyleObject = {
outline: 'none',
};
export const RegionalGuidancePositivePrompt = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const selectPrompt = useMemo(
@ -49,14 +53,25 @@ export const RegionalGuidancePositivePrompt = memo(() => {
placeholder={t('parameters.positivePromptPlaceholder')}
onChange={onChange}
onKeyDown={onKeyDown}
variant="darkFilled"
paddingRight={30}
variant="outline"
paddingInlineStart={2}
paddingInlineEnd={8}
minH={28}
zIndex="0 !important"
_focusVisible={_focusVisible}
/>
<PromptOverlayButtonWrapper>
<Flex
flexDir="column"
gap={2}
position="absolute"
insetBlockStart={2}
insetInlineEnd={0}
alignItems="center"
justifyContent="center"
>
<RegionalGuidanceDeletePromptButton onDelete={onDeletePrompt} />
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
</Flex>
</Box>
</PromptPopover>
);

View File

@ -35,7 +35,7 @@ export const RegionalGuidanceSettings = memo(() => {
{flags.hasPositivePrompt && (
<>
<RegionalGuidancePositivePrompt />
{(flags.hasNegativePrompt || flags.hasIPAdapters) && <Divider />}
{!flags.hasNegativePrompt && flags.hasIPAdapters && <Divider />}
</>
)}
{flags.hasNegativePrompt && (

View File

@ -18,6 +18,7 @@ import { CanvasSettingsInvertScrollCheckbox } from 'features/controlLayers/compo
import { CanvasSettingsLogDebugInfoButton } from 'features/controlLayers/components/Settings/CanvasSettingsLogDebugInfo';
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
import { CanvasSettingsResetButton } from 'features/controlLayers/components/Settings/CanvasSettingsResetButton';
import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiSettings4Fill } from 'react-icons/ri';
@ -27,7 +28,7 @@ export const CanvasSettingsPopover = memo(() => {
return (
<Popover isLazy>
<PopoverTrigger>
<IconButton aria-label={t('common.settingsLabel')} icon={<RiSettings4Fill />} />
<IconButton aria-label={t('common.settingsLabel')} icon={<RiSettings4Fill />} variant="ghost" />
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
@ -37,6 +38,7 @@ export const CanvasSettingsPopover = memo(() => {
<CanvasSettingsInvertScrollCheckbox />
<CanvasSettingsClipToBboxCheckbox />
<CanvasSettingsDynamicGridSwitch />
<CanvasSettingsShowHUDSwitch />
<CanvasSettingsResetButton />
<DebugSettings />
</Flex>

View File

@ -0,0 +1,28 @@
import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSettingsSlice, settingsShowHUDToggled } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);
export const CanvasSettingsShowHUDSwitch = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const showHUD = useAppSelector(selectShowHUD);
const onChange = useCallback(() => {
dispatch(settingsShowHUDToggled());
}, [dispatch]);
return (
<FormControl>
<FormLabel m={0} flexGrow={1}>
{t('controlLayers.showHUD')}
</FormLabel>
<Switch size="sm" isChecked={showHUD} onChange={onChange} />
</FormControl>
);
});
CanvasSettingsShowHUDSwitch.displayName = 'CanvasSettingsShowHUDSwitch';

View File

@ -8,20 +8,18 @@ import { useAppSelector } from 'app/store/storeHooks';
import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import Konva from 'konva';
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
import { v4 as uuidv4 } from 'uuid';
const log = logger('canvas');
const showHud = false;
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
Konva.showWarnings = false;
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null, asPreview: boolean) => {
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null) => {
const store = useAppStore();
const socket = useStore($socket);
const dpr = useDevicePixelRatio({ round: false });
@ -42,28 +40,25 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null,
const manager = new CanvasManager(stage, container, store, socket);
manager.initialize();
return manager.destroy;
}, [asPreview, container, socket, stage, store]);
}, [container, socket, stage, store]);
useLayoutEffect(() => {
Konva.pixelRatio = dpr;
}, [dpr]);
};
type Props = {
asPreview?: boolean;
};
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.dynamicGrid);
const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);
export const StageComponent = memo(({ asPreview = false }: Props) => {
export const StageComponent = memo(() => {
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);
const [stage] = useState(
() =>
new Konva.Stage({
id: uuidv4(),
id: getPrefixedId('konva_stage'),
container: document.createElement('div'),
listening: !asPreview,
})
);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
@ -72,7 +67,7 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
setContainer(el);
}, []);
useStageRenderer(stage, container, asPreview);
useStageRenderer(stage, container);
useEffect(
() => () => {
@ -82,10 +77,11 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
);
return (
<Flex position="relative" w="full" h="full" bg={dynamicGrid ? 'base.850' : 'base.900'}>
<Flex position="relative" w="full" h="full" bg={dynamicGrid ? 'base.850' : 'base.900'} borderRadius="base">
{!dynamicGrid && (
<Flex
position="absolute"
borderRadius="base"
bgImage={TRANSPARENCY_CHECKER_PATTERN}
top={0}
right={0}
@ -102,15 +98,12 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
left={0}
ref={containerRef}
borderRadius="base"
border={1}
borderStyle="solid"
borderColor="base.700"
overflow="hidden"
data-testid="control-layers-canvas"
/>
{!asPreview && (
<Flex position="absolute" top={0} insetInlineStart={0} pointerEvents="none">
{showHud && <HeadsUpDisplay />}
{showHUD && (
<Flex position="absolute" top={1} insetInlineStart={1} pointerEvents="none">
<HeadsUpDisplay />
</Flex>
)}
</Flex>

View File

@ -98,9 +98,9 @@ export const StagingAreaToolbar = memo(() => {
onPrev,
{
preventDefault: true,
enabled: isCanvasActive,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive]
[isCanvasActive, shouldShowStagedImage, imageCount]
);
useHotkeys(
@ -108,9 +108,9 @@ export const StagingAreaToolbar = memo(() => {
onNext,
{
preventDefault: true,
enabled: isCanvasActive,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive]
[isCanvasActive, shouldShowStagedImage, imageCount]
);
useHotkeys(
@ -118,9 +118,9 @@ export const StagingAreaToolbar = memo(() => {
onAccept,
{
preventDefault: true,
enabled: isCanvasActive,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive]
[isCanvasActive, shouldShowStagedImage, imageCount]
);
const counterText = useMemo(() => {

View File

@ -1,4 +1,4 @@
import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
import { Box, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIColorPicker from 'common/components/IAIColorPicker';
@ -23,17 +23,13 @@ export const ToolFillColorPicker = memo(() => {
return (
<Popover isLazy>
<PopoverTrigger>
<Flex
as="button"
aria-label={t('controlLayers.brushColor')}
borderRadius="full"
borderWidth={1}
bg={rgbaColorToString(fill)}
w={8}
h={8}
cursor="pointer"
tabIndex={-1}
/>
<Flex role="button" aria-label={t('controlLayers.fill.fillColor')} tabIndex={-1} w={8} h={8}>
<Tooltip label={t('controlLayers.fill.fillColor')}>
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Box borderRadius="full" w={6} h={6} borderWidth={1} bg={rgbaColorToString(fill)} />
</Flex>
</Tooltip>
</Flex>
</PopoverTrigger>
<PopoverContent>
<PopoverBody minH={64}>

View File

@ -1,4 +1,4 @@
import { Button, ButtonGroup, Flex, Heading } from '@invoke-ai/ui-library';
import { Button, ButtonGroup, Flex, Heading, Spacer } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
@ -6,9 +6,9 @@ import {
useEntityIdentifierContext,
} from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter';
import { memo, useCallback } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCheckBold, PiXBold } from 'react-icons/pi';
import { PiArrowsCounterClockwiseBold, PiCheckBold, PiXBold } from 'react-icons/pi';
const TransformBox = memo(() => {
const { t } = useTranslation();
@ -16,14 +16,6 @@ const TransformBox = memo(() => {
const adapter = useEntityAdapter(entityIdentifier);
const isProcessing = useStore(adapter.transformer.$isProcessing);
const applyTransform = useCallback(() => {
adapter.transformer.applyTransform();
}, [adapter.transformer]);
const cancelFilter = useCallback(() => {
adapter.transformer.stopTransform();
}, [adapter.transformer]);
return (
<Flex
bg="base.800"
@ -40,16 +32,33 @@ const TransformBox = memo(() => {
<Heading size="md" color="base.300" userSelect="none">
{t('controlLayers.tool.transform')}
</Heading>
<ButtonGroup isAttached={false} size="sm" alignSelf="self-end">
<ButtonGroup isAttached={false} size="sm" w="full">
<Button
leftIcon={<PiArrowsCounterClockwiseBold />}
onClick={adapter.transformer.resetTransform}
isLoading={isProcessing}
loadingText={t('controlLayers.reset')}
variant="ghost"
>
{t('accessibility.reset')}
</Button>
<Spacer />
<Button
leftIcon={<PiCheckBold />}
onClick={applyTransform}
onClick={adapter.transformer.applyTransform}
isLoading={isProcessing}
loadingText={t('common.apply')}
variant="ghost"
>
{t('common.apply')}
</Button>
<Button leftIcon={<PiXBold />} onClick={cancelFilter} isLoading={isProcessing} loadingText={t('common.cancel')}>
<Button
leftIcon={<PiXBold />}
onClick={adapter.transformer.stopTransform}
isLoading={isProcessing}
loadingText={t('common.cancel')}
variant="ghost"
>
{t('common.cancel')}
</Button>
</ButtonGroup>

View File

@ -0,0 +1,70 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import {
controlLayerAdded,
inpaintMaskAdded,
ipaAdded,
rasterLayerAdded,
rgAdded,
} from 'features/controlLayers/store/canvasSlice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
type Props = {
type: CanvasEntityIdentifier['type'];
};
export const CanvasEntityAddOfTypeButton = memo(({ type }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
switch (type) {
case 'inpaint_mask':
dispatch(inpaintMaskAdded({ isSelected: true }));
break;
case 'regional_guidance':
dispatch(rgAdded({ isSelected: true }));
break;
case 'raster_layer':
dispatch(rasterLayerAdded({ isSelected: true }));
break;
case 'control_layer':
dispatch(controlLayerAdded({ isSelected: true }));
break;
case 'ip_adapter':
dispatch(ipaAdded({ isSelected: true }));
break;
}
}, [dispatch, type]);
const label = useMemo(() => {
switch (type) {
case 'inpaint_mask':
return t('controlLayers.addInpaintMask');
case 'regional_guidance':
return t('controlLayers.addRegionalGuidance');
case 'raster_layer':
return t('controlLayers.addRasterLayer');
case 'control_layer':
return t('controlLayers.addControlLayer');
case 'ip_adapter':
return t('controlLayers.addIPAdapter');
}
}, [type, t]);
return (
<IconButton
size="sm"
aria-label={label}
tooltip={label}
variant="link"
icon={<PiPlusBold />}
onClick={onClick}
alignSelf="stretch"
/>
);
});
CanvasEntityAddOfTypeButton.displayName = 'CanvasEntityAddOfTypeButton';

View File

@ -0,0 +1,31 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
export const CanvasEntityDeleteButton = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(entityDeleted({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<IconButton
size="sm"
aria-label={t('common.delete')}
tooltip={t('common.delete')}
variant="link"
alignSelf="stretch"
icon={<PiTrashSimpleFill />}
onClick={onClick}
colorScheme="error"
/>
);
});
CanvasEntityDeleteButton.displayName = 'CanvasEntityDeleteButton';

View File

@ -1,34 +1,29 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityIsEnabled } from 'features/controlLayers/hooks/useEntityIsEnabled';
import { entityIsEnabledToggled } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback, useRef } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCheckBold } from 'react-icons/pi';
import { PiCircleBold, PiCircleFill } from 'react-icons/pi';
export const CanvasEntityEnabledToggle = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const ref = useRef<HTMLButtonElement>(null);
const isEnabled = useEntityIsEnabled(entityIdentifier);
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(entityIsEnabledToggled({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
const isHovered = useBoolean(false);
return (
<IconButton
ref={ref}
size="sm"
onMouseOver={isHovered.setTrue}
onMouseOut={isHovered.setFalse}
aria-label={t(isEnabled ? 'common.enabled' : 'common.disabled')}
tooltip={t(isEnabled ? 'common.enabled' : 'common.disabled')}
variant="ghost"
icon={isEnabled || isHovered.isTrue ? <PiCheckBold /> : undefined}
variant="link"
alignSelf="stretch"
icon={isEnabled ? <PiCircleFill /> : <PiCircleBold />}
onClick={onClick}
/>
);

View File

@ -1,6 +1,7 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
import { useBoolean } from 'common/hooks/useBoolean';
import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
@ -53,6 +54,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props
</Text>
<Spacer />
</Flex>
<CanvasEntityAddOfTypeButton type={type} />
{type !== 'ip_adapter' && <CanvasEntityTypeIsHiddenToggle type={type} />}
</Flex>
<Collapse in={collapse.isTrue}>

View File

@ -0,0 +1,20 @@
import { Flex } from '@invoke-ai/ui-library';
import { CanvasEntityDeleteButton } from 'features/controlLayers/components/common/CanvasEntityDeleteButton';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { memo } from 'react';
export const CanvasEntityHeaderCommonActions = memo(() => {
const entityIdentifier = useEntityIdentifierContext();
return (
<Flex alignSelf="stretch">
{entityIdentifier.type !== 'ip_adapter' && <CanvasEntityIsLockedToggle />}
<CanvasEntityEnabledToggle />
<CanvasEntityDeleteButton />
</Flex>
);
});
CanvasEntityHeaderCommonActions.displayName = 'CanvasEntityHeaderCommonActions';

View File

@ -1,34 +1,29 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import { entityIsLockedToggled } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback, useRef } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiLockSimpleFill } from 'react-icons/pi';
import { PiLockSimpleFill, PiLockSimpleOpenBold } from 'react-icons/pi';
export const CanvasEntityIsLockedToggle = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const ref = useRef<HTMLButtonElement>(null);
const isLocked = useEntityIsLocked(entityIdentifier);
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(entityIsLockedToggled({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
const isHovered = useBoolean(false);
return (
<IconButton
ref={ref}
size="sm"
onMouseOver={isHovered.setTrue}
onMouseOut={isHovered.setFalse}
aria-label={t(isLocked ? 'controlLayers.locked' : 'controlLayers.unlocked')}
tooltip={t(isLocked ? 'controlLayers.locked' : 'controlLayers.unlocked')}
variant="ghost"
icon={isLocked || isHovered.isTrue ? <PiLockSimpleFill /> : undefined}
variant="link"
alignSelf="stretch"
icon={isLocked ? <PiLockSimpleFill /> : <PiLockSimpleOpenBold />}
onClick={onClick}
/>
);

View File

@ -11,7 +11,10 @@ import { useSelector } from 'react-redux';
const ChakraCanvas = chakra.canvas;
const PADDING = 4;
const PADDING = 2;
const CONTAINER_WIDTH = 36; // this is size 12 in our theme - need it in px for the canvas
const CONTAINER_WIDTH_PX = `${CONTAINER_WIDTH}px`;
export const CanvasEntityPreviewImage = memo(() => {
const entityIdentifier = useEntityIdentifierContext();
@ -31,11 +34,10 @@ export const CanvasEntityPreviewImage = memo(() => {
[entityIdentifier]
);
const maskColor = useSelector(selectMaskColor);
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cache = useStore(adapter.renderer.$canvasCache);
useEffect(() => {
if (!cache || !canvasRef.current || !containerRef.current) {
if (!cache || !canvasRef.current) {
return;
}
const ctx = canvasRef.current.getContext('2d');
@ -49,7 +51,7 @@ export const CanvasEntityPreviewImage = memo(() => {
canvasRef.current.width = rect.width;
canvasRef.current.height = rect.height;
const scale = containerRef.current.offsetWidth / rect.width;
const scale = CONTAINER_WIDTH / rect.width;
const sx = rect.x;
const sy = rect.y;
@ -72,11 +74,10 @@ export const CanvasEntityPreviewImage = memo(() => {
return (
<Flex
position="relative"
ref={containerRef}
alignItems="center"
justifyContent="center"
w={12}
h={12}
w={CONTAINER_WIDTH_PX}
h={CONTAINER_WIDTH_PX}
borderRadius="sm"
borderWidth={1}
bg="base.900"

View File

@ -1,16 +1,14 @@
/* eslint-disable i18next/no-literal-string */
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors';
import { memo, useCallback } from 'react';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowClockwiseBold, PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { useDispatch } from 'react-redux';
export const UndoRedoButtonGroup = memo(() => {
const { t } = useTranslation();
export const useCanvasUndoRedo = () => {
useAssertSingleton('useCanvasUndoRedo');
const dispatch = useDispatch();
const mayUndo = useAppSelector(selectCanvasMayUndo);
@ -27,25 +25,4 @@ export const UndoRedoButtonGroup = memo(() => {
mayRedo,
handleRedo,
]);
return (
<ButtonGroup>
<IconButton
aria-label={t('unifiedCanvas.undo')}
tooltip={t('unifiedCanvas.undo')}
onClick={handleUndo}
icon={<PiArrowCounterClockwiseBold />}
isDisabled={!mayUndo}
/>
<IconButton
aria-label={t('unifiedCanvas.redo')}
tooltip={t('unifiedCanvas.redo')}
onClick={handleRedo}
icon={<PiArrowClockwiseBold />}
isDisabled={!mayRedo}
/>
</ButtonGroup>
);
});
UndoRedoButtonGroup.displayName = 'UndoRedoButtonGroup';
};

View File

@ -29,15 +29,15 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
const parts: string[] = [];
if (entityIdentifier.type === 'inpaint_mask') {
parts.push(t('controlLayers.inpaintMask', { count: 1 }));
parts.push(t('controlLayers.inpaintMask'));
} else if (entityIdentifier.type === 'control_layer') {
parts.push(t('controlLayers.controlLayer', { count: 1 }));
parts.push(t('controlLayers.controlLayer'));
} else if (entityIdentifier.type === 'raster_layer') {
parts.push(t('controlLayers.rasterLayer', { count: 1 }));
parts.push(t('controlLayers.rasterLayer'));
} else if (entityIdentifier.type === 'ip_adapter') {
parts.push(t('common.ipAdapter', { count: 1 }));
parts.push(t('common.ipAdapter'));
} else if (entityIdentifier.type === 'regional_guidance') {
parts.push(t('controlLayers.regionalGuidance', { count: 1 }));
parts.push(t('controlLayers.regionalGuidance'));
} else {
assert(false, 'Unexpected entity type');
}

View File

@ -8,15 +8,15 @@ export const useEntityTypeString = (type: CanvasEntityIdentifier['type']): strin
const typeString = useMemo(() => {
switch (type) {
case 'control_layer':
return t('controlLayers.controlLayer', { count: 0 });
return t('controlLayers.controlLayer');
case 'raster_layer':
return t('controlLayers.rasterLayer', { count: 0 });
return t('controlLayers.rasterLayer');
case 'inpaint_mask':
return t('controlLayers.inpaintMask', { count: 0 });
return t('controlLayers.inpaintMask');
case 'regional_guidance':
return t('controlLayers.regionalGuidance', { count: 0 });
return t('controlLayers.regionalGuidance');
case 'ip_adapter':
return t('controlLayers.ipAdapter', { count: 0 });
return t('controlLayers.ipAdapter');
default:
return '';
}

View File

@ -112,12 +112,11 @@ export class CanvasEntityLayerAdapter extends CanvasModuleABC {
this.renderer.updateOpacity(opacity);
}
if (state.type === 'control_layer' && this.state.type === 'control_layer') {
if (this.isFirstRender || state.withTransparencyEffect !== this.state.withTransparencyEffect) {
if (state.type === 'control_layer' && prevState.type === 'control_layer') {
if (this.isFirstRender || state.withTransparencyEffect !== prevState.withTransparencyEffect) {
this.renderer.updateTransparencyEffect(state.withTransparencyEffect);
}
}
// this.transformer.syncInteractionState();
if (this.isFirstRender) {
this.transformer.updateBbox();

View File

@ -259,13 +259,7 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
// This is called when a transform anchor is dragged. By this time, the transform constraints in the above
// callbacks have been enforced, and the transformer has updated its nodes' attributes. We need to pass the
// updated attributes to the object group, propagating the transformation on down.
this.parent.renderer.konva.objectGroup.setAttrs({
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
scaleX: this.konva.proxyRect.scaleX(),
scaleY: this.konva.proxyRect.scaleY(),
rotation: this.konva.proxyRect.rotation(),
});
this.syncObjectGroupWithProxyRect();
});
this.konva.transformer.on('transformend', () => {
@ -395,6 +389,54 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
this.parent.konva.layer.add(this.konva.transformer);
}
// TODO(psyche): These don't work when the entity is rotated, need to do some math to offset the flip after rotation
// flipHorizontal = () => {
// if (!this.isTransforming || this.$isProcessing.get()) {
// return;
// }
// // Flipping horizontally = flipping across the vertical axis:
// // - Flip by negating the x scale
// // - Restore position by translating the rect rightwards by the width of the rect
// const x = this.konva.proxyRect.x();
// const width = this.konva.proxyRect.width();
// const scaleX = this.konva.proxyRect.scaleX();
// this.konva.proxyRect.setAttrs({
// scaleX: -scaleX,
// x: x + width * scaleX,
// });
// this.syncObjectGroupWithProxyRect();
// };
// flipVertical = () => {
// if (!this.isTransforming || this.$isProcessing.get()) {
// return;
// }
// // Flipping vertically = flipping across the horizontal axis:
// // - Flip by negating the y scale
// // - Restore position by translating the rect downwards by the height of the rect
// const y = this.konva.proxyRect.y();
// const height = this.konva.proxyRect.height();
// const scaleY = this.konva.proxyRect.scaleY();
// this.konva.proxyRect.setAttrs({
// scaleY: -scaleY,
// y: y + height * scaleY,
// });
// this.syncObjectGroupWithProxyRect();
// };
syncObjectGroupWithProxyRect = () => {
this.parent.renderer.konva.objectGroup.setAttrs({
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
scaleX: this.konva.proxyRect.scaleX(),
scaleY: this.konva.proxyRect.scaleY(),
rotation: this.konva.proxyRect.rotation(),
});
};
/**
* Updates the transformer's visual components to match the parent entity's position and bounding box.
* @param position The position of the parent entity
@ -510,6 +552,12 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
this.stopTransform();
};
resetTransform = () => {
this.resetScale();
this.updatePosition();
this.updateBbox();
};
/**
* Stops the transformation of the entity. If the transformation is in progress, the entity will be reset to its
* original state.
@ -520,12 +568,9 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
this.isTransforming = false;
this.setInteractionMode('off');
// Reset the scale of the the entity. We've either replaced the transformed objects with a rasterized image, or
// Reset the transform of the the entity. We've either replaced the transformed objects with a rasterized image, or
// canceled a transformation. In either case, the scale should be reset.
this.resetScale();
this.updatePosition();
this.updateBbox();
this.resetTransform();
this.syncInteractionState();
this.manager.stateApi.$transformingEntity.set(null);
this.$isProcessing.set(false);

View File

@ -250,12 +250,10 @@ export class CanvasToolModule extends CanvasModuleABC {
this.konva.colorPicker.group.visible(tool === 'colorPicker');
};
render = () => {
syncCursorStyle = () => {
const stage = this.manager.stage;
const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount();
const toolState = this.manager.stateApi.getToolState();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const cursorPos = this.manager.stateApi.$lastCursorPos.get();
const isMouseDown = this.manager.stateApi.$isMouseDown.get();
const tool = this.manager.stateApi.$tool.get();
@ -294,6 +292,158 @@ export class CanvasToolModule extends CanvasModuleABC {
// Non-drawable layers don't have tools
stage.container.style.cursor = 'not-allowed';
}
};
renderBrushTool = (cursorPos: Coordinate) => {
const toolState = this.manager.stateApi.getToolState();
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.brush.width / 2;
// The circle is scaled
this.konva.brush.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: rgbaColorToString(brushPreviewFill),
});
// But the borders are in screen-pixels
this.konva.brush.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.brush.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
renderEraserTool = (cursorPos: Coordinate) => {
const toolState = this.manager.stateApi.getToolState();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.eraser.width / 2;
// The circle is scaled
this.konva.eraser.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: 'white',
});
// But the borders are in screen-pixels
this.konva.eraser.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.eraser.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
renderColorPicker = (cursorPos: Coordinate) => {
const toolState = this.manager.stateApi.getToolState();
const colorUnderCursor = this.manager.stateApi.$colorUnderCursor.get();
const colorPickerInnerRadius = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_RADIUS);
const colorPickerOuterRadius = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_RADIUS + CanvasToolModule.COLOR_PICKER_THICKNESS
);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.colorPicker.newColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(colorUnderCursor),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.colorPicker.oldColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(toolState.fill),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.colorPicker.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius,
outerRadius: colorPickerOuterRadius + onePixel,
});
this.konva.colorPicker.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius + onePixel,
outerRadius: colorPickerOuterRadius + twoPixels,
});
const size = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SIZE);
const space = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SPACE);
const innerThickness = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_INNER_THICKNESS);
const outerThickness = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_OUTER_THICKNESS);
this.konva.colorPicker.crosshairNorthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairNorthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairEastOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairEastInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairSouthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairSouthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairWestOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
this.konva.colorPicker.crosshairWestInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
};
render = () => {
const stage = this.manager.stage;
const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const cursorPos = this.manager.stateApi.$lastCursorPos.get();
const tool = this.manager.stateApi.$tool.get();
const isDrawable =
!!selectedEntity &&
selectedEntity.state.isEnabled &&
!selectedEntity.state.isLocked &&
isDrawableEntity(selectedEntity.state);
this.syncCursorStyle();
stage.setIsDraggable(tool === 'view');
@ -305,136 +455,11 @@ export class CanvasToolModule extends CanvasModuleABC {
// No need to render the brush preview if the cursor position or color is missing
if (cursorPos && tool === 'brush') {
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.brush.width / 2;
// The circle is scaled
this.konva.brush.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: rgbaColorToString(brushPreviewFill),
});
// But the borders are in screen-pixels
this.konva.brush.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.brush.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
this.renderBrushTool(cursorPos);
} else if (cursorPos && tool === 'eraser') {
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.eraser.width / 2;
// The circle is scaled
this.konva.eraser.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: 'white',
});
// But the borders are in screen-pixels
this.konva.eraser.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.eraser.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
this.renderEraserTool(cursorPos);
} else if (cursorPos && tool === 'colorPicker') {
const colorUnderCursor = this.manager.stateApi.$colorUnderCursor.get();
const colorPickerInnerRadius = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_RADIUS);
const colorPickerOuterRadius = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_RADIUS + CanvasToolModule.COLOR_PICKER_THICKNESS
);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.colorPicker.newColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(colorUnderCursor),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.colorPicker.oldColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(toolState.fill),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.colorPicker.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius,
outerRadius: colorPickerOuterRadius + onePixel,
});
this.konva.colorPicker.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius + onePixel,
outerRadius: colorPickerOuterRadius + twoPixels,
});
const size = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SIZE);
const space = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SPACE);
const innerThickness = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_CROSSHAIR_INNER_THICKNESS
);
const outerThickness = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_CROSSHAIR_OUTER_THICKNESS
);
this.konva.colorPicker.crosshairNorthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairNorthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairEastOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairEastInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairSouthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairSouthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairWestOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
this.konva.colorPicker.crosshairWestInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
this.renderColorPicker(cursorPos);
}
this.setToolVisibility(tool, isDrawable);
@ -864,6 +889,10 @@ export class CanvasToolModule extends CanvasModuleABC {
this.manager.stateApi.$spaceKey.set(true);
this.manager.stateApi.$lastCursorPos.set(null);
this.manager.stateApi.$lastMouseDownPos.set(null);
} else if (e.key === 'Alt') {
// Select the color picker on alt key down
this.manager.stateApi.$toolBuffer.set(this.manager.stateApi.$tool.get());
this.manager.stateApi.$tool.set('colorPicker');
}
};
@ -880,6 +909,11 @@ export class CanvasToolModule extends CanvasModuleABC {
this.manager.stateApi.$tool.set(toolBuffer ?? 'move');
this.manager.stateApi.$toolBuffer.set(null);
this.manager.stateApi.$spaceKey.set(false);
} else if (e.key === 'Alt') {
// Revert the tool to the previous tool on alt key up
const toolBuffer = this.manager.stateApi.$toolBuffer.get();
this.manager.stateApi.$tool.set(toolBuffer ?? 'move');
this.manager.stateApi.$toolBuffer.set(null);
}
};

View File

@ -1,17 +1,17 @@
import { createAction, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { canvasSlice } from 'features/controlLayers/store/canvasSlice';
import type { SessionMode, StagingAreaImage } from 'features/controlLayers/store/types';
import type { StagingAreaImage } from 'features/controlLayers/store/types';
export type CanvasSessionState = {
mode: SessionMode;
sendToCanvas: boolean;
isStaging: boolean;
stagedImages: StagingAreaImage[];
selectedStagedImageIndex: number;
};
const initialState: CanvasSessionState = {
mode: 'generate',
sendToCanvas: false,
isStaging: false,
stagedImages: [],
selectedStagedImageIndex: 0,
@ -27,6 +27,7 @@ export const canvasSessionSlice = createSlice({
},
sessionImageStaged: (state, action: PayloadAction<{ stagingAreaImage: StagingAreaImage }>) => {
const { stagingAreaImage } = action.payload;
state.isStaging = true;
state.stagedImages.push(stagingAreaImage);
state.selectedStagedImageIndex = state.stagedImages.length - 1;
},
@ -50,9 +51,8 @@ export const canvasSessionSlice = createSlice({
state.stagedImages = [];
state.selectedStagedImageIndex = 0;
},
sessionModeChanged: (state, action: PayloadAction<{ mode: SessionMode }>) => {
const { mode } = action.payload;
state.mode = mode;
sessionSendToCanvasChanged: (state, action: PayloadAction<boolean>) => {
state.sendToCanvas = action.payload;
},
},
});
@ -64,7 +64,7 @@ export const {
sessionStagingAreaReset,
sessionNextStagedImageSelected,
sessionPrevStagedImageSelected,
sessionModeChanged,
sessionSendToCanvasChanged,
} = canvasSessionSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@ -85,3 +85,7 @@ export const sessionStagingAreaImageAccepted = createAction<{ index: number }>(
export const selectCanvasSessionSlice = (s: RootState) => s.canvasSession;
export const selectIsStaging = createSelector(selectCanvasSessionSlice, (canvasSession) => canvasSession.isStaging);
export const selectIsComposing = createSelector(
selectCanvasSessionSlice,
(canvasSession) => canvasSession.sendToCanvas
);

View File

@ -35,10 +35,14 @@ export const canvasSettingsSlice = createSlice({
settingsAutoSaveToggled: (state) => {
state.autoSave = !state.autoSave;
},
settingsShowHUDToggled: (state) => {
state.showHUD = !state.showHUD;
},
},
});
export const { clipToBboxChanged, settingsAutoSaveToggled, settingsDynamicGridToggled } = canvasSettingsSlice.actions;
export const { clipToBboxChanged, settingsAutoSaveToggled, settingsDynamicGridToggled, settingsShowHUDToggled } =
canvasSettingsSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrate = (state: any): any => {

View File

@ -1045,10 +1045,7 @@ export const canvasSlice = createSlice({
const bboxDims = calculateNewSize(state.bbox.aspectRatio.value, optimalDimension * optimalDimension);
state.bbox.rect.width = bboxDims.width;
state.bbox.rect.height = bboxDims.height;
if (state.bbox.scaleMethod === 'auto') {
state.bbox.scaledSize = getScaledBoundingBoxDimensions(bboxDims, optimalDimension);
}
syncScaledSize(state);
}
});
},

View File

@ -317,7 +317,7 @@ export const selectScheduler = createParamsSelector((params) => params.scheduler
export const selectSeamlessXAxis = createParamsSelector((params) => params.seamlessXAxis);
export const selectSeamlessYAxis = createParamsSelector((params) => params.seamlessYAxis);
export const selectSeed = createParamsSelector((params) => params.seed);
export const selectShouldRandomizeSeed = createParamsSelector((params) => params.shouldConcatPrompts);
export const selectShouldRandomizeSeed = createParamsSelector((params) => params.shouldRandomizeSeed);
export const selectVAEPrecision = createParamsSelector((params) => params.vaePrecision);
export const selectIterations = createParamsSelector((params) => params.iterations);
export const selectShouldUseCPUNoise = createParamsSelector((params) => params.shouldUseCpuNoise);

View File

@ -685,8 +685,6 @@ export type StagingAreaImage = {
offsetY: number;
};
export type SessionMode = 'generate' | 'compose';
export type CanvasState = {
_version: 3;
selectedEntityIdentifier: CanvasEntityIdentifier | null;

View File

@ -81,6 +81,7 @@ const DeleteImageModal = () => {
cancelButtonText={t('boards.cancel')}
acceptButtonText={t('controlnet.delete')}
acceptCallback={handleDelete}
useInert={false}
>
<Flex direction="column" gap={3}>
<ImageUsageMessage imageUsage={imageUsageSummary} />

View File

@ -20,7 +20,7 @@ export const DynamicPromptsModal = memo(() => {
const { isOpen, onClose } = useDynamicPromptsModal();
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<Modal isOpen={isOpen} onClose={onClose} isCentered useInert={false}>
<ModalOverlay />
<ModalContent w="80vw" h="80vh" maxW="unset" maxH="unset">
<ModalHeader>{t('dynamicPrompts.dynamicPrompts')}</ModalHeader>

View File

@ -59,7 +59,7 @@ const GalleryPanelContent = () => {
}, [boardSearchText.length, boardSearchDisclosure, boardsListPanel, dispatch]);
return (
<Flex ref={ref} position="relative" flexDirection="column" h="full" w="full" pt={2} tabIndex={-1}>
<Flex ref={ref} position="relative" flexDirection="column" h="full" w="full" tabIndex={-1}>
<Flex alignItems="center" gap={0}>
<GalleryHeader />
<Flex alignItems="center" justifyContent="space-between" w="full">

View File

@ -21,7 +21,7 @@ export const ImageViewer = memo(() => {
<Flex
ref={ref}
tabIndex={-1}
layerStyle="first"
layerStyle="body"
borderRadius="base"
position="absolute"
flexDirection="column"
@ -29,7 +29,6 @@ export const ImageViewer = memo(() => {
right={0}
bottom={0}
left={0}
p={2}
rowGap={4}
alignItems="center"
justifyContent="center"

View File

@ -1,69 +1,61 @@
import {
Button,
Flex,
Icon,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Text,
} from '@invoke-ai/ui-library';
import { Flex, Text } from '@invoke-ai/ui-library';
import { IconSwitch } from 'common/components/IconSwitch';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold, PiCheckBold, PiEyeBold, PiPencilBold } from 'react-icons/pi';
import { PiEyeBold, PiPencilBold } from 'react-icons/pi';
export const ViewerToggleMenu = () => {
const TooltipEdit = memo(() => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{t('common.edit')}</Text>
<Text fontWeight="normal">{t('common.editDesc')}</Text>
</Flex>
);
});
TooltipEdit.displayName = 'TooltipEdit';
const TooltipView = memo(() => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{t('common.view')}</Text>
<Text fontWeight="normal">{t('common.viewDesc')}</Text>
</Flex>
);
});
TooltipView.displayName = 'TooltipView';
export const ViewerToggle = memo(() => {
const imageViewer = useImageViewer();
useHotkeys('z', imageViewer.onToggle, [imageViewer]);
useHotkeys('esc', imageViewer.onClose, [imageViewer]);
const onChange = useCallback(
(isChecked: boolean) => {
if (isChecked) {
imageViewer.onClose();
} else {
imageViewer.onOpen();
}
},
[imageViewer]
);
return (
<Popover isLazy>
<PopoverTrigger>
<Button variant="outline" data-testid="toggle-viewer-menu-button" pointerEvents="auto">
<Flex gap={3} w="full" alignItems="center">
{imageViewer.isOpen ? <Icon as={PiEyeBold} /> : <Icon as={PiPencilBold} />}
<Text fontSize="md">{imageViewer.isOpen ? t('common.viewing') : t('common.editing')}</Text>
<Icon as={PiCaretDownBold} />
</Flex>
</Button>
</PopoverTrigger>
<PopoverContent p={2} pointerEvents="auto">
<PopoverArrow />
<PopoverBody>
<Flex flexDir="column">
<Button onClick={imageViewer.onOpen} variant="ghost" h="auto" w="auto" p={2}>
<Flex gap={2} w="full">
<Icon as={PiCheckBold} visibility={imageViewer.isOpen ? 'visible' : 'hidden'} />
<Flex flexDir="column" gap={2} alignItems="flex-start">
<Text fontWeight="semibold" color="base.100">
{t('common.viewing')}
</Text>
<Text fontWeight="normal" color="base.300">
{t('common.viewingDesc')}
</Text>
</Flex>
</Flex>
</Button>
<Button onClick={imageViewer.onClose} variant="ghost" h="auto" w="auto" p={2}>
<Flex gap={2} w="full">
<Icon as={PiCheckBold} visibility={imageViewer.isOpen ? 'hidden' : 'visible'} />
<Flex flexDir="column" gap={2} alignItems="flex-start">
<Text fontWeight="semibold" color="base.100">
{t('common.editing')}
</Text>
<Text fontWeight="normal" color="base.300">
{t('common.editingDesc')}
</Text>
</Flex>
</Flex>
</Button>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
<IconSwitch
isChecked={!imageViewer.isOpen}
onChange={onChange}
iconUnchecked={<PiEyeBold />}
tooltipUnchecked={<TooltipView />}
iconChecked={<PiPencilBold />}
tooltipChecked={<TooltipEdit />}
ariaLabel="Toggle viewer"
/>
);
};
});
ViewerToggle.displayName = 'ViewerToggle';

View File

@ -7,7 +7,7 @@ import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import CurrentImageButtons from './CurrentImageButtons';
import { ViewerToggleMenu } from './ViewerToggleMenu';
import { ViewerToggle } from './ViewerToggleMenu';
const selectShowToggle = createSelector(selectActiveTab, (tab) => {
if (tab === 'upscaling' || tab === 'workflows') {
@ -31,7 +31,7 @@ export const ViewerToolbar = memo(() => {
</Flex>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineStart="auto">
{showToggle && <ViewerToggleMenu />}
{showToggle && <ViewerToggle />}
</Flex>
</Flex>
</Flex>

View File

@ -123,6 +123,7 @@ const ModelListItem = ({ model }: ModelListItemProps) => {
title={t('modelManager.deleteModel')}
acceptCallback={handleModelDelete}
acceptButtonText={t('modelManager.delete')}
useInert={false}
>
<Flex rowGap={4} flexDirection="column">
<Text fontWeight="bold">{t('modelManager.deleteMsg1')}</Text>

View File

@ -67,6 +67,7 @@ export const ModelConvertButton = memo(({ modelConfig }: ModelConvertProps) => {
acceptButtonText={`${t('modelManager.convert')}`}
isOpen={isOpen}
onClose={onClose}
useInert={false}
>
<Flex flexDirection="column" rowGap={4}>
<Text fontSize="md">{t('modelManager.convertToDiffusersHelpText1')}</Text>

View File

@ -2,6 +2,7 @@ import 'reactflow/dist/style.css';
import { Flex } from '@invoke-ai/ui-library';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
@ -10,7 +11,6 @@ import { useTranslation } from 'react-i18next';
import { MdDeviceHub } from 'react-icons/md';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import AddNodePopover from './flow/AddNodePopover/AddNodePopover';
import { Flow } from './flow/Flow';
import BottomLeftPanel from './flow/panels/BottomLeftPanel/BottomLeftPanel';
import MinimapPanel from './flow/panels/MinimapPanel/MinimapPanel';
@ -31,7 +31,7 @@ const NodeEditor = () => {
{data && (
<>
<Flow />
<AddNodePopover />
<AddNodeCmdk />
<TopPanel />
<BottomLeftPanel />
<MinimapPanel />

View File

@ -0,0 +1,420 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import {
Box,
Flex,
Icon,
Input,
Modal,
ModalBody,
ModalContent,
ModalOverlay,
Spacer,
Text,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/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 {
$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 { isInvocationNode } from 'features/nodes/types/invocation';
import { toast } from 'features/toast/toast';
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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiFlaskBold, PiHammerBold } 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;
};
const useAddNode = () => {
const { t } = useTranslation();
const store = useAppStore();
const buildInvocation = useBuildNode();
const templates = useStore($templates);
const pendingConnection = useStore($pendingConnection);
const addNode = useCallback(
(nodeType: string): void => {
const node = buildInvocation(nodeType);
if (!node) {
const errorMessage = t('nodes.unknownNode', {
nodeType: nodeType,
});
toast({
status: 'error',
title: errorMessage,
});
return;
}
// Find a cozy spot for the node
const cursorPos = $cursorPos.get();
const { nodes, edges } = selectNodesSlice(store.getState());
node.position = findUnoccupiedPosition(nodes, cursorPos?.x ?? node.position.x, cursorPos?.y ?? node.position.y);
node.selected = true;
// Deselect all other nodes and edges
const nodeChanges: NodeChange[] = [{ type: 'add', item: node }];
const edgeChanges: EdgeChange[] = [];
nodes.forEach(({ id, selected }) => {
if (selected) {
nodeChanges.push({ type: 'select', id, selected: false });
}
});
edges.forEach(({ id, selected }) => {
if (selected) {
edgeChanges.push({ type: 'select', id, selected: false });
}
});
// Onwards!
if (nodeChanges.length > 0) {
store.dispatch(nodesChanged(nodeChanges));
}
if (edgeChanges.length > 0) {
store.dispatch(edgesChanged(edgeChanges));
}
// Auto-connect an edge if we just added a node and have a pending connection
if (pendingConnection && isInvocationNode(node)) {
const edgePendingUpdate = $edgePendingUpdate.get();
const { handleType } = pendingConnection;
const source = handleType === 'source' ? pendingConnection.nodeId : node.id;
const sourceHandle = handleType === 'source' ? pendingConnection.handleId : null;
const target = handleType === 'target' ? pendingConnection.nodeId : node.id;
const targetHandle = handleType === 'target' ? pendingConnection.handleId : null;
const { nodes, edges } = selectNodesSlice(store.getState());
const connection = getFirstValidConnection(
source,
sourceHandle,
target,
targetHandle,
nodes,
edges,
templates,
edgePendingUpdate
);
if (connection) {
const newEdge = connectionToEdge(connection);
store.dispatch(edgesChanged([{ type: 'add', item: newEdge }]));
}
}
},
[buildInvocation, pendingConnection, store, t, templates]
);
return addNode;
};
const cmdkRootSx: SystemStyleObject = {
'[cmdk-root]': {
w: 'full',
h: 'full',
},
'[cmdk-list]': {
w: 'full',
h: 'full',
},
};
export const AddNodeCmdk = memo(() => {
const { t } = useTranslation();
const addNodeCmdk = useAddNodeCmdk();
const addNodeCmdkIsOpen = useStore(addNodeCmdk.$boolean);
const inputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const addNode = useAddNode();
const tab = useAppSelector(selectActiveTab);
const throttledSearchTerm = useThrottle(searchTerm, 100);
useHotkeys(['shift+a', 'space'], addNodeCmdk.setTrue, { enabled: tab === 'workflows', preventDefault: true }, [tab]);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
}, []);
const onClose = useCallback(() => {
addNodeCmdk.setFalse();
setSearchTerm('');
$pendingConnection.set(null);
}, [addNodeCmdk]);
const onSelect = useCallback(
(value: string) => {
addNode(value);
onClose();
},
[addNode, onClose]
);
return (
<Modal
isOpen={addNodeCmdkIsOpen}
onClose={onClose}
useInert={false}
initialFocusRef={inputRef}
size="xl"
isCentered
>
<ModalOverlay />
<ModalContent h="512" maxH="70%">
<ModalBody p={2} h="full" sx={cmdkRootSx}>
<CommandRoot loop shouldFilter={false}>
<Flex flexDir="column" h="full" gap={2}>
<Input ref={inputRef} value={searchTerm} onChange={onChange} placeholder={t('nodes.nodeSearch')} />
<Box w="full" h="full">
<ScrollableContent>
<CommandEmpty>
<IAINoContentFallback
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
icon={null}
label="No matching items"
/>
</CommandEmpty>
<CommandList>
<NodeCommandList searchTerm={throttledSearchTerm} onSelect={onSelect} />
</CommandList>
</ScrollableContent>
</Box>
</Flex>
</CommandRoot>
</ModalBody>
</ModalContent>
</Modal>
);
});
AddNodeCmdk.displayName = 'AddNodeCmdk';
const cmdkItemSx: SystemStyleObject = {
'&[data-selected="true"]': {
bg: 'base.700',
},
};
type NodeCommandItemData = {
value: string;
label: string;
description: string;
classification: S['Classification'];
nodePack: string;
};
const $templatesArray = computed($templates, (templates) => Object.values(templates));
const createRegex = memoize(
(inputValue: string) =>
new RegExp(
inputValue
.trim()
.replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '')
.split(' ')
.join('.*'),
'gi'
)
);
// Filterable items are a subset of Invocation template - we also want to filter for notes or current image node,
// so we are using a less specific type instead of `InvocationTemplate`
type FilterableItem = {
type: string;
title: string;
description: string;
tags: string[];
classification: S['Classification'];
nodePack: string;
};
const filter = memoize(
(item: FilterableItem, searchTerm: string) => {
const regex = createRegex(searchTerm);
if (!searchTerm) {
return true;
}
if (item.title.includes(searchTerm) || regex.test(item.title)) {
return true;
}
if (item.type.includes(searchTerm) || regex.test(item.type)) {
return true;
}
if (item.description.includes(searchTerm) || regex.test(item.description)) {
return true;
}
if (item.nodePack.includes(searchTerm) || regex.test(item.nodePack)) {
return true;
}
if (item.classification.includes(searchTerm) || regex.test(item.classification)) {
return true;
}
for (const tag of item.tags) {
if (tag.includes(searchTerm) || regex.test(tag)) {
return true;
}
}
return false;
},
(item: FilterableItem, searchTerm: string) => `${item.type}-${searchTerm}`
);
const NodeCommandList = memo(({ searchTerm, onSelect }: { searchTerm: string; onSelect: (value: string) => void }) => {
const { t } = useTranslation();
const templatesArray = useStore($templatesArray);
const pendingConnection = useStore($pendingConnection);
const currentImageFilterItem = useMemo<FilterableItem>(
() => ({
type: 'current_image',
title: t('nodes.currentImage'),
description: t('nodes.currentImageDescription'),
tags: ['progress', 'image', 'current'],
classification: 'stable',
nodePack: 'invokeai',
}),
[t]
);
const notesFilterItem = useMemo<FilterableItem>(
() => ({
type: 'notes',
title: t('nodes.notes'),
description: t('nodes.notesDescription'),
tags: ['notes'],
classification: 'stable',
nodePack: 'invokeai',
}),
[t]
);
const items = useMemo<NodeCommandItemData[]>(() => {
// If we have a connection in progress, we need to filter the node choices
const _items: NodeCommandItemData[] = [];
if (!pendingConnection) {
for (const template of templatesArray) {
if (filter(template, searchTerm)) {
_items.push({
label: template.title,
value: template.type,
description: template.description,
classification: template.classification,
nodePack: template.nodePack,
});
}
}
for (const item of [currentImageFilterItem, notesFilterItem]) {
if (filter(item, searchTerm)) {
_items.push({
label: item.title,
value: item.type,
description: item.description,
classification: item.classification,
nodePack: item.nodePack,
});
}
}
} else {
for (const template of templatesArray) {
if (filter(template, searchTerm)) {
const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs;
for (const field of Object.values(candidateFields)) {
const sourceType =
pendingConnection.handleType === 'source' ? field.type : pendingConnection.fieldTemplate.type;
const targetType =
pendingConnection.handleType === 'target' ? field.type : pendingConnection.fieldTemplate.type;
if (validateConnectionTypes(sourceType, targetType)) {
_items.push({
label: template.title,
value: template.type,
description: template.description,
classification: template.classification,
nodePack: template.nodePack,
});
break;
}
}
}
}
}
return _items;
}, [pendingConnection, currentImageFilterItem, searchTerm, notesFilterItem, templatesArray]);
return (
<>
{items.map((item) => (
<CommandItem key={item.value} value={item.value} onSelect={onSelect} asChild>
<Flex role="button" flexDir="column" sx={cmdkItemSx} py={1} px={2} borderRadius="base">
<Flex alignItems="center" gap={2}>
{item.classification === 'beta' && <Icon boxSize={4} color="invokeYellow.300" as={PiHammerBold} />}
{item.classification === 'prototype' && <Icon boxSize={4} color="invokeRed.300" as={PiFlaskBold} />}
<Text fontWeight="semibold">{item.label}</Text>
<Spacer />
<Text variant="subtext" fontWeight="semibold">
{item.nodePack}
</Text>
</Flex>
{item.description && <Text color="base.200">{item.description}</Text>}
</Flex>
</CommandItem>
))}
</>
);
});
NodeCommandList.displayName = 'CommandListItems';

View File

@ -1,267 +0,0 @@
import 'reactflow/dist/style.css';
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, Flex, Popover, PopoverAnchor, PopoverBody, PopoverContent } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppStore } from 'app/store/storeHooks';
import type { SelectInstance } from 'chakra-react-select';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { useBuildNode } from 'features/nodes/hooks/useBuildNode';
import {
$cursorPos,
$edgePendingUpdate,
$isAddNodePopoverOpen,
$pendingConnection,
$templates,
closeAddNodePopover,
edgesChanged,
nodesChanged,
openAddNodePopover,
} 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 { AnyNode } from 'features/nodes/types/invocation';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { toast } from 'features/toast/toast';
import { filter, map, memoize, some } from 'lodash-es';
import { memo, useCallback, useMemo, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useHotkeys } from 'react-hotkeys-hook';
import type { HotkeyCallback } from 'react-hotkeys-hook/dist/types';
import { useTranslation } from 'react-i18next';
import type { FilterOptionOption } from 'react-select/dist/declarations/src/filters';
import type { EdgeChange, NodeChange } from 'reactflow';
const createRegex = memoize(
(inputValue: string) =>
new RegExp(
inputValue
.trim()
.replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '')
.split(' ')
.join('.*'),
'gi'
)
);
const filterOption = memoize((option: FilterOptionOption<ComboboxOption>, inputValue: string) => {
if (!inputValue) {
return true;
}
const regex = createRegex(inputValue);
return (
regex.test(option.label) ||
regex.test(option.data.description ?? '') ||
(option.data.tags ?? []).some((tag) => regex.test(tag))
);
});
const AddNodePopover = () => {
const dispatch = useAppDispatch();
const buildInvocation = useBuildNode();
const { t } = useTranslation();
const selectRef = useRef<SelectInstance<ComboboxOption> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const templates = useStore($templates);
const pendingConnection = useStore($pendingConnection);
const isOpen = useStore($isAddNodePopoverOpen);
const store = useAppStore();
const isWorkflowsActive = useStore(INTERACTION_SCOPES.workflows.$isActive);
const filteredTemplates = useMemo(() => {
// If we have a connection in progress, we need to filter the node choices
const templatesArray = map(templates);
if (!pendingConnection) {
return templatesArray;
}
return filter(templates, (template) => {
const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs;
return some(candidateFields, (field) => {
const sourceType =
pendingConnection.handleType === 'source' ? field.type : pendingConnection.fieldTemplate.type;
const targetType =
pendingConnection.handleType === 'target' ? field.type : pendingConnection.fieldTemplate.type;
return validateConnectionTypes(sourceType, targetType);
});
});
}, [templates, pendingConnection]);
const options = useMemo(() => {
const _options: ComboboxOption[] = map(filteredTemplates, (template) => {
return {
label: template.title,
value: template.type,
description: template.description,
tags: template.tags,
};
});
//We only want these nodes if we're not filtered
if (!pendingConnection) {
_options.push({
label: t('nodes.currentImage'),
value: 'current_image',
description: t('nodes.currentImageDescription'),
tags: ['progress'],
});
_options.push({
label: t('nodes.notes'),
value: 'notes',
description: t('nodes.notesDescription'),
tags: ['notes'],
});
}
_options.sort((a, b) => a.label.localeCompare(b.label));
return _options;
}, [filteredTemplates, pendingConnection, t]);
const addNode = useCallback(
(nodeType: string): AnyNode | null => {
const node = buildInvocation(nodeType);
if (!node) {
const errorMessage = t('nodes.unknownNode', {
nodeType: nodeType,
});
toast({
status: 'error',
title: errorMessage,
});
return null;
}
// Find a cozy spot for the node
const cursorPos = $cursorPos.get();
const { nodes, edges } = selectNodesSlice(store.getState());
node.position = findUnoccupiedPosition(nodes, cursorPos?.x ?? node.position.x, cursorPos?.y ?? node.position.y);
node.selected = true;
// Deselect all other nodes and edges
const nodeChanges: NodeChange[] = [{ type: 'add', item: node }];
const edgeChanges: EdgeChange[] = [];
nodes.forEach(({ id, selected }) => {
if (selected) {
nodeChanges.push({ type: 'select', id, selected: false });
}
});
edges.forEach(({ id, selected }) => {
if (selected) {
edgeChanges.push({ type: 'select', id, selected: false });
}
});
// Onwards!
if (nodeChanges.length > 0) {
dispatch(nodesChanged(nodeChanges));
}
if (edgeChanges.length > 0) {
dispatch(edgesChanged(edgeChanges));
}
return node;
},
[buildInvocation, store, dispatch, t]
);
const onChange = useCallback<ComboboxOnChange>(
(v) => {
if (!v) {
return;
}
const node = addNode(v.value);
// Auto-connect an edge if we just added a node and have a pending connection
if (pendingConnection && isInvocationNode(node)) {
const edgePendingUpdate = $edgePendingUpdate.get();
const { handleType } = pendingConnection;
const source = handleType === 'source' ? pendingConnection.nodeId : node.id;
const sourceHandle = handleType === 'source' ? pendingConnection.handleId : null;
const target = handleType === 'target' ? pendingConnection.nodeId : node.id;
const targetHandle = handleType === 'target' ? pendingConnection.handleId : null;
const { nodes, edges } = selectNodesSlice(store.getState());
const connection = getFirstValidConnection(
source,
sourceHandle,
target,
targetHandle,
nodes,
edges,
templates,
edgePendingUpdate
);
if (connection) {
const newEdge = connectionToEdge(connection);
dispatch(edgesChanged([{ type: 'add', item: newEdge }]));
}
}
closeAddNodePopover();
},
[addNode, dispatch, pendingConnection, store, templates]
);
const handleHotkeyOpen: HotkeyCallback = useCallback((e) => {
if (!$isAddNodePopoverOpen.get()) {
e.preventDefault();
openAddNodePopover();
flushSync(() => {
selectRef.current?.inputRef?.focus();
});
}
}, []);
useHotkeys(['shift+a', 'space'], handleHotkeyOpen, { enabled: isWorkflowsActive }, [isWorkflowsActive]);
const noOptionsMessage = useCallback(() => t('nodes.noMatchingNodes'), [t]);
return (
<Popover
isOpen={isOpen}
onClose={closeAddNodePopover}
placement="bottom"
openDelay={0}
closeDelay={0}
closeOnBlur={true}
returnFocusOnClose={true}
initialFocusRef={inputRef}
isLazy
>
<PopoverAnchor>
<Flex position="absolute" top="15%" insetInlineStart="50%" pointerEvents="none" />
</PopoverAnchor>
<PopoverContent
p={0}
top={-1}
shadow="dark-lg"
borderColor="invokeBlue.400"
borderWidth="2px"
borderStyle="solid"
>
<PopoverBody w="32rem" p={0}>
<Combobox
menuIsOpen={isOpen}
selectRef={selectRef}
value={null}
placeholder={t('nodes.nodeSearch')}
options={options}
noOptionsMessage={noOptionsMessage}
filterOption={filterOption}
onChange={onChange}
onMenuClose={closeAddNodePopover}
inputRef={inputRef}
closeMenuOnSelect={false}
/>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default memo(AddNodePopover);

View File

@ -8,10 +8,10 @@ import { useSyncExecutionState } from 'features/nodes/hooks/useExecutionState';
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher';
import {
$addNodeCmdk,
$cursorPos,
$didUpdateEdge,
$edgePendingUpdate,
$isAddNodePopoverOpen,
$lastEdgeUpdateMouseEvent,
$pendingConnection,
$viewport,
@ -281,7 +281,7 @@ export const Flow = memo(() => {
const onEscapeHotkey = useCallback(() => {
if (!$edgePendingUpdate.get()) {
$pendingConnection.set(null);
$isAddNodePopoverOpen.set(false);
$addNodeCmdk.set(false);
cancelConnection();
}
}, [cancelConnection]);

View File

@ -1,10 +1,11 @@
import { IconButton } from '@invoke-ai/ui-library';
import { openAddNodePopover } from 'features/nodes/store/nodesSlice';
import { useAddNodeCmdk } from 'features/nodes/store/nodesSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
const AddNodeButton = () => {
const addNodeCmdk = useAddNodeCmdk();
const { t } = useTranslation();
return (
@ -12,7 +13,7 @@ const AddNodeButton = () => {
tooltip={t('nodes.addNodeToolTip')}
aria-label={t('nodes.addNode')}
icon={<PiPlusBold />}
onClick={openAddNodePopover}
onClick={addNodeCmdk.setTrue}
pointerEvents="auto"
/>
);

View File

@ -47,6 +47,7 @@ const ClearFlowButton = () => {
onClose={onClose}
title={t('nodes.clearWorkflow')}
acceptCallback={handleNewWorkflow}
useInert={false}
>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.clearWorkflowDesc')}</Text>

View File

@ -102,7 +102,7 @@ const WorkflowEditorSettings = ({ children }: Props) => {
<>
{children({ onOpen })}
<Modal isOpen={isOpen} onClose={onClose} size="2xl" isCentered>
<Modal isOpen={isOpen} onClose={onClose} size="2xl" isCentered useInert={false}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{t('nodes.workflowSettings')}</ModalHeader>

View File

@ -3,7 +3,6 @@ import 'reactflow/dist/style.css';
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import QueueControls from 'features/queue/components/QueueControls';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
@ -34,7 +33,6 @@ const NodeEditorPanelGroup = () => {
return (
<Flex w="full" h="full" gap={2} flexDir="column">
<QueueControls />
<Flex w="full" justifyContent="space-between" alignItems="center" gap="4" padding={1}>
<Flex justifyContent="space-between" alignItems="center" gap="4">
<WorkflowLibraryButton />

View File

@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react';
import { useAppStore } from 'app/store/storeHooks';
import { $mouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import {
$addNodeCmdk,
$didUpdateEdge,
$edgePendingUpdate,
$isAddNodePopoverOpen,
$pendingConnection,
$templates,
edgesChanged,
@ -107,7 +107,7 @@ export const useConnection = () => {
$pendingConnection.set(null);
} else {
// The mouse is not over a node - we should open the add node popover
$isAddNodePopoverOpen.set(true);
$addNodeCmdk.set(true);
}
}, [store, templates, updateNodeInternals]);

View File

@ -1,6 +1,7 @@
import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
import type { PersistConfig } from 'app/store/store';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { workflowLoaded } from 'features/nodes/store/actions';
import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants';
import type {
@ -431,14 +432,8 @@ export const $didUpdateEdge = atom(false);
export const $lastEdgeUpdateMouseEvent = atom<MouseEvent | null>(null);
export const $viewport = atom<Viewport>({ x: 0, y: 0, zoom: 1 });
export const $isAddNodePopoverOpen = atom(false);
export const closeAddNodePopover = () => {
$isAddNodePopoverOpen.set(false);
$pendingConnection.set(null);
};
export const openAddNodePopover = () => {
$isAddNodePopoverOpen.set(true);
};
export const $addNodeCmdk = atom(false);
export const useAddNodeCmdk = buildUseBoolean($addNodeCmdk);
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrateNodesState = (state: any): any => {

View File

@ -163,6 +163,7 @@ export const collect: InvocationTemplate = {
},
},
useCache: true,
nodePack: 'invokeai',
classification: 'stable',
};
@ -480,6 +481,7 @@ const iterate: InvocationTemplate = {
},
},
useCache: true,
nodePack: 'invokeai',
classification: 'stable',
};
@ -1152,6 +1154,7 @@ export const schema = {
type: 'object',
required: ['type', 'id'],
title: 'CollectInvocation',
node_pack: 'invokeai',
description: 'Collects values into a collection',
classification: 'stable',
version: '1.0.0',
@ -1513,6 +1516,7 @@ export const schema = {
title: 'IterateInvocation',
description: 'Iterates over a list of items',
classification: 'stable',
node_pack: 'invokeai',
version: '1.1.0',
output: {
$ref: '#/components/schemas/IterateInvocationOutput',

View File

@ -16,7 +16,7 @@ const zInvocationTemplate = z.object({
outputType: z.string().min(1),
version: zSemVer,
useCache: z.boolean(),
nodePack: z.string().min(1).nullish(),
nodePack: z.string().min(1).default('invokeai'),
classification: zClassification,
});
export type InvocationTemplate = z.infer<typeof zInvocationTemplate>;
@ -26,7 +26,7 @@ export type InvocationTemplate = z.infer<typeof zInvocationTemplate>;
export const zInvocationNodeData = z.object({
id: z.string().trim().min(1),
version: zSemVer,
nodePack: z.string().min(1).nullish(),
nodePack: z.string().min(1).default('invokeai'),
label: z.string(),
notes: z.string(),
type: z.string().trim().min(1),

View File

@ -10,7 +10,9 @@ export const prepareLinearUIBatch = (
g: Graph,
prepend: boolean,
noise: Invocation<'noise'>,
posCond: Invocation<'compel' | 'sdxl_compel_prompt'>
posCond: Invocation<'compel' | 'sdxl_compel_prompt'>,
origin: 'generation' | 'workflows' | 'upscaling',
destination: 'canvas' | 'gallery'
): BatchConfig => {
const { iterations, model, shouldRandomizeSeed, seed, shouldConcatPrompts } = state.params;
const { prompts, seedBehaviour } = state.dynamicPrompts;
@ -103,7 +105,8 @@ export const prepareLinearUIBatch = (
graph: g.getGraph(),
runs: 1,
data,
origin: 'canvas',
origin,
destination,
},
};

View File

@ -29,7 +29,7 @@ export const addInpaint = async (
const canvas = selectCanvasSlice(state);
const { bbox } = canvas;
const { mode } = canvasSession;
const { sendToCanvas: isComposing } = canvasSession;
const initialImage = await manager.compositor.getCompositeRasterLayerImageDTO(bbox.rect);
const maskImage = await manager.compositor.getCompositeInpaintMaskImageDTO(bbox.rect);
@ -99,7 +99,7 @@ export const addInpaint = async (
g.addEdge(resizeImageToOriginalSize, 'image', canvasPasteBack, 'generated_image');
g.addEdge(resizeMaskToOriginalSize, 'image', canvasPasteBack, 'mask');
if (mode === 'generate') {
if (!isComposing) {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}
@ -143,7 +143,7 @@ export const addInpaint = async (
g.addEdge(l2i, 'image', canvasPasteBack, 'generated_image');
if (mode === 'generate') {
if (!isComposing) {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}

View File

@ -30,7 +30,7 @@ export const addOutpaint = async (
const canvas = selectCanvasSlice(state);
const { bbox } = canvas;
const { mode } = canvasSession;
const { sendToCanvas: isComposing } = canvasSession;
const initialImage = await manager.compositor.getCompositeRasterLayerImageDTO(bbox.rect);
const maskImage = await manager.compositor.getCompositeInpaintMaskImageDTO(bbox.rect);
@ -123,7 +123,7 @@ export const addOutpaint = async (
g.addEdge(resizeOutputImageToOriginalSize, 'image', canvasPasteBack, 'generated_image');
g.addEdge(resizeOutputMaskToOriginalSize, 'image', canvasPasteBack, 'mask');
if (mode === 'generate') {
if (!isComposing) {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}
@ -173,7 +173,7 @@ export const addOutpaint = async (
g.addEdge(createGradientMask, 'expanded_mask_area', canvasPasteBack, 'mask');
g.addEdge(l2i, 'image', canvasPasteBack, 'generated_image');
if (mode === 'generate') {
if (!isComposing) {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}

View File

@ -282,7 +282,7 @@ export const buildSD1Graph = async (
canvasOutput = addWatermarker(g, canvasOutput);
}
const shouldSaveToGallery = canvasSession.mode === 'generate' || canvasSettings.autoSave;
const shouldSaveToGallery = !canvasSession.sendToCanvas || canvasSettings.autoSave;
g.updateNode(canvasOutput, {
id: getPrefixedId('canvas_output'),

View File

@ -285,7 +285,7 @@ export const buildSDXLGraph = async (
canvasOutput = addWatermarker(g, canvasOutput);
}
const shouldSaveToGallery = canvasSession.mode === 'generate' || canvasSettings.autoSave;
const shouldSaveToGallery = !canvasSession.sendToCanvas || canvasSettings.autoSave;
g.updateNode(canvasOutput, {
id: getPrefixedId('canvas_output'),

View File

@ -38,6 +38,7 @@ export const buildInvocationNode = (position: XYPosition, template: InvocationTe
isOpen: true,
isIntermediate: type === 'save_image' ? false : true,
useCache: template.useCache,
nodePack: template.nodePack,
inputs,
},
};

View File

@ -85,6 +85,7 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor
isOpen: true,
isIntermediate: node.is_intermediate ?? false,
useCache: node.use_cache ?? true,
nodePack: template.nodePack,
inputs,
},
});

View File

@ -28,6 +28,7 @@ describe('validateWorkflow', () => {
isOpen: true,
isIntermediate: true,
useCache: true,
nodePack: 'invokeai',
inputs: {
model: {
name: 'model',
@ -56,6 +57,7 @@ describe('validateWorkflow', () => {
isOpen: true,
isIntermediate: true,
useCache: true,
nodePack: 'invokeai',
inputs: {
board: {
name: 'board',

View File

@ -1,27 +1,26 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button } from '@invoke-ai/ui-library';
import { useClearQueueConfirmationAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
import { useClearQueue } from './ClearQueueConfirmationAlertDialog';
type Props = ButtonProps;
const ClearQueueButton = (props: Props) => {
const { t } = useTranslation();
const dialogState = useClearQueueConfirmationAlertDialog();
const { isLoading, isDisabled } = useClearQueue();
const clearQueue = useClearQueue();
return (
<>
<Button
isDisabled={isDisabled}
isLoading={isLoading}
isDisabled={clearQueue.isDisabled}
isLoading={clearQueue.isLoading}
tooltip={t('queue.clearTooltip')}
leftIcon={<PiTrashSimpleFill />}
colorScheme="error"
onClick={dialogState.setTrue}
onClick={clearQueue.openDialog}
data-testid={t('queue.clear')}
{...props}
>

View File

@ -1,27 +1,77 @@
import { ConfirmationAlertDialog, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $isConnected } from 'app/hooks/useSocketIO';
import { useAppDispatch } from 'app/store/storeHooks';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { listCursorChanged, listPriorityChanged } from 'features/queue/store/queueSlice';
import { toast } from 'features/toast/toast';
import { atom } from 'nanostores';
import { memo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useClearQueueMutation, useGetQueueStatusQuery } from 'services/api/endpoints/queue';
const $boolean = atom(false);
export const useClearQueueConfirmationAlertDialog = buildUseBoolean($boolean);
const useClearQueueConfirmationAlertDialog = buildUseBoolean($boolean);
export const useClearQueue = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const dialog = useClearQueueConfirmationAlertDialog();
const isOpen = useStore(dialog.$boolean);
const { data: queueStatus } = useGetQueueStatusQuery();
const isConnected = useStore($isConnected);
const [trigger, { isLoading }] = useClearQueueMutation({
fixedCacheKey: 'clearQueue',
});
const clearQueue = useCallback(async () => {
if (!queueStatus?.queue.total) {
return;
}
try {
await trigger().unwrap();
toast({
id: 'QUEUE_CLEAR_SUCCEEDED',
title: t('queue.clearSucceeded'),
status: 'success',
});
dispatch(listCursorChanged(undefined));
dispatch(listPriorityChanged(undefined));
} catch {
toast({
id: 'QUEUE_CLEAR_FAILED',
title: t('queue.clearFailed'),
status: 'error',
});
}
}, [queueStatus?.queue.total, trigger, dispatch, t]);
const isDisabled = useMemo(() => !isConnected || !queueStatus?.queue.total, [isConnected, queueStatus?.queue.total]);
return {
clearQueue,
isOpen,
openDialog: dialog.setTrue,
closeDialog: dialog.setFalse,
isLoading,
queueStatus,
isDisabled,
};
};
export const ClearQueueConfirmationsAlertDialog = memo(() => {
const { t } = useTranslation();
const dialogState = useClearQueueConfirmationAlertDialog();
const isOpen = useStore(dialogState.$boolean);
const { clearQueue } = useClearQueue();
const clearQueue = useClearQueue();
return (
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={dialogState.setFalse}
isOpen={clearQueue.isOpen}
onClose={clearQueue.closeDialog}
title={t('queue.clearTooltip')}
acceptCallback={clearQueue}
acceptCallback={clearQueue.clearQueue}
acceptButtonText={t('queue.clear')}
useInert={false}
>
<Text>{t('queue.clearQueueAlertDialog')}</Text>
<br />

View File

@ -1,67 +1,40 @@
import type { IconButtonProps } from '@invoke-ai/ui-library';
import { IconButton, useShiftModifier } from '@invoke-ai/ui-library';
import { useClearQueueConfirmationAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { QueueCountBadge } from 'features/queue/components/QueueCountBadge';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { memo } from 'react';
import { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold, PiXBold } from 'react-icons/pi';
type ClearQueueButtonProps = Omit<IconButtonProps, 'aria-label'>;
import { useClearQueue } from './ClearQueueConfirmationAlertDialog';
export const ClearAllQueueIconButton = memo((props: ClearQueueButtonProps) => {
export const ClearQueueIconButton = memo((_) => {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const dialogState = useClearQueueConfirmationAlertDialog();
const { isLoading, isDisabled } = useClearQueue();
const clearQueue = useClearQueue();
const cancelCurrentQueueItem = useCancelCurrentQueueItem();
return (
<IconButton
isDisabled={isDisabled}
isLoading={isLoading}
aria-label={t('queue.clear')}
tooltip={t('queue.clearTooltip')}
icon={<PiTrashSimpleBold size="16px" />}
colorScheme="error"
onClick={dialogState.setTrue}
data-testid={t('queue.clear')}
{...props}
/>
);
});
ClearAllQueueIconButton.displayName = 'ClearAllQueueIconButton';
const ClearSingleQueueItemIconButton = memo((props: ClearQueueButtonProps) => {
const { t } = useTranslation();
const { cancelQueueItem, isLoading, isDisabled } = useCancelCurrentQueueItem();
return (
<IconButton
isDisabled={isDisabled}
isLoading={isLoading}
aria-label={t('queue.cancel')}
tooltip={t('queue.cancelTooltip')}
icon={<PiXBold size="16px" />}
colorScheme="error"
onClick={cancelQueueItem}
data-testid={t('queue.cancel')}
{...props}
/>
);
});
ClearSingleQueueItemIconButton.displayName = 'ClearSingleQueueItemIconButton';
export const ClearQueueIconButton = memo((props: ClearQueueButtonProps) => {
// Show the single item clear button when shift is pressed
// Otherwise show the clear queue button
const shift = useShiftModifier();
if (shift) {
return <ClearAllQueueIconButton {...props} />;
}
return <ClearSingleQueueItemIconButton {...props} />;
return (
<>
<IconButton
ref={ref}
size="lg"
isDisabled={shift ? clearQueue.isDisabled : cancelCurrentQueueItem.isDisabled}
isLoading={shift ? clearQueue.isLoading : cancelCurrentQueueItem.isLoading}
aria-label={shift ? t('queue.clear') : t('queue.cancel')}
tooltip={shift ? t('queue.clearTooltip') : t('queue.cancelTooltip')}
icon={shift ? <PiTrashSimpleBold /> : <PiXBold />}
colorScheme="error"
onClick={shift ? clearQueue.openDialog : cancelCurrentQueueItem.cancelQueueItem}
data-testid={shift ? t('queue.clear') : t('queue.cancel')}
/>
{/* The badge is dynamically positioned, needs a ref to the target element */}
<QueueCountBadge targetRef={ref} />
</>
);
});
ClearQueueIconButton.displayName = 'ClearQueueIconButton';

View File

@ -15,7 +15,7 @@ export const InvokeQueueBackButton = memo(() => {
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
return (
<Flex pos="relative" flexGrow={1} minW="240px">
<Flex pos="relative" w="192px">
<QueueIterationsNumberInput />
<QueueButtonTooltip>
<Button
@ -25,7 +25,6 @@ export const InvokeQueueBackButton = memo(() => {
isDisabled={isDisabled}
rightIcon={<RiSparkling2Fill />}
variant="solid"
zIndex={1}
colorScheme="invokeYellow"
size="lg"
w="calc(100% - 60px)"

View File

@ -1,101 +0,0 @@
import {
Badge,
Box,
IconButton,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
useDisclosure,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useClearQueueConfirmationAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { usePauseProcessor } from 'features/queue/hooks/usePauseProcessor';
import { useResumeProcessor } from 'features/queue/hooks/useResumeProcessor';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPauseFill, PiPlayFill, PiTrashSimpleBold } from 'react-icons/pi';
import { RiListCheck, RiPlayList2Fill } from 'react-icons/ri';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
export const QueueActionsMenuButton = memo(() => {
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const { t } = useTranslation();
const dialogState = useClearQueueConfirmationAlertDialog();
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
const { queueSize } = useGetQueueStatusQuery(undefined, {
selectFromResult: (res) => ({
queueSize: res.data ? res.data.queue.pending + res.data.queue.in_progress : 0,
}),
});
const { isLoading: isLoadingClearQueue, isDisabled: isDisabledClearQueue } = useClearQueue();
const {
resumeProcessor,
isLoading: isLoadingResumeProcessor,
isDisabled: isDisabledResumeProcessor,
} = useResumeProcessor();
const {
pauseProcessor,
isLoading: isLoadingPauseProcessor,
isDisabled: isDisabledPauseProcessor,
} = usePauseProcessor();
const openQueue = useCallback(() => {
dispatch(setActiveTab('queue'));
}, [dispatch]);
return (
<Box pos="relative">
<Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose} placement="bottom-end">
<MenuButton as={IconButton} aria-label="Queue Actions Menu" icon={<RiListCheck />} />
<MenuList>
<MenuItem
isDestructive
icon={<PiTrashSimpleBold size="16px" />}
onClick={dialogState.setTrue}
isLoading={isLoadingClearQueue}
isDisabled={isDisabledClearQueue}
>
{t('queue.clearTooltip')}
</MenuItem>
{isResumeEnabled && (
<MenuItem
icon={<PiPlayFill size="14px" />}
onClick={resumeProcessor}
isLoading={isLoadingResumeProcessor}
isDisabled={isDisabledResumeProcessor}
>
{t('queue.resumeTooltip')}
</MenuItem>
)}
{isPauseEnabled && (
<MenuItem
icon={<PiPauseFill size="14px" />}
onClick={pauseProcessor}
isLoading={isLoadingPauseProcessor}
isDisabled={isDisabledPauseProcessor}
>
{t('queue.pauseTooltip')}
</MenuItem>
)}
<MenuDivider />
<MenuItem icon={<RiPlayList2Fill />} onClick={openQueue}>
{t('queue.openQueue')}
</MenuItem>
</MenuList>
</Menu>
{queueSize > 0 && (
<Badge pos="absolute" insetInlineStart={-3} insetBlockStart={-1.5} colorScheme="invokeYellow" zIndex="docked">
{queueSize}
</Badge>
)}
</Box>
);
});
QueueActionsMenuButton.displayName = 'QueueActionsMenuButton';

View File

@ -1,24 +1,27 @@
import { ButtonGroup, Flex, Spacer } from '@invoke-ai/ui-library';
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasSendToToggle } from 'features/controlLayers/components/CanvasSendToToggle';
import { ClearQueueIconButton } from 'features/queue/components/ClearQueueIconButton';
import QueueFrontButton from 'features/queue/components/QueueFrontButton';
import ProgressBar from 'features/system/components/ProgressBar';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import { InvokeQueueBackButton } from './InvokeQueueBackButton';
import { QueueActionsMenuButton } from './QueueActionsMenuButton';
const QueueControls = () => {
const isPrependEnabled = useFeatureStatus('prependQueue');
const tab = useAppSelector(selectActiveTab);
return (
<Flex w="full" position="relative" borderRadius="base" gap={2} pt={2} flexDir="column">
<ButtonGroup size="lg" isAttached={false}>
<Flex w="full" position="relative" borderRadius="base" gap={2} flexDir="column">
<Flex gap={2}>
{isPrependEnabled && <QueueFrontButton />}
<InvokeQueueBackButton />
<Spacer />
<QueueActionsMenuButton />
{tab === 'generation' && <CanvasSendToToggle />}
<ClearQueueIconButton />
</ButtonGroup>
</Flex>
<ProgressBar />
</Flex>
);

View File

@ -0,0 +1,77 @@
import { Badge, Portal } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $isParametersPanelOpen } from 'features/ui/store/uiSlice';
import type { RefObject } from 'react';
import { memo, useEffect, useState } from 'react';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
type Props = {
targetRef: RefObject<HTMLDivElement>;
};
export const QueueCountBadge = memo(({ targetRef }: Props) => {
const [badgePos, setBadgePos] = useState<{ x: string; y: string } | null>(null);
const isParametersPanelOpen = useStore($isParametersPanelOpen);
const { queueSize } = useGetQueueStatusQuery(undefined, {
selectFromResult: (res) => ({
queueSize: res.data ? res.data.queue.pending + res.data.queue.in_progress : 0,
}),
});
useEffect(() => {
if (!targetRef.current) {
return;
}
const target = targetRef.current;
const parent = target.parentElement;
if (!parent) {
return;
}
const cb = () => {
if (!$isParametersPanelOpen.get()) {
return;
}
const { x, y } = target.getBoundingClientRect();
setBadgePos({ x: `${x - 7}px`, y: `${y - 5}px` });
};
const resizeObserver = new ResizeObserver(cb);
resizeObserver.observe(parent);
cb();
return () => {
resizeObserver.disconnect();
};
}, [targetRef]);
if (queueSize === 0) {
return null;
}
if (!badgePos) {
return null;
}
if (!isParametersPanelOpen) {
return null;
}
return (
<Portal>
<Badge
pos="absolute"
insetInlineStart={badgePos.x}
insetBlockStart={badgePos.y}
colorScheme="invokeYellow"
zIndex="docked"
shadow="dark-lg"
userSelect="none"
>
{queueSize}
</Badge>
</Portal>
);
});
QueueCountBadge.displayName = 'QueueCountBadge';

View File

@ -1,6 +1,7 @@
import type { ChakraProps, CollapseProps } from '@invoke-ai/ui-library';
import { ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai/ui-library';
import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge';
import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText';
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps';
@ -52,6 +53,7 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
const isCanceled = useMemo(() => ['canceled', 'completed', 'failed'].includes(item.status), [item.status]);
const originText = useOriginText(item.origin);
const destinationText = useDestinationText(item.destination);
const icon = useMemo(() => <PiXBold />, []);
return (
@ -76,6 +78,11 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
{originText}
</Text>
</Flex>
<Flex w={COLUMN_WIDTHS.destination} flexShrink={0}>
<Text overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" alignItems="center">
{destinationText}
</Text>
</Flex>
<Flex w={COLUMN_WIDTHS.time} alignItems="center" flexShrink={0}>
{executionTime || '-'}
</Flex>

View File

@ -1,5 +1,6 @@
import { Button, ButtonGroup, Flex, Heading, Spinner, Text } from '@invoke-ai/ui-library';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText';
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
import { useCancelBatch } from 'features/queue/hooks/useCancelBatch';
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
@ -17,7 +18,7 @@ type Props = {
};
const QueueItemComponent = ({ queueItemDTO }: Props) => {
const { session_id, batch_id, item_id, origin } = queueItemDTO;
const { session_id, batch_id, item_id, origin, destination } = queueItemDTO;
const { t } = useTranslation();
const { cancelBatch, isLoading: isLoadingCancelBatch, isCanceled } = useCancelBatch(batch_id);
@ -26,6 +27,7 @@ const QueueItemComponent = ({ queueItemDTO }: Props) => {
const { data: queueItem } = useGetQueueItemQuery(item_id);
const originText = useOriginText(origin);
const destinationText = useDestinationText(destination);
const statusAndTiming = useMemo(() => {
if (!queueItem) {
@ -54,6 +56,7 @@ const QueueItemComponent = ({ queueItemDTO }: Props) => {
>
<QueueItemData label={t('queue.status')} data={statusAndTiming} />
<QueueItemData label={t('queue.origin')} data={originText} />
<QueueItemData label={t('queue.destination')} data={destinationText} />
<QueueItemData label={t('queue.item')} data={item_id} />
<QueueItemData label={t('queue.batch')} data={batch_id} />
<QueueItemData label={t('queue.session')} data={session_id} />

View File

@ -25,6 +25,9 @@ const QueueListHeader = () => {
<Flex ps={0.5} w={COLUMN_WIDTHS.origin} alignItems="center">
<Text variant="subtext">{t('queue.origin')}</Text>
</Flex>
<Flex ps={0.5} w={COLUMN_WIDTHS.destination} alignItems="center">
<Text variant="subtext">{t('queue.destination')}</Text>
</Flex>
<Flex ps={0.5} w={COLUMN_WIDTHS.time} alignItems="center">
<Text variant="subtext">{t('queue.time')}</Text>
</Flex>

View File

@ -4,7 +4,8 @@ export const COLUMN_WIDTHS = {
statusDot: 2,
time: '4rem',
origin: '5rem',
destination: '6rem',
batchId: '5rem',
fieldValues: 'auto',
actions: 'auto',
};
} as const;

View File

@ -0,0 +1,16 @@
import { useTranslation } from 'react-i18next';
import type { SessionQueueItemDTO } from 'services/api/types';
export const useDestinationText = (destination: SessionQueueItemDTO['destination']) => {
const { t } = useTranslation();
if (destination === 'canvas') {
return t('queue.canvas');
}
if (destination === 'gallery') {
return t('queue.gallery');
}
return t('queue.other');
};

View File

@ -4,13 +4,13 @@ import type { SessionQueueItemDTO } from 'services/api/types';
export const useOriginText = (origin: SessionQueueItemDTO['origin']) => {
const { t } = useTranslation();
if (origin === 'canvas') {
return t('queue.originCanvas');
if (origin === 'generation') {
return t('queue.generation');
}
if (origin === 'workflows') {
return t('queue.originWorkflows');
return t('queue.workflows');
}
return t('queue.originOther');
return t('queue.other');
};

Some files were not shown because too many files have changed in this diff Show More