Compare commits

..

25 Commits

Author SHA1 Message Date
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
62 changed files with 1248 additions and 669 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

@ -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

@ -1734,6 +1734,8 @@
"unlocked": "Unlocked",
"deleteSelected": "Delete Selected",
"deleteAll": "Delete All",
"flipHorizontal": "Flip Horizontal",
"flipVertical": "Flip Vertical",
"fill": {
"fillStyle": "Fill Style",
"solid": "Solid",

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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -27,7 +27,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 />

View File

@ -82,10 +82,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,9 +103,6 @@ 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"
/>

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

@ -29,13 +29,14 @@ export const UndoRedoButtonGroup = memo(() => {
]);
return (
<ButtonGroup>
<ButtonGroup isAttached={false}>
<IconButton
aria-label={t('unifiedCanvas.undo')}
tooltip={t('unifiedCanvas.undo')}
onClick={handleUndo}
icon={<PiArrowCounterClockwiseBold />}
isDisabled={!mayUndo}
variant="ghost"
/>
<IconButton
aria-label={t('unifiedCanvas.redo')}
@ -43,6 +44,7 @@ export const UndoRedoButtonGroup = memo(() => {
onClick={handleRedo}
icon={<PiArrowClockwiseBold />}
isDisabled={!mayRedo}
variant="ghost"
/>
</ButtonGroup>
);

View File

@ -1,34 +1,28 @@
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}
icon={isEnabled ? <PiCircleFill /> : <PiCircleBold />}
onClick={onClick}
/>
);

View File

@ -1,34 +1,28 @@
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}
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

@ -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

@ -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

@ -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,18 +1,8 @@
import {
Button,
Flex,
Icon,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Text,
} from '@invoke-ai/ui-library';
import { ButtonGroup, Flex, IconButton, Text, Tooltip } from '@invoke-ai/ui-library';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
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 { t } = useTranslation();
@ -21,49 +11,45 @@ export const ViewerToggleMenu = () => {
useHotkeys('esc', imageViewer.onClose, [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>
<Flex gap={4} alignItems="center" justifyContent="center">
<ButtonGroup size="md">
<Tooltip
hasArrow
label={
<Flex flexDir="column">
<Text fontWeight="semibold">{t('common.viewing')}</Text>
<Text fontWeight="normal">{t('common.viewingDesc')}</Text>
</Flex>
}
>
<IconButton
icon={<PiEyeBold />}
onClick={imageViewer.onOpen}
variant={imageViewer.isOpen ? 'solid' : 'outline'}
colorScheme={imageViewer.isOpen ? 'invokeBlue' : 'base'}
aria-label={t('common.viewing')}
w={12}
/>
</Tooltip>
<Tooltip
hasArrow
label={
<Flex flexDir="column">
<Text fontWeight="semibold">{t('common.editing')}</Text>
<Text fontWeight="normal">{t('common.editingDesc')}</Text>
</Flex>
}
>
<IconButton
icon={<PiPencilBold />}
onClick={imageViewer.onClose}
variant={!imageViewer.isOpen ? 'solid' : 'outline'}
colorScheme={!imageViewer.isOpen ? 'invokeBlue' : 'base'}
aria-label={t('common.editing')}
w={12}
/>
</Tooltip>
</ButtonGroup>
</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,418 @@
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 { 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 { 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 throttledSearchTerm = useThrottle(searchTerm, 100);
useHotkeys(['shift+a', 'space'], addNodeCmdk.setTrue, { preventDefault: true });
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

@ -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

@ -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

@ -22,6 +22,7 @@ export const ClearQueueConfirmationsAlertDialog = memo(() => {
title={t('queue.clearTooltip')}
acceptCallback={clearQueue}
acceptButtonText={t('queue.clear')}
useInert={false}
>
<Text>{t('queue.clearQueueAlertDialog')}</Text>
<br />

View File

@ -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

@ -7,16 +7,18 @@ import {
MenuDivider,
MenuItem,
MenuList,
Portal,
useDisclosure,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import type { Coordinate } from 'features/controlLayers/store/types';
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 { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPauseFill, PiPlayFill, PiTrashSimpleBold } from 'react-icons/pi';
import { RiListCheck, RiPlayList2Fill } from 'react-icons/ri';
@ -26,6 +28,8 @@ export const QueueActionsMenuButton = memo(() => {
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [badgePos, setBadgePos] = useState<Coordinate | null>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const dialogState = useClearQueueConfirmationAlertDialog();
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
@ -49,10 +53,17 @@ export const QueueActionsMenuButton = memo(() => {
dispatch(setActiveTab('queue'));
}, [dispatch]);
useEffect(() => {
if (menuButtonRef.current) {
const { x, y } = menuButtonRef.current.getBoundingClientRect();
setBadgePos({ x: x - 10, y: y - 10 });
}
}, []);
return (
<Box pos="relative">
<Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose} placement="bottom-end">
<MenuButton as={IconButton} aria-label="Queue Actions Menu" icon={<RiListCheck />} />
<MenuButton ref={menuButtonRef} as={IconButton} aria-label="Queue Actions Menu" icon={<RiListCheck />} />
<MenuList>
<MenuItem
isDestructive
@ -89,10 +100,18 @@ export const QueueActionsMenuButton = memo(() => {
</MenuItem>
</MenuList>
</Menu>
{queueSize > 0 && (
<Badge pos="absolute" insetInlineStart={-3} insetBlockStart={-1.5} colorScheme="invokeYellow" zIndex="docked">
{queueSize}
</Badge>
{queueSize > 0 && badgePos !== null && (
<Portal>
<Badge
pos="absolute"
insetInlineStart={badgePos.x}
insetBlockStart={badgePos.y}
colorScheme="invokeYellow"
zIndex="docked"
>
{queueSize}
</Badge>
</Portal>
)}
</Box>
);

View File

@ -11,7 +11,7 @@ import { QueueActionsMenuButton } from './QueueActionsMenuButton';
const QueueControls = () => {
const isPrependEnabled = useFeatureStatus('prependQueue');
return (
<Flex w="full" position="relative" borderRadius="base" gap={2} pt={2} flexDir="column">
<Flex w="full" position="relative" borderRadius="base" gap={2} flexDir="column">
<ButtonGroup size="lg" isAttached={false}>
{isPrependEnabled && <QueueFrontButton />}
<InvokeQueueBackButton />

View File

@ -67,7 +67,7 @@ export const StylePresetModal = () => {
}, [stylePresetModalState.prefilledFormData]);
return (
<Modal isOpen={stylePresetModalState.isModalOpen} onClose={handleCloseModal} isCentered size="2xl">
<Modal isOpen={stylePresetModalState.isModalOpen} onClose={handleCloseModal} isCentered size="2xl" useInert={false}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{modalTitle}</ModalHeader>

View File

@ -179,6 +179,7 @@ export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithI
acceptCallback={handleDeletePreset}
acceptButtonText={t('common.delete')}
cancelButtonText={t('common.cancel')}
useInert={false}
>
<p>{t('stylePresets.deleteTemplate2')}</p>
</ConfirmationAlertDialog>

View File

@ -53,7 +53,7 @@ const AboutModal = ({ children }: AboutModalProps) => {
{cloneElement(children, {
onClick: onOpen,
})}
<Modal isOpen={isOpen} onClose={onClose} isCentered size="2xl">
<Modal isOpen={isOpen} onClose={onClose} isCentered size="2xl" useInert={false}>
<ModalOverlay />
<ModalContent maxH="80vh" h="34rem">
<ModalHeader>{t('accessibility.about')}</ModalHeader>

View File

@ -70,7 +70,7 @@ const HotkeysModal = ({ children }: HotkeysModalProps) => {
{cloneElement(children, {
onClick: onOpen,
})}
<Modal isOpen={isOpen} onClose={onClose} isCentered size="2xl">
<Modal isOpen={isOpen} onClose={onClose} isCentered size="2xl" useInert={false}>
<ModalOverlay />
<ModalContent maxH="80vh" h="80vh">
<ModalHeader>{t('hotkeys.keyboardShortcuts')}</ModalHeader>

View File

@ -0,0 +1,72 @@
import {
Flex,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { atom } from 'nanostores';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const $refreshAfterResetModalState = atom(false);
export const useRefreshAfterResetModal = buildUseBoolean($refreshAfterResetModalState);
const RefreshAfterResetModal = () => {
const { t } = useTranslation();
const [countdown, setCountdown] = useState(3);
const refreshModal = useRefreshAfterResetModal();
const isOpen = useStore(refreshModal.$boolean);
useEffect(() => {
if (!isOpen) {
return;
}
const i = window.setInterval(() => setCountdown((prev) => prev - 1), 1000);
return () => {
window.clearInterval(i);
};
}, [isOpen]);
useEffect(() => {
if (countdown <= 0) {
window.location.reload();
}
}, [countdown]);
return (
<>
<Modal
closeOnOverlayClick={false}
isOpen={isOpen}
onClose={refreshModal.setFalse}
isCentered
closeOnEsc={false}
useInert={false}
>
<ModalOverlay backdropFilter="blur(40px)" />
<ModalContent>
<ModalHeader />
<ModalBody>
<Flex justifyContent="center">
<Text fontSize="lg">
<Text>
{t('settings.resetComplete')} {t('settings.reloadingIn')} {countdown}...
</Text>
</Text>
</Flex>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
</>
);
};
export default memo(RefreshAfterResetModal);

View File

@ -25,12 +25,13 @@ import {
} from 'react-icons/pi';
import { RiDiscordFill, RiGithubFill, RiSettings4Line } from 'react-icons/ri';
import SettingsModal from './SettingsModal';
import { useSettingsModal } from './SettingsModal';
import { SettingsUpsellMenuItem } from './SettingsUpsellMenuItem';
const SettingsMenu = () => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
useGlobalMenuClose(onClose);
const settingsModal = useSettingsModal();
const isBugLinkEnabled = useFeatureStatus('bugLink');
const isDiscordLinkEnabled = useFeatureStatus('discordLink');
@ -75,11 +76,9 @@ const SettingsMenu = () => {
{t('common.hotkeysLabel')}
</MenuItem>
</HotkeysModal>
<SettingsModal>
<MenuItem as="button" icon={<PiToggleRightFill />}>
{t('common.settingsLabel')}
</MenuItem>
</SettingsModal>
<MenuItem onClick={settingsModal.setTrue} as="button" icon={<PiToggleRightFill />}>
{t('common.settingsLabel')}
</MenuItem>
</MenuGroup>
<MenuGroup title={t('accessibility.about')}>
<AboutModal>

View File

@ -13,13 +13,15 @@ import {
ModalOverlay,
Switch,
Text,
useDisclosure,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { useClearStorage } from 'common/hooks/useClearStorage';
import { selectShouldUseCPUNoise, shouldUseCpuNoiseChanged } from 'features/controlLayers/store/paramsSlice';
import { useRefreshAfterResetModal } from 'features/system/components/SettingsModal/RefreshAfterResetModal';
import { SettingsDeveloperLogIsEnabled } from 'features/system/components/SettingsModal/SettingsDeveloperLogIsEnabled';
import { SettingsDeveloperLogLevel } from 'features/system/components/SettingsModal/SettingsDeveloperLogLevel';
import { SettingsDeveloperLogNamespaces } from 'features/system/components/SettingsModal/SettingsDeveloperLogNamespaces';
@ -40,8 +42,9 @@ import {
} from 'features/system/store/systemSlice';
import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import { setShouldShowProgressInViewer } from 'features/ui/store/uiSlice';
import type { ChangeEvent, ReactElement } from 'react';
import { cloneElement, memo, useCallback, useEffect, useState } from 'react';
import { atom } from 'nanostores';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetAppConfigQuery } from 'services/api/endpoints/appInfo';
@ -54,27 +57,29 @@ type ConfigOptions = {
shouldShowLocalizationToggle?: boolean;
};
const defaultConfig: ConfigOptions = {
shouldShowDeveloperSettings: true,
shouldShowResetWebUiText: true,
shouldShowClearIntermediates: true,
shouldShowLocalizationToggle: true,
};
type SettingsModalProps = {
/* The button to open the Settings Modal */
children: ReactElement;
config?: ConfigOptions;
};
const SettingsModal = ({ children, config }: SettingsModalProps) => {
const $settingsModal = atom(false);
export const useSettingsModal = buildUseBoolean($settingsModal);
const SettingsModal = ({ config = defaultConfig }: SettingsModalProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [countdown, setCountdown] = useState(3);
const shouldShowDeveloperSettings = config?.shouldShowDeveloperSettings ?? true;
const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true;
const shouldShowClearIntermediates = config?.shouldShowClearIntermediates ?? true;
const shouldShowLocalizationToggle = config?.shouldShowLocalizationToggle ?? true;
useEffect(() => {
if (!shouldShowDeveloperSettings) {
if (!config?.shouldShowDeveloperSettings) {
dispatch(logIsEnabledChanged(false));
}
}, [shouldShowDeveloperSettings, dispatch]);
}, [dispatch, config?.shouldShowDeveloperSettings]);
const { isNSFWCheckerAvailable, isWatermarkerAvailable } = useGetAppConfigQuery(undefined, {
selectFromResult: ({ data }) => ({
@ -89,11 +94,10 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
intermediatesCount,
isLoading: isLoadingClearIntermediates,
refetchIntermediatesCount,
} = useClearIntermediates(shouldShowClearIntermediates);
const { isOpen: isSettingsModalOpen, onOpen: _onSettingsModalOpen, onClose: onSettingsModalClose } = useDisclosure();
const { isOpen: isRefreshModalOpen, onOpen: onRefreshModalOpen, onClose: onRefreshModalClose } = useDisclosure();
} = useClearIntermediates(Boolean(config?.shouldShowClearIntermediates));
const settingsModal = useSettingsModal();
const settingsModalIsOpen = useStore(settingsModal.$boolean);
const refreshModal = useRefreshAfterResetModal();
const shouldUseCpuNoise = useAppSelector(selectShouldUseCPUNoise);
const shouldConfirmOnDelete = useAppSelector(selectSystemShouldConfirmOnDelete);
@ -105,25 +109,17 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
const clearStorage = useClearStorage();
const handleOpenSettingsModel = useCallback(() => {
if (shouldShowClearIntermediates) {
useEffect(() => {
if (settingsModalIsOpen && Boolean(config?.shouldShowClearIntermediates)) {
refetchIntermediatesCount();
}
_onSettingsModalOpen();
}, [_onSettingsModalOpen, refetchIntermediatesCount, shouldShowClearIntermediates]);
}, [config?.shouldShowClearIntermediates, refetchIntermediatesCount, settingsModalIsOpen]);
const handleClickResetWebUI = useCallback(() => {
clearStorage();
onSettingsModalClose();
onRefreshModalOpen();
setInterval(() => setCountdown((prev) => prev - 1), 1000);
}, [clearStorage, onSettingsModalClose, onRefreshModalOpen]);
useEffect(() => {
if (countdown <= 0) {
window.location.reload();
}
}, [countdown]);
settingsModal.setFalse();
refreshModal.setTrue();
}, [clearStorage, settingsModal, refreshModal]);
const handleChangeShouldConfirmOnDelete = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@ -169,139 +165,107 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
);
return (
<>
{cloneElement(children, {
onClick: handleOpenSettingsModel,
})}
<Modal isOpen={settingsModalIsOpen} onClose={settingsModal.setFalse} size="2xl" isCentered useInert={false}>
<ModalOverlay />
<ModalContent maxH="80vh" h="68rem">
<ModalHeader bg="none">{t('common.settingsLabel')}</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" flexDir="column" gap={4}>
<ScrollableContent>
<Flex flexDir="column" gap={4}>
<FormControlGroup formLabelProps={{ flexGrow: 1 }}>
<StickyScrollable title={t('settings.general')}>
<FormControl>
<FormLabel>{t('settings.confirmOnDelete')}</FormLabel>
<Switch isChecked={shouldConfirmOnDelete} onChange={handleChangeShouldConfirmOnDelete} />
</FormControl>
</StickyScrollable>
<Modal isOpen={isSettingsModalOpen} onClose={onSettingsModalClose} size="2xl" isCentered>
<ModalOverlay />
<ModalContent maxH="80vh" h="68rem">
<ModalHeader bg="none">{t('common.settingsLabel')}</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" flexDir="column" gap={4}>
<ScrollableContent>
<Flex flexDir="column" gap={4}>
<FormControlGroup formLabelProps={{ flexGrow: 1 }}>
<StickyScrollable title={t('settings.general')}>
<FormControl>
<FormLabel>{t('settings.confirmOnDelete')}</FormLabel>
<Switch isChecked={shouldConfirmOnDelete} onChange={handleChangeShouldConfirmOnDelete} />
</FormControl>
<StickyScrollable title={t('settings.generation')}>
<FormControl isDisabled={!isNSFWCheckerAvailable}>
<FormLabel>{t('settings.enableNSFWChecker')}</FormLabel>
<Switch isChecked={shouldUseNSFWChecker} onChange={handleChangeShouldUseNSFWChecker} />
</FormControl>
<FormControl isDisabled={!isWatermarkerAvailable}>
<FormLabel>{t('settings.enableInvisibleWatermark')}</FormLabel>
<Switch isChecked={shouldUseWatermarker} onChange={handleChangeShouldUseWatermarker} />
</FormControl>
</StickyScrollable>
<StickyScrollable title={t('settings.ui')}>
<FormControl>
<FormLabel>{t('settings.showProgressInViewer')}</FormLabel>
<Switch isChecked={shouldShowProgressInViewer} onChange={handleChangeShouldShowProgressInViewer} />
</FormControl>
<FormControl>
<FormLabel>{t('settings.antialiasProgressImages')}</FormLabel>
<Switch
isChecked={shouldAntialiasProgressImage}
onChange={handleChangeShouldAntialiasProgressImage}
/>
</FormControl>
<FormControl>
<InformationalPopover feature="noiseUseCPU" inPortal={false}>
<FormLabel>{t('parameters.useCpuNoise')}</FormLabel>
</InformationalPopover>
<Switch isChecked={shouldUseCpuNoise} onChange={handleChangeShouldUseCpuNoise} />
</FormControl>
{Boolean(config?.shouldShowLocalizationToggle) && <SettingsLanguageSelect />}
<FormControl>
<FormLabel>{t('settings.enableInformationalPopovers')}</FormLabel>
<Switch
isChecked={shouldEnableInformationalPopovers}
onChange={handleChangeShouldEnableInformationalPopovers}
/>
</FormControl>
</StickyScrollable>
{Boolean(config?.shouldShowDeveloperSettings) && (
<StickyScrollable title={t('settings.developer')}>
<SettingsDeveloperLogIsEnabled />
<SettingsDeveloperLogLevel />
<SettingsDeveloperLogNamespaces />
</StickyScrollable>
)}
<StickyScrollable title={t('settings.generation')}>
<FormControl isDisabled={!isNSFWCheckerAvailable}>
<FormLabel>{t('settings.enableNSFWChecker')}</FormLabel>
<Switch isChecked={shouldUseNSFWChecker} onChange={handleChangeShouldUseNSFWChecker} />
</FormControl>
<FormControl isDisabled={!isWatermarkerAvailable}>
<FormLabel>{t('settings.enableInvisibleWatermark')}</FormLabel>
<Switch isChecked={shouldUseWatermarker} onChange={handleChangeShouldUseWatermarker} />
</FormControl>
</StickyScrollable>
<StickyScrollable title={t('settings.ui')}>
<FormControl>
<FormLabel>{t('settings.showProgressInViewer')}</FormLabel>
<Switch
isChecked={shouldShowProgressInViewer}
onChange={handleChangeShouldShowProgressInViewer}
/>
</FormControl>
<FormControl>
<FormLabel>{t('settings.antialiasProgressImages')}</FormLabel>
<Switch
isChecked={shouldAntialiasProgressImage}
onChange={handleChangeShouldAntialiasProgressImage}
/>
</FormControl>
<FormControl>
<InformationalPopover feature="noiseUseCPU" inPortal={false}>
<FormLabel>{t('parameters.useCpuNoise')}</FormLabel>
</InformationalPopover>
<Switch isChecked={shouldUseCpuNoise} onChange={handleChangeShouldUseCpuNoise} />
</FormControl>
{shouldShowLocalizationToggle && <SettingsLanguageSelect />}
<FormControl>
<FormLabel>{t('settings.enableInformationalPopovers')}</FormLabel>
<Switch
isChecked={shouldEnableInformationalPopovers}
onChange={handleChangeShouldEnableInformationalPopovers}
/>
</FormControl>
</StickyScrollable>
{shouldShowDeveloperSettings && (
<StickyScrollable title={t('settings.developer')}>
<SettingsDeveloperLogIsEnabled />
<SettingsDeveloperLogLevel />
<SettingsDeveloperLogNamespaces />
</StickyScrollable>
)}
{shouldShowClearIntermediates && (
<StickyScrollable title={t('settings.clearIntermediates')}>
<Button
tooltip={hasPendingItems ? t('settings.clearIntermediatesDisabled') : undefined}
colorScheme="warning"
onClick={clearIntermediates}
isLoading={isLoadingClearIntermediates}
isDisabled={!intermediatesCount || hasPendingItems}
>
{t('settings.clearIntermediatesWithCount', {
count: intermediatesCount ?? 0,
})}
</Button>
<Text fontWeight="bold">{t('settings.clearIntermediatesDesc1')}</Text>
<Text variant="subtext">{t('settings.clearIntermediatesDesc2')}</Text>
<Text variant="subtext">{t('settings.clearIntermediatesDesc3')}</Text>
</StickyScrollable>
)}
<StickyScrollable title={t('settings.resetWebUI')}>
<Button colorScheme="error" onClick={handleClickResetWebUI}>
{t('settings.resetWebUI')}
{Boolean(config?.shouldShowClearIntermediates) && (
<StickyScrollable title={t('settings.clearIntermediates')}>
<Button
tooltip={hasPendingItems ? t('settings.clearIntermediatesDisabled') : undefined}
colorScheme="warning"
onClick={clearIntermediates}
isLoading={isLoadingClearIntermediates}
isDisabled={!intermediatesCount || hasPendingItems}
>
{t('settings.clearIntermediatesWithCount', {
count: intermediatesCount ?? 0,
})}
</Button>
{shouldShowResetWebUiText && (
<>
<Text variant="subtext">{t('settings.resetWebUIDesc1')}</Text>
<Text variant="subtext">{t('settings.resetWebUIDesc2')}</Text>
</>
)}
<Text fontWeight="bold">{t('settings.clearIntermediatesDesc1')}</Text>
<Text variant="subtext">{t('settings.clearIntermediatesDesc2')}</Text>
<Text variant="subtext">{t('settings.clearIntermediatesDesc3')}</Text>
</StickyScrollable>
</FormControlGroup>
</Flex>
</ScrollableContent>
</ModalBody>
)}
<ModalFooter />
</ModalContent>
</Modal>
<Modal
closeOnOverlayClick={false}
isOpen={isRefreshModalOpen}
onClose={onRefreshModalClose}
isCentered
closeOnEsc={false}
>
<ModalOverlay backdropFilter="blur(40px)" />
<ModalContent>
<ModalHeader />
<ModalBody>
<Flex justifyContent="center">
<Text fontSize="lg">
<Text>
{t('settings.resetComplete')} {t('settings.reloadingIn')} {countdown}...
</Text>
</Text>
<StickyScrollable title={t('settings.resetWebUI')}>
<Button colorScheme="error" onClick={handleClickResetWebUI}>
{t('settings.resetWebUI')}
</Button>
{Boolean(config?.shouldShowResetWebUiText) && (
<>
<Text variant="subtext">{t('settings.resetWebUIDesc1')}</Text>
<Text variant="subtext">{t('settings.resetWebUIDesc2')}</Text>
</>
)}
</StickyScrollable>
</FormControlGroup>
</Flex>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
</>
</ScrollableContent>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
);
};

View File

@ -18,7 +18,7 @@ export const VerticalNavBar = memo(() => {
const customNavComponent = useStore($customNavComponent);
return (
<Flex flexDir="column" alignItems="center" pt={4} pb={2} gap={4}>
<Flex flexDir="column" alignItems="center" py={2} gap={4}>
<InvokeAILogoComponent />
<Flex gap={4} pt={6} h="full" flexDir="column">
<TabMountGate tab="generation">

View File

@ -62,7 +62,7 @@ export const LoadWorkflowFromGraphModal = () => {
onClose();
}, [dispatch, onClose, workflowRaw]);
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('workflows.loadFromGraph')}</ModalHeader>

View File

@ -46,6 +46,7 @@ export const NewWorkflowConfirmationAlertDialog = memo((props: Props) => {
onClose={onClose}
title={t('nodes.newWorkflow')}
acceptCallback={handleNewWorkflow}
useInert={false}
>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.newWorkflowDesc')}</Text>

View File

@ -16,7 +16,7 @@ const WorkflowLibraryModal = () => {
const { t } = useTranslation();
const { isOpen, onClose } = useWorkflowLibraryModalContext();
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<Modal isOpen={isOpen} onClose={onClose} isCentered useInert={false}>
<ModalOverlay />
<ModalContent w="80%" h="80%" minW="unset" minH="unset" maxW="1200px" maxH="664px">
<ModalHeader>{t('workflows.workflowLibrary')}</ModalHeader>

View File

@ -15241,10 +15241,9 @@ export type components = {
version: string;
/**
* Node Pack
* @description Whether or not this is a custom node
* @default null
* @description The node pack that this node belongs to, will be 'invokeai' for built-in nodes
*/
node_pack: string | null;
node_pack: string;
/**
* @description The node's classification
* @default stable

View File

@ -1 +1 @@
__version__ = "4.2.9.dev5"
__version__ = "4.2.9.dev7"