Compare commits

..

111 Commits

Author SHA1 Message Date
psychedelicious
1cbd609860 chore: bump version to v5.7.0rc2 2025-02-26 21:04:23 +11:00
psychedelicious
047c643295 tidy(app): document & clean up batch prep logic 2025-02-26 21:04:23 +11:00
psychedelicious
d1e03aa1c5 tidy(app): remove timing debug logs 2025-02-26 21:04:23 +11:00
psychedelicious
1bb8edf57e perf(app): optimise batch prep logic even more
Found another place where we deepcopy a dict, but it is safe to mutate.

Restructured the prep logic a bit to support this. Updated tests to use the new structure.
2025-02-26 21:04:23 +11:00
psychedelicious
a3e78f0db6 perf(app): optimise batch prep logic
- Avoid pydantic models when dict manipulation works
- Avoid extraneous deep copies when we can safely mutate
- Avoid NamedTuple construct and its overhead
- Fix tests to use altered function signatures
- Remove extraneous populate_graph function
2025-02-26 21:04:23 +11:00
Hosted Weblate
1ccf43aa1e translationBot(ui): update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/
Translation: InvokeAI/Web UI
2025-02-26 18:27:50 +11:00
Linos
a290975fae translationBot(ui): update translation (Vietnamese)
Currently translated at 100.0% (1795 of 1795 strings)

translationBot(ui): update translation (Vietnamese)

Currently translated at 98.2% (1763 of 1795 strings)

Co-authored-by: Linos <linos.coding@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/vi/
Translation: InvokeAI/Web UI
2025-02-26 18:27:50 +11:00
psychedelicious
43c2116d64 chore(ui): lint 2025-02-26 18:25:23 +11:00
psychedelicious
9d0a24ead3 fix(ui): race condition with node-form-field relationship overlay 2025-02-26 18:25:23 +11:00
psychedelicious
d61a3d2950 chore(ui): typegen 2025-02-26 18:25:23 +11:00
psychedelicious
7b63858802 fix(ui): hide node footer on batch and generator nodes 2025-02-26 18:25:23 +11:00
psychedelicious
fae23a744f fix(ui): always check batch sizes when there is at least 1 batch node
Not sure why I had this only checking if the size was >1. Doesn't make sense...
2025-02-26 18:25:23 +11:00
psychedelicious
7c574719e5 feat(ui): image generator w/ image to board type 2025-02-26 18:25:23 +11:00
psychedelicious
43a212dd47 tidy(ui): remove generator fields' explicit "value" parameter
This was a half-baked attempt to work around the issue with async generator nodes. It's not needed; the values are never referenced.
2025-02-26 18:25:23 +11:00
psychedelicious
a103bc8a0a feat(ui): update delete boards modal logic for updated board images endpoint
The functionality is the same - just need to explicitly opt out of categories and is_intermediate constraints.
2025-02-26 18:25:23 +11:00
psychedelicious
1a42fbf541 feat(ui): update listAllImageNamesForBoard query to match updated route 2025-02-26 18:25:23 +11:00
psychedelicious
d550067dd4 chore(ui): typegen 2025-02-26 18:25:23 +11:00
psychedelicious
7003bcad62 feat(nodes): add image generator node 2025-02-26 18:25:23 +11:00
psychedelicious
ef95f4962c feat(app): extend "all image names for board" apis
The method and route now supports:
- "none" as a board ID, sentinel value for uncategorized
- Optionally specify image categories
- Optionally specify is_intermediate
2025-02-26 18:25:23 +11:00
psychedelicious
2e13bbbe1b refactor(ui): make all readiness checking async
This fixes the broken readiness checks introduced in the previous commit.

To support async batch generators, all of the validation of the generators needs to be async. This is problematic because a lot of the validation logic was in redux selectors, which are necessarily synchronous.

To resolve this, the readiness checks and related logic are restructured to be run async in response to redux state changes via `useEffect` (another option is to directly subscribe to redux store). These async functions then set some react state. The checks are debounced to prevent thrashing the UI.

See #7580 for more context about this issue.

Other changes:
- Fix a minor issue where empty collections were also checked against their min and max sizes, and errors were shown for all the checks. If a collection is empty, we don't need to do the min/max checks. If a collection is empty, we skip the other min/max checks and do not report those errors to the user.
- When a field is connected, do not attempt to check its value. This fixes an issue where collection fields with a connection could erroneously appear to be invalid.
- Improved error messages for batch nodes.
2025-02-26 18:25:23 +11:00
psychedelicious
43349cb5ce feat(ui): fix dynamic prompts generators (but break readiness checks) 2025-02-26 18:25:23 +11:00
psychedelicious
d037eea42a feat(ui): debouncedUpdateReasons is async 2025-02-26 18:25:23 +11:00
psychedelicious
42c5be16d1 tidy(ui): extract resolveBatchValues to own file 2025-02-26 18:25:23 +11:00
psychedelicious
c7c4453a92 feat(ui): add overlay to show related fields/nodes 2025-02-26 17:25:58 +11:00
psychedelicious
c71ddf6e5d perf(ui): use css to hide/show node selection borders 2025-02-26 17:25:58 +11:00
psychedelicious
c33ed68f78 perf(ui): use css to hide/show field action buttons 2025-02-26 17:25:58 +11:00
psychedelicious
48e389f155 tweak(ui): form element header hover color 2025-02-26 17:25:58 +11:00
psychedelicious
5c423fece4 fix(ui): container view mode layout 2025-02-26 17:25:58 +11:00
psychedelicious
3f86049802 fix(ui): text & heading view mode layout 2025-02-26 17:25:58 +11:00
psychedelicious
47d395d0a8 chore(ui): knip 2025-02-26 17:25:58 +11:00
psychedelicious
b666ef41ff fix(ui): various styling fixes 2025-02-26 17:25:58 +11:00
psychedelicious
375f62380b fix(ui): disable autoscroll on column layout containers 2025-02-26 17:25:58 +11:00
psychedelicious
42c4462edc refactor(ui): styling for form edit mode (maybe done?)
- Restructure components
- Let each element render its own edit mode
- arrrrghh
2025-02-26 17:25:58 +11:00
psychedelicious
7591adebd5 refactor(ui): styling for form edit mode (wip) 2025-02-26 17:25:58 +11:00
psychedelicious
9d9b2f73db feat(ui): styling for dnd buttons 2025-02-26 17:25:58 +11:00
Mary Hipp
abaae39c29 make sure notes node exists like we do for invocation nodes 2025-02-26 07:33:22 +11:00
Mary Hipp
b1c9f59c30 add actions for copying image and opening image in new tab 2025-02-25 11:55:36 -05:00
psychedelicious
7bcbe180df tests(ui): fix test to account for new board field template default 2025-02-25 11:10:06 +11:00
psychedelicious
a626387a0b feat(ui): use auto-add board as default for nodes
Board fields in the workflow editor now default to using the auto-add board by default.

**This is a change in behaviour - previously, we defaulted to no board (i.e. Uncategorized).**

There is some translation needed between the UI field values for a board and what the graph expects.

A "BoardField" is an object in the shape of `{board_id: string}`.

Valid board field values in the graph:
- undefined
- a BoardField

Value UI values and their mapping to the graph values:
- 'none' -> undefined
- 'auto' -> BoardField for the auto-add board, or if the auto-add board is Uncategorized, undefined
- undefined -> undefined (this is a fallback case with the new logic)
- a BoardField -> the same BoardField
2025-02-25 11:10:06 +11:00
psychedelicious
759229e3c8 fix(ui): reset form initial values when workflow is saved 2025-02-25 11:04:44 +11:00
Mary Hipp
ad4b81ba21 do not render Whats New until app is ready 2025-02-24 11:56:16 -05:00
Mary Hipp
637b629b95 lint 2025-02-24 11:56:16 -05:00
psychedelicious
4aaa807415 experiment(ui): show loader until studio init actions are complete 2025-02-24 11:56:16 -05:00
Riccardo Giovanetti
e884be5042 translationBot(ui): update translation (Italian)
Currently translated at 98.9% (1737 of 1755 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.9% (1735 of 1753 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.9% (1731 of 1749 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.9% (1731 of 1749 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.6% (1726 of 1749 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2025-02-24 08:28:55 +11:00
psychedelicious
13e129bef2 fix(ui): star button not working on Chrome
Not sure why the perf optimisation doesn't work on Chrome but I reverted it.
2025-02-24 08:01:14 +11:00
psychedelicious
157904522f feat(ui): add zoom to node button to node field headers 2025-02-21 08:21:56 -05:00
psychedelicious
3045cd7b3a tidy(ui): split up FormElementEditModeHeader components 2025-02-21 08:21:56 -05:00
psychedelicious
e9e2bab4ee feat(ui): make useZoomToNode not rely on reactflow ctx 2025-02-21 08:21:56 -05:00
psychedelicious
6cd794d860 tweak(ui): container settings popover placement @ top 2025-02-21 08:21:56 -05:00
psychedelicious
c9b0307bcd fix(ui): non-direct input field names do not block reactflow drag 2025-02-21 08:21:56 -05:00
psychedelicious
55aee034b0 fix(ui): do not zoom when double clicking switch 2025-02-21 08:21:56 -05:00
psychedelicious
e81ef0a090 tweak(ui): "Description" -> "Show Description" 2025-02-21 08:21:56 -05:00
psychedelicious
1a806739f2 fix(ui): missing translation for string field component 2025-02-21 08:21:56 -05:00
psychedelicious
067aeeac23 tweak(ui): heading and text elements editable styling 2025-02-21 08:21:56 -05:00
psychedelicious
47b37d946f fix(ui): prevent selecting edit mode header 2025-02-21 08:21:56 -05:00
psychedelicious
ddfdeca8bd tweak(ui): make editable form headers less bright 2025-02-21 08:21:56 -05:00
psychedelicious
55b2a4388d fix(ui): overflow in workflow title 2025-02-21 08:21:56 -05:00
psychedelicious
6ab2bebfa6 chore: bump version to v5.7.0rc1 2025-02-21 13:00:01 +11:00
psychedelicious
3f18bfed4e feat(ui): add loading state for builder 2025-02-21 12:24:03 +11:00
psychedelicious
012054acaa feat(ui): add dialog when loading workflow if unsaved changes 2025-02-21 12:24:03 +11:00
psychedelicious
efb7f36f28 chore(ui): typegen 2025-02-21 12:24:03 +11:00
psychedelicious
05ea1c7637 chore(ui): fix circular dep 2025-02-21 12:24:03 +11:00
psychedelicious
2ba0f920d2 feat(ui): hide workflow desc in builder edit mode 2025-02-21 12:24:03 +11:00
psychedelicious
c3ab4f4d6e feat(ui): tweak dnd button styling 2025-02-21 12:24:03 +11:00
psychedelicious
36b3089d5d feat(ui): tweak dnd element buttons styling 2025-02-21 12:24:03 +11:00
psychedelicious
6c4d002bd6 feat(ui): hide reset node field value button when value is unchanged 2025-02-21 12:24:03 +11:00
psychedelicious
b2cfa137a3 feat(ui): when migrating pre-builder workflows, hide description for node fields by default, matching prev behaviour 2025-02-21 12:24:03 +11:00
psychedelicious
9d57bc1697 feat(ui): node text areas resizable
There's a reactflow issue that prevents the size from being applied when a workflow is loaded, but at least you can resize the fields.
2025-02-21 12:24:03 +11:00
psychedelicious
e6db36d0c4 feat(ui): hide the root container frame and header 2025-02-21 12:24:03 +11:00
psychedelicious
78832e546a feat(ui): restore plus sign button to add node field to form 2025-02-21 12:24:03 +11:00
psychedelicious
6cfeadb33b feat(ui): add fake dnd node field element w/ info tooltip 2025-02-21 12:24:03 +11:00
psychedelicious
d1d3971ee3 feat(ui): make index optional when adding elements, update tests 2025-02-21 12:24:03 +11:00
psychedelicious
e9ce259d43 feat(ui): smaller buttons for builder dnd elements 2025-02-21 12:24:03 +11:00
psychedelicious
34d988063f feat(ui): change reset button to menu 2025-02-21 12:24:03 +11:00
psychedelicious
e2bdbfe721 fix(ui): use getIsFormEmpty util when validating workflow 2025-02-21 12:24:03 +11:00
psychedelicious
fe7e1958ea fix(ui): fall back to empty form if invalid during validation 2025-02-21 12:24:03 +11:00
psychedelicious
cf8f18e690 feat(ui): add getIsFormEmpty util & tests 2025-02-21 12:24:03 +11:00
psychedelicious
da7b31b2a8 fix(app): add form to Workflow pydantic schema so it gets saved 2025-02-21 12:24:03 +11:00
psychedelicious
fb82664944 fix(ui): update linear view field migration logic to work w/ new data structure 2025-02-21 12:24:03 +11:00
psychedelicious
58ae9ed8a5 feat(ui): add form structure validation and tests 2025-02-21 12:24:03 +11:00
psychedelicious
d142a94b67 chore(ui): knip 2025-02-21 12:24:03 +11:00
psychedelicious
c8135126f2 fix(ui): use "native" reactflow interaction class names 2025-02-21 12:24:03 +11:00
psychedelicious
560910ed2f feat(ui): workflows panel redesign WIP 2025-02-21 12:24:03 +11:00
psychedelicious
b78ac40a22 feat(ui): workflows panel redesign WIP 2025-02-21 12:24:03 +11:00
psychedelicious
9ecafc8706 feat(ui): workflows panel redesign WIP 2025-02-21 12:24:03 +11:00
psychedelicious
871cb54988 feat(ui): panel resize handles have grab icon 2025-02-21 12:24:03 +11:00
psychedelicious
e3069ad336 fix(ui): remove ancient node selection logic that created duplicate node selection actions 2025-02-21 12:24:03 +11:00
psychedelicious
28027702dd feat(ui): add useZoomToNode hook 2025-02-21 12:24:03 +11:00
psychedelicious
d72840620a feat(ui): remove extraneous formElementNodeFieldInitialValueChanged action 2025-02-21 12:24:03 +11:00
psychedelicious
4f2de2674e feat(ui): remove extraneous formContainerChildrenReordered action 2025-02-21 12:24:03 +11:00
psychedelicious
340c9c0697 feat(ui): make builder heading a bit smaller 2025-02-21 12:24:03 +11:00
psychedelicious
f77549dc4f feat(ui): use constants for reactflow opt-out classNames 2025-02-20 14:25:51 +11:00
psychedelicious
5653352ae8 feat(ui): double click to zoom to node
Requires a bit of fanagling to ensure the double click doesn't interfer w/ other stuff
2025-02-20 14:25:51 +11:00
psychedelicious
f1bc2ea962 fix(ui): allow pasting of collapsed edges 2025-02-20 14:25:51 +11:00
psychedelicious
2a9f7b2e38 feat(ui): abstract node/field validation logic, use error color for node title when node has errors 2025-02-20 14:25:51 +11:00
psychedelicious
c379d76844 feat(ui): add "unsafe" version of field instance selector 2025-02-20 14:25:51 +11:00
psychedelicious
6496fcdcbd feat(ui): make field names draggable, not the whole field name "bar" 2025-02-20 14:25:51 +11:00
psychedelicious
812b8fddd6 feat(ui): slimmer image component 2025-02-20 14:25:51 +11:00
psychedelicious
dc9165dfc1 chore(ui): bump vitest to latest
All but the core `vitest` package were updated recently. Tests still ran but the test UI dashboard didn't. After updating, all tests still run, seems fine.

Also tested building in app and package mode.
2025-02-20 09:08:24 +11:00
psychedelicious
59826438f6 fix(ui): failing test cases for form manip utils 2025-02-20 09:08:24 +11:00
psychedelicious
87cd52241d tests(ui): coverage for form-manipulation.ts 2025-02-20 09:08:24 +11:00
psychedelicious
7506b0e7ae feat(ui): require parentId when adding form elements 2025-02-20 09:08:24 +11:00
psychedelicious
4b29a2f395 refactor(ui): validateWorkflow takes a single object as arg 2025-02-20 09:08:24 +11:00
psychedelicious
3bcaa42309 tidy(ui): more file/variable organisation 2025-02-20 09:08:24 +11:00
psychedelicious
8e14cdb8b6 feat(ui): make dnd hooks never throw
Just log errors.
2025-02-20 09:08:24 +11:00
psychedelicious
9ef6e52ad8 tidy(ui): organize & document builder dnd logic 2025-02-20 09:08:24 +11:00
psychedelicious
148bd70a24 refactor(ui): revert to using single tree for form data 2025-02-20 09:08:24 +11:00
psychedelicious
1461c88c12 lint model 2025-02-20 09:08:24 +11:00
psychedelicious
bcfeae94d2 fix(ui): node title shows text cursor 2025-02-20 09:08:24 +11:00
psychedelicious
40eedfebf7 fix(ui): zoom reset on first interaction
Closes #7648
2025-02-20 09:08:24 +11:00
psychedelicious
d0a231d59e fix(ui): model field types not recognized as such during workflow validation and field styling 2025-02-20 09:08:24 +11:00
208 changed files with 6820 additions and 4299 deletions

View File

@@ -1,4 +1,4 @@
from typing import Optional, Union
from typing import Literal, Optional, Union
from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
@@ -87,7 +88,9 @@ async def delete_board(
try:
if include_images is True:
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
board_id=board_id,
categories=None,
is_intermediate=None,
)
ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id)
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
@@ -98,7 +101,9 @@ async def delete_board(
)
else:
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
board_id=board_id,
categories=None,
is_intermediate=None,
)
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
@@ -141,11 +146,15 @@ async def list_boards(
response_model=list[str],
)
async def list_all_board_image_names(
board_id: str = Path(description="The id of the board"),
board_id: str | Literal["none"] = Path(description="The id of the board"),
categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."),
is_intermediate: bool | None = Query(default=None, description="Whether to list intermediate images."),
) -> list[str]:
"""Gets a list of images for a board"""
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id,
categories,
is_intermediate,
)
return image_names

View File

@@ -64,13 +64,50 @@ class ImageBatchInvocation(BaseBatchInvocation):
"""Create a batched generation, where the workflow is executed once for each image in the batch."""
images: list[ImageField] = InputField(
default=[], min_length=1, description="The images to batch over", input=Input.Direct
default=[],
min_length=1,
description="The images to batch over",
)
def invoke(self, context: InvocationContext) -> ImageOutput:
raise NotExecutableNodeError()
@invocation_output("image_generator_output")
class ImageGeneratorOutput(BaseInvocationOutput):
"""Base class for nodes that output a collection of boards"""
images: list[ImageField] = OutputField(description="The generated images")
class ImageGeneratorField(BaseModel):
pass
@invocation(
"image_generator",
title="Image Generator",
tags=["primitives", "board", "image", "batch", "special"],
category="primitives",
version="1.0.0",
classification=Classification.Special,
)
class ImageGenerator(BaseInvocation):
"""Generated a collection of images for use in a batched generation"""
generator: ImageGeneratorField = InputField(
description="The image generator.",
input=Input.Direct,
title="Generator Type",
)
def __init__(self):
raise NotExecutableNodeError()
def invoke(self, context: InvocationContext) -> ImageGeneratorOutput:
raise NotExecutableNodeError()
@invocation(
"string_batch",
title="String Batch",

View File

@@ -1,5 +1,7 @@
from abc import ABC, abstractmethod
from typing import Optional
from typing import Literal, Optional
from invokeai.app.services.image_records.image_records_common import ImageCategory
class BoardImageRecordStorageBase(ABC):
@@ -25,7 +27,9 @@ class BoardImageRecordStorageBase(ABC):
@abstractmethod
def get_all_board_image_names_for_board(
self,
board_id: str,
board_id: str | Literal["none"],
categories: list[ImageCategory] | None,
is_intermediate: bool | None,
) -> list[str]:
"""Gets all board images for a board, as a list of the image names."""
pass

View File

@@ -1,9 +1,13 @@
import sqlite3
import threading
from typing import Optional, cast
from typing import Literal, Optional, cast
from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
from invokeai.app.services.image_records.image_records_common import ImageRecord, deserialize_image_record
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageRecord,
deserialize_image_record,
)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
@@ -97,17 +101,59 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
self._lock.release()
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
def get_all_board_image_names_for_board(
self,
board_id: str | Literal["none"],
categories: list[ImageCategory] | None,
is_intermediate: bool | None,
) -> list[str]:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT image_name
FROM board_images
WHERE board_id = ?;
""",
(board_id,),
)
params: list[str | bool] = []
# Base query is a join between images and board_images
stmt = """
SELECT images.image_name
FROM images
LEFT JOIN board_images ON board_images.image_name = images.image_name
WHERE 1=1
"""
# Add the board_id filter
if board_id == "none":
stmt += "AND board_images.board_id IS NULL"
else:
stmt += "AND board_images.board_id = ?"
params.append(board_id)
# Add the category filter
if categories is not None:
# Convert the enum values to unique list of strings
category_strings = [c.value for c in set(categories)]
# Create the correct length of placeholders
placeholders = ",".join("?" * len(category_strings))
stmt += f"""--sql
AND images.image_category IN ( {placeholders} )
"""
# Unpack the included categories into the query params
for c in category_strings:
params.append(c)
# Add the is_intermediate filter
if is_intermediate is not None:
stmt += """--sql
AND images.is_intermediate = ?
"""
params.append(is_intermediate)
# Put a ring on it
stmt += ";"
# Execute the query
self._cursor.execute(stmt, params)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
image_names = [r[0] for r in result]
return image_names

View File

@@ -1,5 +1,7 @@
from abc import ABC, abstractmethod
from typing import Optional
from typing import Literal, Optional
from invokeai.app.services.image_records.image_records_common import ImageCategory
class BoardImagesServiceABC(ABC):
@@ -25,7 +27,9 @@ class BoardImagesServiceABC(ABC):
@abstractmethod
def get_all_board_image_names_for_board(
self,
board_id: str,
board_id: str | Literal["none"],
categories: list[ImageCategory] | None,
is_intermediate: bool | None,
) -> list[str]:
"""Gets all board images for a board, as a list of the image names."""
pass

View File

@@ -1,6 +1,7 @@
from typing import Optional
from typing import Literal, Optional
from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.invoker import Invoker
@@ -25,9 +26,15 @@ class BoardImagesService(BoardImagesServiceABC):
def get_all_board_image_names_for_board(
self,
board_id: str,
board_id: str | Literal["none"],
categories: list[ImageCategory] | None,
is_intermediate: bool | None,
) -> list[str]:
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
board_id,
categories,
is_intermediate,
)
def get_board_for_image(
self,

View File

@@ -1,7 +1,7 @@
import datetime
import json
from itertools import chain, product
from typing import Generator, Iterable, Literal, NamedTuple, Optional, TypeAlias, Union, cast
from typing import Generator, Literal, Optional, TypeAlias, Union, cast
from pydantic import (
AliasChoices,
@@ -406,61 +406,143 @@ class IsFullResult(BaseModel):
# region Util
def populate_graph(graph: Graph, node_field_values: Iterable[NodeFieldValue]) -> Graph:
def create_session_nfv_tuples(batch: Batch, maximum: int) -> Generator[tuple[str, str, str], None, None]:
"""
Populates the given graph with the given batch data items.
"""
graph_clone = graph.model_copy(deep=True)
for item in node_field_values:
node = graph_clone.get_node(item.node_path)
if node is None:
continue
setattr(node, item.field_name, item.value)
graph_clone.update_node(item.node_path, node)
return graph_clone
Given a batch and a maximum number of sessions to create, generate a tuple of session_id, session_json, and
field_values_json for each session.
The batch has a "source" graph and a data property. The data property is a list of lists of BatchDatum objects.
Each BatchDatum has a field identifier (e.g. a node id and field name), and a list of values to substitute into
the field.
def create_session_nfv_tuples(
batch: Batch, maximum: int
) -> Generator[tuple[GraphExecutionState, list[NodeFieldValue], Optional[WorkflowWithoutID]], None, None]:
"""
Create all graph permutations from the given batch data and graph. Yields tuples
of the form (graph, batch_data_items) where batch_data_items is the list of BatchDataItems
that was applied to the graph.
This structure allows us to create a new graph for every possible permutation of BatchDatum objects:
- Each BatchDatum can be "expanded" into a dict of node-field-value tuples - one for each item in the BatchDatum.
- Zip each inner list of expanded BatchDatum objects together. Call this a "batch_data_list".
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of permutations of BatchDatum
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of lists of BatchDatum objects.
Each inner list now represents the substitution values for a single permutation (session).
- For each permutation, substitute the values into the graph
This function is optimized for performance, as it is used to generate a large number of sessions at once.
Args:
batch: The batch to generate sessions from
maximum: The maximum number of sessions to generate
Returns:
A generator that yields tuples of session_id, session_json, and field_values_json for each session. The
generator will stop early if the maximum number of sessions is reached.
"""
# TODO: Should this be a class method on Batch?
data: list[list[tuple[NodeFieldValue]]] = []
data: list[list[tuple[dict]]] = []
batch_data_collection = batch.data if batch.data is not None else []
for batch_datum_list in batch_data_collection:
# each batch_datum_list needs to be convered to NodeFieldValues and then zipped
node_field_values_to_zip: list[list[NodeFieldValue]] = []
for batch_datum_list in batch_data_collection:
node_field_values_to_zip: list[list[dict]] = []
# Expand each BatchDatum into a list of dicts - one for each item in the BatchDatum
for batch_datum in batch_datum_list:
node_field_values = [
NodeFieldValue(node_path=batch_datum.node_path, field_name=batch_datum.field_name, value=item)
# Note: A tuple here is slightly faster than a dict, but we need the object in dict form to be inserted
# in the session_queue table anyways. So, overall creating NFVs as dicts is faster.
{"node_path": batch_datum.node_path, "field_name": batch_datum.field_name, "value": item}
for item in batch_datum.items
]
node_field_values_to_zip.append(node_field_values)
# Zip the dicts together to create a list of dicts for each permutation
data.append(list(zip(*node_field_values_to_zip, strict=True))) # type: ignore [arg-type]
# create generator to yield session,nfv tuples
# We serialize the graph and session once, then mutate the graph dict in place for each session.
#
# This sounds scary, but it's actually fine.
#
# The batch prep logic injects field values into the same fields for each generated session.
#
# For example, after the product operation, we'll end up with a list of node-field-value tuples like this:
# [
# (
# {"node_path": "1", "field_name": "a", "value": 1},
# {"node_path": "2", "field_name": "b", "value": 2},
# {"node_path": "3", "field_name": "c", "value": 3},
# ),
# (
# {"node_path": "1", "field_name": "a", "value": 4},
# {"node_path": "2", "field_name": "b", "value": 5},
# {"node_path": "3", "field_name": "c", "value": 6},
# )
# ]
#
# Note that each tuple has the same length, and each tuple substitutes values in for exactly the same node fields.
# No matter the complexity of the batch, this property holds true.
#
# This means each permutation's substitution can be done in-place on the same graph dict, because it overwrites the
# previous mutation. We only need to serialize the graph once, and then we can mutate it in place for each session.
#
# Previously, we had created new Graph objects for each session, but this was very slow for large (1k+ session
# batches). We then tried dumping the graph to dict and using deep-copy to create a new dict for each session,
# but this was also slow.
#
# Overall, we achieved a 100x speedup by mutating the graph dict in place for each session over creating new Graph
# objects for each session.
#
# We will also mutate the session dict in place, setting a new ID for each session and setting the mutated graph
# dict as the session's graph.
# Dump the batch's graph to a dict once
graph_as_dict = batch.graph.model_dump(warnings=False, exclude_none=True)
# We must provide a Graph object when creating the "dummy" session dict, but we don't actually use it. It will be
# overwritten for each session by the mutated graph_as_dict.
session_dict = GraphExecutionState(graph=Graph()).model_dump(warnings=False, exclude_none=True)
# Now we can create a generator that yields the session_id, session_json, and field_values_json for each session.
count = 0
# Each batch may have multiple runs, so we need to generate the same number of sessions for each run. The total is
# still limited by the maximum number of sessions.
for _ in range(batch.runs):
for d in product(*data):
if count >= maximum:
# We've reached the maximum number of sessions we may generate
return
# Flatten the list of lists of dicts into a single list of dicts
# TODO(psyche): Is the a more efficient way to do this?
flat_node_field_values = list(chain.from_iterable(d))
graph = populate_graph(batch.graph, flat_node_field_values)
yield (GraphExecutionState(graph=graph), flat_node_field_values, batch.workflow)
# Need a fresh ID for each session
session_id = uuid_string()
# Mutate the session dict in place
session_dict["id"] = session_id
# Substitute the values into the graph
for nfv in flat_node_field_values:
graph_as_dict["nodes"][nfv["node_path"]][nfv["field_name"]] = nfv["value"]
# Mutate the session dict in place
session_dict["graph"] = graph_as_dict
# Serialize the session and field values
# Note the use of pydantic's to_jsonable_python to handle serialization of any python object, including sets.
session_json = json.dumps(session_dict, default=to_jsonable_python)
field_values_json = json.dumps(flat_node_field_values, default=to_jsonable_python)
# Yield the session_id, session_json, and field_values_json
yield (session_id, session_json, field_values_json)
# Increment the count so we know when to stop
count += 1
def calc_session_count(batch: Batch) -> int:
"""
Calculates the number of sessions that would be created by the batch, without incurring
the overhead of actually generating them. Adapted from `create_sessions().
Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
creating them, as is done in `create_session_nfv_tuples()`.
The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
many were _actually_ created (which may be less due to the maximum number of sessions).
"""
# TODO: Should this be a class method on Batch?
if not batch.data:
@@ -476,42 +558,75 @@ def calc_session_count(batch: Batch) -> int:
return len(data_product) * batch.runs
class SessionQueueValueToInsert(NamedTuple):
"""A tuple of values to insert into the session_queue table"""
# Careful with the ordering of this - it must match the insert statement
queue_id: str # queue_id
session: str # session json
session_id: str # session_id
batch_id: str # batch_id
field_values: Optional[str] # field_values json
priority: int # priority
workflow: Optional[str] # workflow json
origin: str | None
destination: str | None
retried_from_item_id: int | None = None
ValueToInsertTuple: TypeAlias = tuple[
str, # queue_id
str, # session (as stringified JSON)
str, # session_id
str, # batch_id
str | None, # field_values (optional, as stringified JSON)
int, # priority
str | None, # workflow (optional, as stringified JSON)
str | None, # origin (optional)
str | None, # destination (optional)
str | None, # retried_from_item_id (optional, this is always None for new items)
]
"""A type alias for the tuple of values to insert into the session queue table."""
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
def prepare_values_to_insert(
queue_id: str, batch: Batch, priority: int, max_new_queue_items: int
) -> list[ValueToInsertTuple]:
"""
Given a batch, prepare the values to insert into the session queue table. The list of tuples can be used with an
`executemany` statement to insert multiple rows at once.
Args:
queue_id: The ID of the queue to insert the items into
batch: The batch to prepare the values for
priority: The priority of the queue items
max_new_queue_items: The maximum number of queue items to insert
def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new_queue_items: int) -> ValuesToInsert:
values_to_insert: ValuesToInsert = []
for session, field_values, workflow in create_session_nfv_tuples(batch, max_new_queue_items):
# sessions must have unique id
session.id = uuid_string()
Returns:
A list of tuples to insert into the session queue table. Each tuple contains the following values:
- queue_id
- session (as stringified JSON)
- session_id
- batch_id
- field_values (optional, as stringified JSON)
- priority
- workflow (optional, as stringified JSON)
- origin (optional)
- destination (optional)
- retried_from_item_id (optional, this is always None for new items)
"""
# A tuple is a fast and memory-efficient way to store the values to insert. Previously, we used a NamedTuple, but
# measured a ~5% performance improvement by using a normal tuple instead. For very large batches (10k+ items), the
# this difference becomes noticeable.
#
# So, despite the inferior DX with normal tuples, we use one here for performance reasons.
values_to_insert: list[ValueToInsertTuple] = []
# pydantic's to_jsonable_python handles serialization of any python object, including sets, which json.dumps does
# not support by default. Apparently there are sets somewhere in the graph.
# The same workflow is used for all sessions in the batch - serialize it once
workflow_json = json.dumps(batch.workflow, default=to_jsonable_python) if batch.workflow else None
for session_id, session_json, field_values_json in create_session_nfv_tuples(batch, max_new_queue_items):
values_to_insert.append(
SessionQueueValueToInsert(
queue_id=queue_id,
session=session.model_dump_json(warnings=False, exclude_none=True), # as json
session_id=session.id,
batch_id=batch.batch_id,
# must use pydantic_encoder bc field_values is a list of models
field_values=json.dumps(field_values, default=to_jsonable_python) if field_values else None, # as json
priority=priority,
workflow=json.dumps(workflow, default=to_jsonable_python) if workflow else None, # as json
origin=batch.origin,
destination=batch.destination,
(
queue_id,
session_json,
session_id,
batch.batch_id,
field_values_json,
priority,
workflow_json,
batch.origin,
batch.destination,
None,
)
)
return values_to_insert

View File

@@ -27,7 +27,6 @@ from invokeai.app.services.session_queue.session_queue_common import (
SessionQueueItemDTO,
SessionQueueItemNotFoundError,
SessionQueueStatus,
SessionQueueValueToInsert,
calc_session_count,
prepare_values_to_insert,
)
@@ -772,7 +771,7 @@ class SqliteSessionQueue(SessionQueueBase):
try:
self.__lock.acquire()
values_to_insert: list[SessionQueueValueToInsert] = []
values_to_insert: list[tuple] = []
retried_item_ids: list[int] = []
for item_id in item_ids:
@@ -798,17 +797,17 @@ class SqliteSessionQueue(SessionQueueBase):
else queue_item.item_id
)
value_to_insert = SessionQueueValueToInsert(
queue_id=queue_item.queue_id,
batch_id=queue_item.batch_id,
destination=queue_item.destination,
field_values=field_values_json,
origin=queue_item.origin,
priority=queue_item.priority,
workflow=workflow_json,
session=cloned_session_json,
session_id=cloned_session.id,
retried_from_item_id=retried_from_item_id,
value_to_insert = (
queue_item.queue_id,
queue_item.batch_id,
queue_item.destination,
field_values_json,
queue_item.origin,
queue_item.priority,
workflow_json,
cloned_session_json,
cloned_session.id,
retried_from_item_id,
)
values_to_insert.append(value_to_insert)

View File

@@ -62,9 +62,13 @@ class WorkflowWithoutID(BaseModel):
notes: str = Field(description="The notes of the workflow.")
exposedFields: list[ExposedField] = Field(description="The exposed fields of the workflow.")
meta: WorkflowMeta = Field(description="The meta of the workflow.")
# TODO: nodes and edges are very loosely typed
# TODO(psyche): nodes, edges and form are very loosely typed - they are strictly modeled and checked on the frontend.
nodes: list[dict[str, JsonValue]] = Field(description="The nodes of the workflow.")
edges: list[dict[str, JsonValue]] = Field(description="The edges of the workflow.")
# TODO(psyche): We have a crapload of workflows that have no form, bc it was added after we introduced workflows.
# This is typed as optional to prevent errors when pulling workflows from the DB. The frontend adds a default form if
# it is None.
form: dict[str, JsonValue] | None = Field(default=None, description="The form of the workflow.")
model_config = ConfigDict(extra="ignore")

View File

@@ -136,8 +136,8 @@
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.5",
"@vitest/coverage-v8": "^3.0.6",
"@vitest/ui": "^3.0.6",
"concurrently": "^8.2.2",
"csstype": "^3.1.3",
"dpdm": "^3.14.0",
@@ -158,7 +158,7 @@
"vite-plugin-dts": "^4.5.0",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
"vitest": "^3.0.6"
},
"engines": {
"pnpm": "8"

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,7 @@
"back": "Back",
"batch": "Batch Manager",
"beta": "Beta",
"board": "Board",
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
@@ -311,6 +312,7 @@
},
"gallery": {
"gallery": "Gallery",
"images": "Images",
"assets": "Assets",
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
"assetsTab": "Files youve uploaded for use in your projects.",
@@ -881,11 +883,15 @@
"parseString": "Parse String",
"splitOn": "Split On",
"noBatchGroup": "no group",
"generatorImagesCategory": "Category",
"generatorImages_one": "{{count}} image",
"generatorImages_other": "{{count}} images",
"generatorNRandomValues_one": "{{count}} random value",
"generatorNRandomValues_other": "{{count}} random values",
"generatorNoValues": "empty",
"generatorLoading": "loading",
"generatorLoadFromFile": "Load from File",
"generatorImagesFromBoard": "Images from Board",
"dynamicPromptsRandom": "Dynamic Prompts (Random)",
"dynamicPromptsCombinatorial": "Dynamic Prompts (Combinatorial)",
"addNode": "Add Node",
@@ -902,6 +908,8 @@
"missingNode": "Missing invocation node",
"missingInvocationTemplate": "Missing invocation template",
"missingFieldTemplate": "Missing field template",
"missingSourceOrTargetNode": "Missing source or target node",
"missingSourceOrTargetHandle": "Missing source or target handle",
"nodePack": "Node pack",
"collection": "Collection",
"singleFieldType": "{{name}} (Single)",
@@ -952,6 +960,7 @@
"nodeSearch": "Search for nodes",
"nodeTemplate": "Node Template",
"nodeType": "Node Type",
"nodeName": "Node Name",
"noFieldsLinearview": "No fields added to Linear View",
"noFieldsViewMode": "This workflow has no selected fields to display. View the full workflow to configure values.",
"workflowHelpText": "Need Help? Check out our guide to <LinkComponent>Getting Started with Workflows</LinkComponent>.",
@@ -970,6 +979,8 @@
"newWorkflow": "New Workflow",
"newWorkflowDesc": "Create a new workflow?",
"newWorkflowDesc2": "Your current workflow has unsaved changes.",
"loadWorkflowDesc": "Load workflow?",
"loadWorkflowDesc2": "Your current workflow has unsaved changes.",
"clearWorkflow": "Clear Workflow",
"clearWorkflowDesc": "Clear this workflow and start a new one?",
"clearWorkflowDesc2": "Your current workflow has unsaved changes.",
@@ -999,6 +1010,7 @@
"unknownOutput": "Unknown output: {{name}}",
"updateNode": "Update Node",
"updateApp": "Update App",
"loadingTemplates": "Loading {{name}}",
"updateAllNodes": "Update Nodes",
"allNodesUpdated": "All Nodes Updated",
"unableToUpdateNodes_one": "Unable to update {{count}} node",
@@ -1074,7 +1086,7 @@
"emptyBatches": "empty batches",
"batchNodeNotConnected": "Batch node not connected: {{label}}",
"batchNodeEmptyCollection": "Some batch nodes have empty collections",
"invalidBatchConfigurationCannotCalculate": "Invalid batch configuration; cannot calculate",
"collectionEmpty": "empty collection",
"collectionTooFewItems": "too few items, minimum {{minItems}}",
"collectionTooManyItems": "too many items, maximum {{maxItems}}",
"collectionStringTooLong": "too long, max {{maxLength}}",
@@ -1084,6 +1096,7 @@
"collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (exc max)",
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (exc min)",
"collectionNumberNotMultipleOf": "{{value}} not multiple of {{multipleOf}}",
"batchNodeCollectionSizeMismatchNoGroupId": "Batch group collection size mismatch",
"batchNodeCollectionSizeMismatch": "Collection size mismatch on Batch {{batchGroupId}}",
"noModelSelected": "No model selected",
"noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation",
@@ -1704,21 +1717,29 @@
"copyShareLink": "Copy Share Link",
"copyShareLinkForWorkflow": "Copy Share Link for Workflow",
"delete": "Delete",
"openLibrary": "Open Library",
"builder": {
"builder": "Builder",
"deleteAllElements": "Delete All Form Elements",
"resetAllNodeFields": "Reset All Node Fields",
"builder": "Form Builder",
"layout": "Layout",
"row": "Row",
"column": "Column",
"nodeField": "Node Field",
"zoomToNode": "Zoom to Node",
"nodeFieldTooltip": "To add a node field, click the small plus sign button on the field in the Workflow Editor, or drag the field by its name into the form.",
"addToForm": "Add to Form",
"label": "Label",
"description": "Description",
"showDescription": "Show Description",
"component": "Component",
"numberInput": "Number Input",
"singleLine": "Single Line",
"multiLine": "Multi Line",
"slider": "Slider",
"both": "Both",
"emptyRootPlaceholderViewMode": "Click Edit to start building a form for this workflow.",
"emptyRootPlaceholderEditMode": "Drag a form element or node field here to get started.",
"containerPlaceholder": "Empty Container",
"containerPlaceholderDesc": "Drag a form element or node field into this container.",
"headingPlaceholder": "Empty Heading",
"textPlaceholder": "Empty Text",
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release."

View File

@@ -699,7 +699,6 @@
"batchNodeEmptyCollection": "Alcuni nodi lotto hanno raccolte vuote",
"emptyBatches": "lotti vuoti",
"batchNodeCollectionSizeMismatch": "Le dimensioni della raccolta nel Lotto {{batchGroupId}} non corrispondono",
"invalidBatchConfigurationCannotCalculate": "Configurazione lotto non valida; impossibile calcolare",
"collectionStringTooShort": "troppo corto, minimo {{minLength}}",
"collectionNumberNotMultipleOf": "{{value}} non è multiplo di {{multipleOf}}",
"collectionNumberLTMin": "{{value}} < {{minimum}} (incr min)",
@@ -2174,7 +2173,11 @@
"pasteToCanvas": "Tela",
"pasteToCanvasDesc": "Nuovo livello (nella Tela)",
"pastedTo": "Incollato su {{destination}}",
"regionCopiedToClipboard": "{{region}} Copiato negli appunti"
"regionCopiedToClipboard": "{{region}} Copiato negli appunti",
"errors": {
"unableToFindImage": "Impossibile trovare l'immagine",
"unableToLoadImage": "Impossibile caricare l'immagine"
}
},
"ui": {
"tabs": {

View File

@@ -63,7 +63,7 @@
"compareImage": "So Sánh Ảnh",
"compareHelp4": "Nhấn <Kbd>Z</Kbd> hoặc <Kbd>Esc</Kbd> để thoát.",
"compareHelp3": "Nhấn <Kbd>C</Kbd> để đổi ảnh được so sánh.",
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện ảnh hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
"showArchivedBoards": "Hiển Thị Bảng Được Lưu Trữ",
"drop": "Thả",
"copy": "Sao Chép",
@@ -76,11 +76,11 @@
"deleteImagePermanent": "Ảnh đã xoá không thể phục hồi.",
"exitSearch": "Thoát Tìm Kiếm Hình Ảnh",
"exitBoardSearch": "Thoát Tìm Kiểm Bảng",
"gallery": "Thư Viện",
"gallery": "Thư Viện Ảnh",
"galleryImageSize": "Kích Thước Ảnh",
"downloadSelection": "Tải xuống Phần Được Lựa Chọn",
"bulkDownloadRequested": "Chuẩn Bị Tải Xuống",
"unableToLoad": "Không Thể Tải Thư viện",
"unableToLoad": "Không Thể Tải Thư viện Ảnh",
"newestFirst": "Mới Nhất Trước",
"showStarredImagesFirst": "Hiển Thị Ảnh Gắn Sao Trước",
"bulkDownloadRequestedDesc": "Yêu cầu tải xuống đang được chuẩn bị. Vui lòng chờ trong giây lát.",
@@ -103,7 +103,7 @@
"displaySearch": "Tìm Kiếm Hình Ảnh",
"selectAnImageToCompare": "Chọn Ảnh Để So Sánh",
"slider": "Thanh Trượt",
"gallerySettings": "Cài Đặt Thư Viện",
"gallerySettings": "Cài Đặt Thư Viện Ảnh",
"image": "hình ảnh",
"noImageSelected": "Không Có Ảnh Được Chọn",
"noImagesInGallery": "Không Có Ảnh Để Hiển Thị",
@@ -117,7 +117,7 @@
"unstarImage": "Ngừng Gắn Sao Cho Ảnh",
"compareHelp2": "Nhấn <Kbd>M</Kbd> để tuần hoàn trong chế độ so sánh.",
"boardsSettings": "Thiết Lập Bảng",
"imagesSettings": "Cài Đặt Thư Viện Ảnh",
"imagesSettings": "Cài Đặt Ảnh Trong Thư Viện Ảnh",
"assets": "Tài Nguyên"
},
"common": {
@@ -230,7 +230,10 @@
"max": "Tối Đa",
"resetToDefaults": "Đặt Lại Về Mặc Định",
"seed": "Hạt Giống",
"combinatorial": "Tổ Hợp"
"combinatorial": "Tổ Hợp",
"column": "Cột",
"layout": "Bố Cục",
"row": "Hàng"
},
"prompt": {
"addPromptTrigger": "Thêm Prompt Trigger",
@@ -285,7 +288,7 @@
"cancelBatch": "Huỷ Bỏ Lô",
"status": "Trạng Thái",
"pending": "Đang Chờ",
"gallery": "Thư Viện",
"gallery": "Thư Viện Ảnh",
"front": "trước",
"batch": "Lô",
"origin": "Nguồn Gốc",
@@ -305,10 +308,13 @@
"graphQueued": "Đồ Thị Đã Vào Hàng",
"batchQueuedDesc_other": "Thêm {{count}} phiên vào {{direction}} của hàng",
"batchSize": "Kích Thước Lô",
"cancelAllExceptCurrentQueueItemAlertDialog": "Huỷ tất cả mục đang xếp hàng ngoại trừ mục hiện tại, sẽ dừng các mục đang chờ nhưng cho phép các mục đang chạy được hoàn tất.",
"cancelAllExceptCurrentQueueItemAlertDialog": "Huỷ tất cả mục đang xếp hàng ngoại trừ việc nó sẽ dừng các mục đang chờ nhưng cho phép các mục đang chạy được hoàn tất.",
"cancelAllExceptCurrentQueueItemAlertDialog2": "Bạn có chắc muốn huỷ tất cả mục đang chờ?",
"cancelAllExceptCurrentTooltip": "Huỷ Bỏ Tất Cả Ngoại Trừ Mục Hiện Tại",
"confirm": "Đồng Ý"
"confirm": "Đồng Ý",
"retrySucceeded": "Mục Đã Thử Lại",
"retryFailed": "Có Vấn Đề Khi Thử Lại Mục",
"retryItem": "Thử Lại Mục"
},
"hotkeys": {
"canvas": {
@@ -514,16 +520,16 @@
},
"gallery": {
"galleryNavRight": {
"desc": "Sang phải theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
"desc": "Sang phải theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
"title": "Sang Phải"
},
"galleryNavDown": {
"title": "Đi Xuống",
"desc": "Đi xuống theo mạng lưới thư viện, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
"desc": "Đi xuống theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
},
"galleryNavLeft": {
"title": "Sang Trái",
"desc": "Sang trái theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
"desc": "Sang trái theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
},
"galleryNavUpAlt": {
"title": "Đi Lên (So Sánh Ảnh)",
@@ -535,7 +541,7 @@
},
"galleryNavUp": {
"title": "Đi Lên",
"desc": "Đi lên theo mạng lưới thư viện, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
"desc": "Đi lên theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
},
"galleryNavRightAlt": {
"title": "Sang Phải (So Sánh Ảnh)",
@@ -545,7 +551,7 @@
"title": "Chọn Tất Cả Trên Trang",
"desc": "Chọn tất cả ảnh trên trang hiện tại."
},
"title": "Thư Viện",
"title": "Thư Viện Ảnh",
"galleryNavDownAlt": {
"title": "Đi Xuống (So Sánh Ảnh)",
"desc": "Giống với \"Đi Xuống\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở."
@@ -964,7 +970,7 @@
"versionUnknown": " Phiên Bản Không Rõ",
"workflowContact": "Thông Tin Liên Lạc",
"workflowName": "Tên",
"saveToGallery": "Lưu Vào Thư Viện",
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
"connectionWouldCreateCycle": "Kết nối này sẽ tạo ra vòng lặp",
"addNode": "Thêm Node",
"unsupportedAnyOfLength": "quá nhiều dữ liệu hợp nhất: {{count}}",
@@ -995,7 +1001,16 @@
"generatorLoading": "đang tải",
"generatorLoadFromFile": "Tải Từ Tệp",
"dynamicPromptsRandom": "Dynamic Prompts (Ngẫu Nhiên)",
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)"
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)",
"missingSourceOrTargetNode": "Thiếu nguồn hoặc node mục tiêu",
"missingSourceOrTargetHandle": "Thiếu nguồn hoặc mục tiêu xử lý",
"deletedMissingNodeFieldFormElement": "Xóa vùng nhập bị thiếu: vùng {{fieldName}} của node {{nodeId}}",
"description": "Mô Tả",
"loadWorkflowDesc": "Tải workflow?",
"loadWorkflowDesc2": "Workflow hiện tại của bạn có những điều chỉnh chưa được lưu.",
"loadingTemplates": "Đang Tải {{name}}",
"nodeName": "Tên Node",
"unableToUpdateNode": "Cập nhật node thất bại: node {{node}} thuộc dạng {{type}} (có thể cần xóa và tạo lại)"
},
"popovers": {
"paramCFGRescaleMultiplier": {
@@ -1479,8 +1494,7 @@
"batchNodeCollectionSizeMismatch": "Kích cỡ tài nguyên không phù hợp với Lô {{batchGroupId}}",
"emptyBatches": "lô trống",
"batchNodeNotConnected": "Node Hàng Loạt chưa được kết nối: {{label}}",
"batchNodeEmptyCollection": "Một vài node hàng loạt có tài nguyên rỗng",
"invalidBatchConfigurationCannotCalculate": "Thiết lập lô không hợp lệ; không thể tính toán"
"batchNodeEmptyCollection": "Một vài node hàng loạt có tài nguyên rỗng"
},
"cfgScale": "Thang CFG",
"useSeed": "Dùng Hạt Giống",
@@ -1582,14 +1596,14 @@
"clearIntermediates": "Dọn Sạch Sản Phẩm Trung Gian",
"clearIntermediatesDisabled": "Hàng đợi phải trống để dọn dẹp các sản phẩm trung gian",
"clearIntermediatesDesc1": "Dọn dẹp các sản phẩm trung gian sẽ làm mới trạng thái của Canvas và ControlNet.",
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện ảnh. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
"resetWebUI": "Khởi Động Lại Giao Diện Web",
"showProgressInViewer": "Hiển Thị Hình Ảnh Đang Xử Lý Trong Trình Xem",
"ui": "Giao Diện Người Dùng",
"clearIntermediatesDesc3": "Ảnh trong thư viện sẽ không bị xoá.",
"clearIntermediatesDesc3": "Ảnh trong thư viện ảnh sẽ không bị xoá.",
"informationalPopoversDisabled": "Hộp Thoại Hỗ Trợ Thông Tin Đã Tắt",
"resetComplete": "Giao diện web đã được khởi động lại.",
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện ảnh hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
"displayInProgress": "Hiển Thị Hình Ảnh Đang Xử Lý",
"intermediatesClearedFailed": "Có Vấn Đề Khi Dọn Sạch Sản Phẩm Trung Gian",
"enableInvisibleWatermark": "Bật Chế Độ Ẩn Watermark",
@@ -1617,7 +1631,7 @@
"width": "Chiều Rộng",
"negativePrompt": "Lệnh Tiêu Cực",
"removeBookmark": "Bỏ Đánh Dấu",
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
"global": "Toàn Vùng",
"pullBboxIntoReferenceImageError": "Có Vấn Đề Khi Chuyển Hộp Giới Hạn Thành Ảnh Mẫu",
"clearHistory": "Xoá Lịch Sử",
@@ -1625,12 +1639,12 @@
"mergeVisibleOk": "Đã gộp layer",
"saveLayerToAssets": "Lưu Layer Vào Khu Tài Nguyên",
"canvas": "Canvas",
"savedToGalleryOk": "Đã Lưu Vào Thư Viện",
"savedToGalleryOk": "Đã Lưu Vào Thư Viện Ảnh",
"addGlobalReferenceImage": "Thêm $t(controlLayers.globalReferenceImage)",
"clipToBbox": "Chuyển Nét Thành Hộp Giới Hạn",
"moveToFront": "Chuyển Lên Trước",
"mergeVisible": "Gộp Layer Đang Hiển Thị",
"savedToGalleryError": "Lỗi khi lưu vào thư viện",
"savedToGalleryError": "Lỗi khi lưu vào thư viện ảnh",
"moveToBack": "Chuyển Về Sau",
"moveBackward": "Chuyển Xuống Cuối",
"newGlobalReferenceImageError": "Có Vấn Đề Khi Tạo Ảnh Mẫu Toàn Vùng",
@@ -1650,7 +1664,7 @@
"regional": "Khu Vực",
"regionIsEmpty": "Vùng được chọn trống",
"bookmark": "Đánh Dấu Để Đổi Nhanh",
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
"cropLayerToBbox": "Xén Layer Vào Hộp Giới Hạn",
"mergeDown": "Gộp Xuống",
"mergeVisibleError": "Lỗi khi gộp layer",
@@ -1718,11 +1732,11 @@
"pullBboxIntoLayer": "Chuyển Hộp Giới Hạn Vào Layer",
"addInpaintMask": "Thêm $t(controlLayers.inpaintMask)",
"addRegionalGuidance": "Thêm $t(controlLayers.regionalGuidance)",
"sendToGallery": "Chuyển Tới Thư Viện",
"sendToGallery": "Đã Chuyển Tới Thư Viện Ảnh",
"unlocked": "Mở Khoá",
"addReferenceImage": "Thêm $t(controlLayers.referenceImage)",
"sendingToCanvas": "Chuyển Ảnh Tạo Sinh Vào Canvas",
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện",
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện Ảnh",
"viewProgressOnCanvas": "Xem quá trình xử lý và ảnh đầu ra trong <Btn>Canvas</Btn>.",
"inpaintMask_withCount_other": "Lớp Phủ Inpaint",
"regionalGuidance_withCount_other": "Chỉ Dẫn Khu Vực",
@@ -1733,7 +1747,7 @@
"copyRasterLayerTo": "Sao Chép $t(controlLayers.rasterLayer) Tới",
"copyControlLayerTo": "Sao Chép $t(controlLayers.controlLayer) Tới",
"newRegionalGuidance": "$t(controlLayers.regionalGuidance) Mới",
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện.",
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện ảnh.",
"stagingOnCanvas": "Hiển thị hình ảnh lên",
"pullBboxIntoReferenceImage": "Chuyển Hộp Giới Hạn Vào Ảnh Mẫu",
"maskFill": "Lấp Đầy Lớp Phủ",
@@ -1755,8 +1769,8 @@
"deleteReferenceImage": "Xoá Ảnh Mẫu",
"inpaintMasks_withCount_visible": "Lớp Phủ Inpaint ({{count}})",
"disableTransparencyEffect": "Tắt Hiệu Ứng Trong Suốt",
"newGallerySession": "Phiên Thư Viện Mới",
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện.",
"newGallerySession": "Phiên Thư Viện Ảnh Mới",
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện ảnh.",
"opacity": "Độ Mờ Đục",
"rectangle": "Hình Chữ Nhật",
"addNegativePrompt": "Thêm $t(controlLayers.negativePrompt)",
@@ -1791,13 +1805,13 @@
"process": "Xử Lý"
},
"canvasContextMenu": {
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
"newGlobalReferenceImage": "Ảnh Mẫu Toàn Vùng Mới",
"cropCanvasToBbox": "Xén Canvas Vào Hộp Giới Hạn",
"newRegionalGuidance": "Chỉ Dẫn Khu Vực Mới",
"saveToGalleryGroup": "Lưu Vào Thư Viện",
"saveToGalleryGroup": "Lưu Vào Thư Viện Ảnh",
"newInpaintMask": "Lớp Phủ Inpaint Mới",
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
"newRegionalReferenceImage": "Ảnh Mẫu Khu Vực Mới",
"newControlLayer": "Layer Điều Khiển Được Mới",
"newRasterLayer": "Layer Dạng Raster Mới",
@@ -1808,7 +1822,7 @@
"copyBboxToClipboard": "Sao Chép Hộp Giới Hạn Vào Clipboard"
},
"stagingArea": {
"saveToGallery": "Lưu Vào Thư Viện",
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
"accept": "Chấp Nhận",
"discard": "Bỏ Đi",
"previous": "Trước",
@@ -2090,7 +2104,7 @@
"enableLogging": "Bật Chế Độ Ghi Log",
"logNamespaces": {
"models": "Models",
"gallery": "Thư Viện",
"gallery": "Thư Viện Ảnh",
"config": "Cấu Hình",
"queue": "Queue",
"workflows": "Workflow",
@@ -2178,7 +2192,7 @@
},
"ui": {
"tabs": {
"gallery": "Thư Viện",
"gallery": "Thư Viện Ảnh",
"models": "Models",
"generation": "Generation (Máy Tạo Sinh)",
"upscaling": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)",
@@ -2210,7 +2224,7 @@
"savingWorkflow": "Đang Lưu Workflow...",
"ascending": "Tăng Dần",
"loading": "Đang Tải Workflow",
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Túi Đồ",
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Thư Viện",
"workflows": "Workflow",
"copyShareLinkForWorkflow": "Sao Chép Liên Kết Chia Sẻ Cho Workflow",
"openWorkflow": "Mở Workflow",
@@ -2230,11 +2244,38 @@
"convertGraph": "Chuyển Đổi Đồ Thị",
"saveWorkflowToProject": "Lưu Workflow Vào Dự Án",
"workflowName": "Tên Workflow",
"workflowLibrary": "Túi Đồ",
"workflowLibrary": "Thư Viện",
"opened": "Ngày Mở",
"deleteWorkflow": "Xoá Workflow",
"workflowEditorMenu": "Menu Biên Tập Viên Workflow",
"uploadAndSaveWorkflow": "Tải Lên Túi Đồ"
"uploadAndSaveWorkflow": "Tải Lên Thư Viện",
"openLibrary": "Mở Thư Viện",
"builder": {
"resetAllNodeFields": "Khởi Động Lại Tất Cả Vùng Cho Node",
"builder": "Trình Tạo Vùng Nhập",
"layout": "Bố Cục",
"row": "Hàng",
"zoomToNode": "Phóng To Vào Node",
"addToForm": "Thêm Vào Vùng Nhập",
"label": "Nhãn Tên",
"showDescription": "Hiện Dòng Mô Tả",
"component": "Thành Phần",
"numberInput": "Nhập Số",
"singleLine": "Một Dòng",
"multiLine": "Nhiều Dòng",
"slider": "Thanh Trượt",
"both": "Cả Hai",
"emptyRootPlaceholderViewMode": "Chọn Chỉnh Sửa để bắt đầu tạo nên một vùng nhập cho workflow này.",
"emptyRootPlaceholderEditMode": "Kéo thành phần vùng nhập hoặc vùng cho node vào đây để bắt đầu.",
"containerPlaceholder": "Hộp Chứa Trống",
"headingPlaceholder": "Đầu Dòng Trống",
"textPlaceholder": "Mô Tả Trống",
"column": "Cột",
"deleteAllElements": "Xóa Tất Cả Thành Phần Vùng Nhập",
"nodeField": "Vùng Cho Node",
"nodeFieldTooltip": "Để thêm vùng cho node, bấm vào dấu cộng nhỏ trên vùng trong Vùng Biên Tập Workflow, hoặc kéo vùng theo tên của nó vào vùng nhập.",
"workflowBuilderAlphaWarning": "Trình tạo workflow đang trong giai đoạn alpha. Nó có thể xuất hiện những thay đổi đột ngột trước khi chính thức được phát hành."
}
},
"upscaling": {
"missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale",
@@ -2257,9 +2298,9 @@
"incompatibleBaseModelDesc": "Upscale chỉ hỗ trợ cho model phiên bản SD1.5 và SDXL. Đổi model chính để bật lại tính năng upscale."
},
"newUserExperience": {
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
"gettingStartedSeries": "Cần thêm hướng dẫn? Xem thử <LinkComponent>Bắt Đầu Làm Quen</LinkComponent> để biết thêm mẹo khai thác toàn bộ tiềm năng của Invoke Studio.",
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
"noModelsInstalled": "Dường như bạn chưa tải model nào cả! Bạn có thể <DownloadStarterModelsButton>tải xuống các model khởi đầu</DownloadStarterModelsButton> hoặc <ImportModelsButton>nhập vào thêm model</ImportModelsButton>.",
"lowVRAMMode": "Cho hiệu suất tốt nhất, hãy làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi."
},
@@ -2306,8 +2347,8 @@
"title": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)"
},
"howDoIGenerateAndSaveToTheGallery": {
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện?",
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện."
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện Ảnh?",
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện ảnh."
},
"howDoIEditOnTheCanvas": {
"description": "Hướng dẫn chỉnh sửa ảnh trực tiếp trên canvas.",

View File

@@ -1,13 +1,15 @@
import { Box, useGlobalModifiersInit } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
import { $didStudioInit, useStudioInitAction } from 'app/hooks/useStudioInitAction';
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
import { useLogger } from 'app/logging/useLogger';
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading';
import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useClearStorage } from 'common/hooks/useClearStorage';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
@@ -36,6 +38,7 @@ import { configChanged } from 'features/system/store/configSlice';
import { selectLanguage } from 'features/system/store/systemSelectors';
import { AppContent } from 'features/ui/components/AppContent';
import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import i18n from 'i18n';
import { size } from 'lodash-es';
@@ -54,6 +57,7 @@ interface Props {
}
const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
const didStudioInit = useStore($didStudioInit);
const clearStorage = useClearStorage();
const handleReset = useCallback(() => {
@@ -66,6 +70,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
<AppContent />
{!didStudioInit && <Loading />}
</Box>
<HookIsolator config={config} studioInitAction={studioInitAction} />
<DeleteImageModal />
@@ -75,6 +80,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
<ClearQueueConfirmationsAlertDialog />
<NewWorkflowConfirmationAlertDialog />
<LoadWorkflowConfirmationAlertDialog />
<DeleteStylePresetDialog />
<DeleteWorkflowDialog />
<ShareWorkflowModal />

View File

@@ -2,6 +2,7 @@ import 'i18n';
import type { Middleware } from '@reduxjs/toolkit';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
import type { LoggingOverrides } from 'app/logging/logger';
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
import { $authToken } from 'app/store/nanostores/authToken';
@@ -87,6 +88,12 @@ const InvokeAIUI = ({
);
}, [loggingOverrides]);
useLayoutEffect(() => {
if (studioInitAction) {
$didStudioInit.set(false);
}
}, [studioInitAction]);
useEffect(() => {
// configure API client token
if (token) {

View File

@@ -16,7 +16,8 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st
import { toast } from 'features/toast/toast';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import { useCallback, useEffect, useRef } from 'react';
import { atom } from 'nanostores';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images';
import { getStylePreset } from 'services/api/endpoints/stylePresets';
@@ -32,6 +33,9 @@ type StudioDestinationAction = _StudioInitAction<
{ destination: 'generation' | 'canvas' | 'workflows' | 'upscaling' | 'viewAllWorkflows' | 'viewAllStylePresets' }
>;
// Use global state to show loader until we are ready to render the studio.
export const $didStudioInit = atom(false);
export type StudioInitAction =
| LoadWorkflowAction
| SelectStylePresetAction
@@ -51,8 +55,6 @@ export type StudioInitAction =
export const useStudioInitAction = (action?: StudioInitAction) => {
useAssertSingleton('useStudioInitAction');
const { t } = useTranslation();
// Use a ref to ensure that we only perform the action once
const didInit = useRef(false);
const didParseOpenAPISchema = useStore($hasTemplates);
const store = useAppStore();
const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
@@ -102,16 +104,16 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
}
const metadata = getImageMetadataResult.value;
// This shows a toast
parseAndRecallAllMetadata(metadata, true);
await parseAndRecallAllMetadata(metadata, true);
store.dispatch(setActiveTab('canvas'));
},
[store, t]
);
const handleLoadWorkflow = useCallback(
(workflowId: string) => {
async (workflowId: string) => {
// This shows a toast
getAndLoadWorkflow(workflowId);
await getAndLoadWorkflow(workflowId);
store.dispatch(setActiveTab('workflows'));
},
[getAndLoadWorkflow, store]
@@ -176,36 +178,48 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
[store]
);
const handleStudioInitAction = useCallback(
async (action: StudioInitAction) => {
// This cannot be in the useEffect below because we need to await some of the actions before setting didStudioInit.
switch (action.type) {
case 'loadWorkflow':
await handleLoadWorkflow(action.data.workflowId);
break;
case 'selectStylePreset':
await handleSelectStylePreset(action.data.stylePresetId);
break;
case 'sendToCanvas':
await handleSendToCanvas(action.data.imageName);
break;
case 'useAllParameters':
await handleUseAllMetadata(action.data.imageName);
break;
case 'goToDestination':
handleGoToDestination(action.data.destination);
break;
default:
break;
}
$didStudioInit.set(true);
},
[handleGoToDestination, handleLoadWorkflow, handleSelectStylePreset, handleSendToCanvas, handleUseAllMetadata]
);
useEffect(() => {
if (didInit.current || !action || !didParseOpenAPISchema) {
if ($didStudioInit.get() || !didParseOpenAPISchema) {
return;
}
didInit.current = true;
switch (action.type) {
case 'loadWorkflow':
handleLoadWorkflow(action.data.workflowId);
break;
case 'selectStylePreset':
handleSelectStylePreset(action.data.stylePresetId);
break;
case 'sendToCanvas':
handleSendToCanvas(action.data.imageName);
break;
case 'useAllParameters':
handleUseAllMetadata(action.data.imageName);
break;
case 'goToDestination':
handleGoToDestination(action.data.destination);
break;
default:
break;
if (!action) {
$didStudioInit.set(true);
return;
}
handleStudioInitAction(action);
}, [
handleSendToCanvas,
handleUseAllMetadata,
@@ -214,5 +228,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
handleGoToDestination,
handleLoadWorkflow,
didParseOpenAPISchema,
handleStudioInitAction,
]);
};

View File

@@ -1,10 +1,11 @@
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue';
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
import { resolveBatchValue } from 'features/queue/store/readiness';
import { groupBy } from 'lodash-es';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
import type { Batch, BatchConfig } from 'services/api/types';
@@ -15,12 +16,13 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
enqueueRequested.match(action) && action.payload.tabName === 'workflows',
effect: async (action, { getState, dispatch }) => {
const state = getState();
const nodes = selectNodesSlice(state);
const nodesState = selectNodesSlice(state);
const workflow = state.workflow;
const graph = buildNodesGraph(nodes);
const templates = $templates.get();
const graph = buildNodesGraph(state, templates);
const builtWorkflow = buildWorkflowWithValidation({
nodes: nodes.nodes,
edges: nodes.edges,
nodes: nodesState.nodes,
edges: nodesState.edges,
workflow,
});
@@ -31,7 +33,7 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
const data: Batch['data'] = [];
const invocationNodes = nodes.nodes.filter(isInvocationNode);
const invocationNodes = nodesState.nodes.filter(isInvocationNode);
const batchNodes = invocationNodes.filter(isBatchNode);
// Handle zipping batch nodes. First group the batch nodes by their batch_group_id
@@ -42,9 +44,11 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
const zippedBatchDataCollectionItems: NonNullable<Batch['data']>[number] = [];
for (const node of batchNodes) {
const value = resolveBatchValue(node, invocationNodes, nodes.edges);
const value = await resolveBatchValue({ nodesState, node, dispatch });
const sourceHandle = node.data.type === 'image_batch' ? 'image' : 'value';
const edgesFromBatch = nodes.edges.filter((e) => e.source === node.id && e.sourceHandle === sourceHandle);
const edgesFromBatch = nodesState.edges.filter(
(e) => e.source === node.id && e.sourceHandle === sourceHandle
);
if (batchGroupId !== 'None') {
// If this batch node has a batch_group_id, we will zip the data collection items
for (const edge of edgesFromBatch) {

View File

@@ -22,12 +22,18 @@ const getWorkflow = async (data: GraphAndWorkflowResponse, templates: Templates)
if (data.workflow) {
// Prefer to load the workflow if it's available - it has more information
const parsed = JSON.parse(data.workflow);
return await validateWorkflow(parsed, templates, checkImageAccess, checkBoardAccess, checkModelAccess);
return await validateWorkflow({
workflow: parsed,
templates,
checkImageAccess,
checkBoardAccess,
checkModelAccess,
});
} else if (data.graph) {
// Else we fall back on the graph, using the graphToWorkflow function to convert and do layout
const parsed = JSON.parse(data.graph);
const workflow = graphToWorkflow(parsed as NonNullableGraph, true);
return await validateWorkflow(workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess);
return await validateWorkflow({ workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess });
} else {
throw new Error('No workflow or graph provided');
}

View File

@@ -6,7 +6,18 @@ import { memo } from 'react';
const Loading = () => {
return (
<Flex position="relative" width="100dvw" height="100dvh" alignItems="center" justifyContent="center" bg="#151519">
<Flex
position="absolute"
width="100dvw"
height="100dvh"
alignItems="center"
justifyContent="center"
bg="#151519"
top={0}
right={0}
bottom={0}
left={0}
>
<Image src={InvokeLogoWhite} w="8rem" h="8rem" />
<Spinner
label="Loading"

View File

@@ -1,5 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { useClipboard } from 'common/hooks/useClipboard';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import { imageCopiedToClipboard } from 'features/gallery/store/actions';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,6 +9,7 @@ import { useTranslation } from 'react-i18next';
export const useCopyImageToClipboard = () => {
const { t } = useTranslation();
const clipboard = useClipboard();
const dispatch = useAppDispatch();
const copyImageToClipboard = useCallback(
async (image_url: string) => {
@@ -23,6 +26,7 @@ export const useCopyImageToClipboard = () => {
title: t('toast.imageCopied'),
status: 'success',
});
dispatch(imageCopiedToClipboard());
});
} catch (err) {
toast({
@@ -33,7 +37,7 @@ export const useCopyImageToClipboard = () => {
});
}
},
[clipboard, t]
[clipboard, t, dispatch]
);
return copyImageToClipboard;

View File

@@ -20,7 +20,7 @@ import {
setUpscaleInitialImage,
} from 'features/imageActions/actions';
import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice';
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field';
import type { ImageDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest';
@@ -261,7 +261,7 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget<
const { fieldIdentifier } = targetData.payload;
const fieldInputInstance = selectFieldInputInstance(
const fieldInputInstance = selectFieldInputInstanceSafe(
selectNodesSlice(getState()),
fieldIdentifier.nodeId,
fieldIdentifier.fieldName

View File

@@ -36,7 +36,13 @@ const DeleteBoardModal = () => {
const boardToDelete = useStore($boardToDelete);
const { t } = useTranslation();
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } = useListAllImageNamesForBoardQuery(
boardToDelete?.board_id ?? skipToken
boardToDelete?.board_id
? {
board_id: boardToDelete?.board_id,
categories: undefined,
is_intermediate: undefined,
}
: skipToken
);
const selectImageUsageSummary = useMemo(

View File

@@ -4,7 +4,7 @@ import { useFocusRegion } from 'common/hooks/focus';
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
import { usePanel, type UsePanelOptions } from 'features/ui/hooks/usePanel';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
@@ -94,7 +94,7 @@ const GalleryPanelContent = () => {
<BoardsListWrapper />
</Flex>
</Panel>
<ResizeHandle id="gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
<HorizontalResizeHandle id="gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
<Panel id="gallery-wrapper-panel" minSize={20}>
<Gallery />
</Panel>

View File

@@ -1,5 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { imageOpenedInNewTab } from 'features/gallery/store/actions';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowSquareOutBold } from 'react-icons/pi';
@@ -7,9 +9,11 @@ import { PiArrowSquareOutBold } from 'react-icons/pi';
export const ImageMenuItemOpenInNewTab = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
}, [imageDTO.image_url]);
dispatch(imageOpenedInNewTab());
}, [imageDTO.image_url, dispatch]);
return (
<IconMenuItem

View File

@@ -18,7 +18,6 @@ import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid
import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { atom } from 'nanostores';
import type { MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
@@ -178,20 +177,15 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
);
}, [imageDTO, element, store, dndId]);
// Perf optimization:
// The gallery image component can be heavy and re-render often. We want to track hovering state without causing
// unnecessary re-renders. To do this, we use a local atom - which has a stable reference - in the image component -
// and then pass the atom to the hover icons component, which subscribes to the atom and re-renders when the atom
// changes.
const $isHovered = useMemo(() => atom(false), []);
const [isHovered, setIsHovered] = useState(false);
const onMouseOver = useCallback(() => {
$isHovered.set(true);
}, [$isHovered]);
setIsHovered(true);
}, []);
const onMouseOut = useCallback(() => {
$isHovered.set(false);
}, [$isHovered]);
setIsHovered(false);
}, []);
const onClick = useCallback<MouseEventHandler<HTMLDivElement>>(
(e) => {
@@ -247,7 +241,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
maxH="full"
borderRadius="base"
/>
<GalleryImageHoverIcons imageDTO={imageDTO} $isHovered={$isHovered} />
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered} />
</Flex>
</Box>
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}

View File

@@ -1,22 +1,19 @@
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { GalleryImageDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryImageDeleteIconButton';
import { GalleryImageOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton';
import { GalleryImageSizeBadge } from 'features/gallery/components/ImageGrid/GalleryImageSizeBadge';
import { GalleryImageStarIconButton } from 'features/gallery/components/ImageGrid/GalleryImageStarIconButton';
import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors';
import type { Atom } from 'nanostores';
import { memo } from 'react';
import type { ImageDTO } from 'services/api/types';
type Props = {
imageDTO: ImageDTO;
$isHovered: Atom<boolean>;
isHovered: boolean;
};
export const GalleryImageHoverIcons = memo(({ imageDTO, $isHovered }: Props) => {
export const GalleryImageHoverIcons = memo(({ imageDTO, isHovered }: Props) => {
const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
const isHovered = useStore($isHovered);
return (
<>

View File

@@ -1,3 +1,4 @@
import type { FlexProps } from '@invoke-ai/ui-library';
import { Box, Flex, IconButton, Tooltip, useShiftModifier } from '@invoke-ai/ui-library';
import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { useClipboard } from 'common/hooks/useClipboard';
@@ -18,12 +19,12 @@ type Props = {
withDownload?: boolean;
withCopy?: boolean;
extraCopyActions?: { label: string; getData: (data: unknown) => unknown }[];
};
} & FlexProps;
const overlayscrollbarsOptions = getOverlayScrollbarsParams('scroll', 'scroll').options;
const DataViewer = (props: Props) => {
const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions } = props;
const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions, ...rest } = props;
const dataString = useMemo(() => (isString(data) ? data : formatter.Serialize(data)) ?? '', [data]);
const shift = useShiftModifier();
const clipboard = useClipboard();
@@ -44,8 +45,8 @@ const DataViewer = (props: Props) => {
const { t } = useTranslation();
return (
<Flex layerStyle="second" borderRadius="base" flexGrow={1} w="full" h="full" position="relative">
<Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="auto" p={4} fontSize="sm">
<Flex bg="base.800" borderRadius="base" flexGrow={1} w="full" h="full" position="relative" {...rest}>
<Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="auto" p={2} fontSize="sm">
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayscrollbarsOptions}>
<pre>{dataString}</pre>
</OverlayScrollbarsComponent>

View File

@@ -1,7 +1,7 @@
import { Flex, Image } from '@invoke-ai/ui-library';
import type { ComparisonProps } from 'features/gallery/components/ImageViewer/common';
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { VerticalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
import { memo, useCallback, useRef } from 'react';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
@@ -42,7 +42,7 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Comp
</Flex>
</Flex>
</Panel>
<ResizeHandle id="image-comparison-side-by-side-handle" onDoubleClick={onDoubleClickHandle} />
<VerticalResizeHandle id="image-comparison-side-by-side-handle" onDoubleClick={onDoubleClickHandle} />
<Panel minSize={20}>
<Flex position="relative" w="full" h="full" alignItems="center" justifyContent="center">

View File

@@ -3,3 +3,7 @@ import { createAction } from '@reduxjs/toolkit';
export const sentImageToCanvas = createAction('gallery/sentImageToCanvas');
export const imageDownloaded = createAction('gallery/imageDownloaded');
export const imageCopiedToClipboard = createAction('gallery/imageCopiedToClipboard');
export const imageOpenedInNewTab = createAction('gallery/imageOpenedInNewTab');

View File

@@ -44,6 +44,7 @@ import {
} from 'features/nodes/store/selectors';
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import type { CSSProperties, MouseEvent } from 'react';
@@ -356,6 +357,9 @@ export const Flow = memo(() => {
selectionMode={selectionMode}
elevateEdgesOnSelect
nodeDragThreshold={1}
noDragClassName={NO_DRAG_CLASS}
noWheelClassName={NO_WHEEL_CLASS}
noPanClassName={NO_PAN_CLASS}
>
<Background />
</ReactFlow>

View File

@@ -5,6 +5,7 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import { buildSelectAreConnectedNodesSelected } from 'features/nodes/components/flow/edges/util/buildEdgeSelectors';
import { selectShouldAnimateEdges } from 'features/nodes/store/workflowSettingsSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
import type { CollapsedInvocationNodeEdge } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
@@ -80,7 +81,7 @@ const InvocationCollapsedEdge = ({
<Box
position="absolute"
transform={`translate(-50%, -50%) translate(${labelX}px,${labelY}px)`}
className="edge-label-renderer__custom-edge nodrag nopan" // Unfortunately edge labels do not get the same zIndex treatment as edges do, so we need to manage this ourselves
className={`edge-label-renderer__custom-edge ${NO_DRAG_CLASS} ${NO_PAN_CLASS}`} // Unfortunately edge labels do not get the same zIndex treatment as edges do, so we need to manage this ourselves
// See: https://github.com/xyflow/xyflow/issues/3658
zIndex={1001}
>

View File

@@ -6,6 +6,7 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectShouldAnimateEdges, selectShouldShowEdgeLabels } from 'features/nodes/store/workflowSettingsSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
import type { DefaultInvocationNodeEdge } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
@@ -114,7 +115,7 @@ const InvocationDefaultEdge = ({
{label && shouldShowEdgeLabels && (
<EdgeLabelRenderer>
<Flex
className="nodrag nopan"
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS}`}
transform={`translate(-50%, -50%) translate(${labelX}px,${labelY}px)`}
data-selected={selected}
sx={edgeLabelWrapperSx}

View File

@@ -31,6 +31,9 @@ const sx: SystemStyleObject = {
'&[data-with-footer="true"]': {
borderBottomRadius: 0,
},
'&[data-with-footer="false"]': {
pb: 4,
},
};
const InvocationNode = ({ nodeId, isOpen }: Props) => {

View File

@@ -1,5 +1,6 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Flex, FormControlGroup } from '@invoke-ai/ui-library';
import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode';
import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -16,6 +17,7 @@ const props: ChakraProps = { w: 'unset' };
const InvocationNodeFooter = ({ nodeId }: Props) => {
const hasImageOutput = useNodeHasImageOutput(nodeId);
const isExecutableNode = useIsExecutableNode(nodeId);
const isCacheEnabled = useFeatureStatus('invocationCache');
return (
<Flex
@@ -30,8 +32,8 @@ const InvocationNodeFooter = ({ nodeId }: Props) => {
justifyContent="space-between"
>
<FormControlGroup formControlProps={props} formLabelProps={props}>
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
{isExecutableNode && isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
{isExecutableNode && hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
</FormControlGroup>
</Flex>
);

View File

@@ -3,6 +3,7 @@ import { Flex } from '@invoke-ai/ui-library';
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle';
import InvocationNodeClassificationIcon from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeClassificationIcon';
import { useNodeIsInvalid } from 'features/nodes/hooks/useNodeIsInvalid';
import { memo } from 'react';
import InvocationNodeCollapsedHandles from './InvocationNodeCollapsedHandles';
@@ -25,11 +26,16 @@ const sx: SystemStyleObject = {
'&[data-is-open="true"]': {
borderBottomRadius: 0,
},
'&[data-is-invalid="true"]': {
color: 'error.300',
},
};
const InvocationNodeHeader = ({ nodeId, isOpen }: Props) => {
const isInvalid = useNodeIsInvalid(nodeId);
return (
<Flex layerStyle="nodeHeader" sx={sx} data-is-open={isOpen}>
<Flex layerStyle="nodeHeader" sx={sx} data-is-open={isOpen} data-is-invalid={isInvalid}>
<NodeCollapseButton nodeId={nodeId} isOpen={isOpen} />
<InvocationNodeClassificationIcon nodeId={nodeId} />
<NodeTitle nodeId={nodeId} />

View File

@@ -23,7 +23,7 @@ export const InvocationNodeNotesTextarea = memo(({ nodeId }: Props) => {
return (
<FormControl orientation="vertical" h="full">
<FormLabel>{t('nodes.notes')}</FormLabel>
<Textarea value={notes} onChange={handleNotesChanged} rows={10} resize="none" />
<Textarea value={notes} onChange={handleNotesChanged} rows={10} resize="none" variant="darkFilled" />
</FormControl>
);
});

View File

@@ -3,6 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
import { useNodeIsIntermediate } from 'features/nodes/hooks/useNodeIsIntermediate';
import { nodeIsIntermediateChanged } from 'features/nodes/store/nodesSlice';
import { NO_PAN_CLASS } from 'features/nodes/types/constants';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -29,7 +30,7 @@ const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
}
return (
<FormControl className="nopan">
<FormControl className={NO_PAN_CLASS}>
<FormLabel m={0}>{t('nodes.saveToGallery')} </FormLabel>
<Checkbox onChange={handleChange} isChecked={!isIntermediate} />
</FormControl>

View File

@@ -2,6 +2,7 @@ import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useUseCache } from 'features/nodes/hooks/useUseCache';
import { nodeUseCacheChanged } from 'features/nodes/store/nodesSlice';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -22,9 +23,9 @@ const UseCacheCheckbox = ({ nodeId }: { nodeId: string }) => {
);
const { t } = useTranslation();
return (
<FormControl>
<FormControl className={NO_FIT_ON_DOUBLE_CLICK_CLASS}>
<FormLabel m={0}>{t('invocationCache.useCache')}</FormLabel>
<Checkbox className="nopan" onChange={handleChange} isChecked={useCache} />
<Checkbox className={NO_PAN_CLASS} onChange={handleChange} isChecked={useCache} />
</FormControl>
);
};

View File

@@ -1,6 +1,7 @@
import { CompositeNumberInput } from '@invoke-ai/ui-library';
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -16,7 +17,7 @@ export const FloatFieldInput = memo((props: FieldComponentProps<FloatFieldInputI
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
flex="1 1 0"
/>
);

View File

@@ -1,6 +1,7 @@
import { CompositeNumberInput, CompositeSlider } from '@invoke-ai/ui-library';
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -18,7 +19,7 @@ export const FloatFieldInputAndSlider = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
marks
withThumbTooltip
flex="1 1 0"
@@ -31,7 +32,7 @@ export const FloatFieldInputAndSlider = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
flex="1 1 0"
/>
</>

View File

@@ -1,6 +1,7 @@
import { CompositeSlider } from '@invoke-ai/ui-library';
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -16,7 +17,7 @@ export const FloatFieldSlider = memo((props: FieldComponentProps<FloatFieldInput
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
marks
withThumbTooltip
flex="1 1 0"

View File

@@ -0,0 +1,29 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAddNodeFieldToRoot } from 'features/nodes/components/sidePanel/builder/use-add-node-field-to-root';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
export const InputFieldAddToFormRoot = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const addToRoot = useAddNodeFieldToRoot(nodeId, fieldName);
return (
<IconButton
variant="ghost"
tooltip={t('workflows.builder.addToForm')}
aria-label={t('workflows.builder.addToForm')}
icon={<PiPlusBold />}
pointerEvents="auto"
size="xs"
onClick={addToRoot}
/>
);
});
InputFieldAddToFormRoot.displayName = 'InputFieldAddToFormRoot';

View File

@@ -10,6 +10,7 @@ import {
import { useAppDispatch } from 'app/store/storeHooks';
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -59,7 +60,7 @@ const Content = memo(({ nodeId, fieldName }: Props) => {
<FormControl orientation="vertical">
<FormLabel>{t('nodes.description')}</FormLabel>
<Textarea
className="nodrag nopan nowheel"
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS} ${NO_WHEEL_CLASS}`}
fontSize="sm"
value={description ?? ''}
onChange={onChange}

View File

@@ -1,14 +1,16 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, FormControl, Spacer } from '@invoke-ai/ui-library';
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { InputFieldAddToFormRoot } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldAddToFormRoot';
import { InputFieldDescriptionPopover } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover';
import { InputFieldHandle } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle';
import { InputFieldResetToDefaultValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldResetToDefaultValueIconButton';
import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd';
import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useRef, useState } from 'react';
import { memo, useRef } from 'react';
import { InputFieldRenderer } from './InputFieldRenderer';
import { InputFieldTitle } from './InputFieldTitle';
@@ -57,12 +59,12 @@ type CommonProps = {
fieldTemplate: FieldInputTemplate;
};
const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid, isConnected }: CommonProps) => {
const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid }: CommonProps) => {
return (
<InputFieldWrapper>
<FormControl isInvalid={isInvalid} isDisabled={isConnected} px={2}>
<Flex px={2}>
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} />
</FormControl>
</Flex>
<InputFieldHandle nodeId={nodeId} fieldName={fieldName} />
</InputFieldWrapper>
);
@@ -70,8 +72,10 @@ const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid, isConne
ConnectedOrConnectionField.displayName = 'ConnectedOrConnectionField';
const directFieldSx: SystemStyleObject = {
orientation: 'vertical',
w: 'full',
px: 2,
flexDir: 'column',
gap: 1,
'&[data-is-dragging="true"]': {
opacity: 0.3,
},
@@ -81,48 +85,39 @@ const directFieldSx: SystemStyleObject = {
'&[data-is-connected="true"]': {
pointerEvents: 'none',
},
// The action buttons are hidden by default and shown on hover
'& .direct-field-action-buttons': {
display: 'none',
},
_hover: {
'& .direct-field-action-buttons': {
display: 'inline-flex',
},
},
};
const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemplate }: CommonProps) => {
const draggableRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const onMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const onMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef);
return (
<InputFieldWrapper>
<FormControl
ref={draggableRef}
isInvalid={isInvalid}
isDisabled={isConnected}
sx={directFieldSx}
data-is-connected={isConnected}
data-is-dragging={isDragging}
>
<Flex flexDir="column" w="full" gap={1} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Flex className="nodrag" ref={dragHandleRef} gap={1}>
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} />
<Spacer />
{isHovered && (
<>
<InputFieldDescriptionPopover nodeId={nodeId} fieldName={fieldName} />
<InputFieldResetToDefaultValueIconButton nodeId={nodeId} fieldName={fieldName} />
</>
)}
<Flex ref={draggableRef} sx={directFieldSx} data-is-connected={isConnected} data-is-dragging={isDragging}>
<Flex gap={1}>
<Flex className={NO_DRAG_CLASS} ref={dragHandleRef}>
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} isDragging={isDragging} />
</Flex>
<Spacer />
<Flex className="direct-field-action-buttons">
<InputFieldDescriptionPopover nodeId={nodeId} fieldName={fieldName} />
<InputFieldResetToDefaultValueIconButton nodeId={nodeId} fieldName={fieldName} />
<InputFieldAddToFormRoot nodeId={nodeId} fieldName={fieldName} />
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
</FormControl>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
{fieldTemplate.input !== 'direct' && <InputFieldHandle nodeId={nodeId} fieldName={fieldName} />}
</InputFieldWrapper>
);

View File

@@ -9,8 +9,9 @@ import {
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY, MODEL_TYPES } from 'features/nodes/types/constants';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { isModelFieldType } from 'features/nodes/types/field';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -64,7 +65,7 @@ export const InputFieldHandle = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
const isModelField = useMemo(() => MODEL_TYPES.some((t) => t === fieldTemplate.type.name), [fieldTemplate.type]);
const isModelField = useMemo(() => isModelFieldType(fieldTemplate.type), [fieldTemplate.type]);
const isConnectionInProgress = useIsConnectionInProgress();
if (isConnectionInProgress) {

View File

@@ -4,6 +4,7 @@ import { FloatFieldSlider } from 'features/nodes/components/flow/nodes/Invocatio
import { FloatFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatFieldCollectionInputComponent';
import { FloatGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorFieldComponent';
import { ImageFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent';
import { ImageGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageGeneratorFieldComponent';
import { IntegerFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerFieldCollectionInputComponent';
import { IntegerGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerGeneratorFieldComponent';
import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent';
@@ -49,6 +50,8 @@ import {
isImageFieldCollectionInputTemplate,
isImageFieldInputInstance,
isImageFieldInputTemplate,
isImageGeneratorFieldInputInstance,
isImageGeneratorFieldInputTemplate,
isIntegerFieldCollectionInputInstance,
isIntegerFieldCollectionInputTemplate,
isIntegerFieldInputInstance,
@@ -392,6 +395,13 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
return <StringGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isImageGeneratorFieldInputTemplate(template)) {
if (!isImageGeneratorFieldInputInstance(field)) {
return null;
}
return <ImageGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
return null;
});

View File

@@ -12,7 +12,8 @@ import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsCo
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -36,10 +37,11 @@ interface Props {
nodeId: string;
fieldName: string;
isInvalid?: boolean;
isDragging?: boolean;
}
export const InputFieldTitle = memo((props: Props) => {
const { nodeId, fieldName, isInvalid } = props;
const { nodeId, fieldName, isInvalid, isDragging } = props;
const inputRef = useRef<HTMLInputElement>(null);
const label = useInputFieldLabel(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName);
@@ -64,21 +66,34 @@ export const InputFieldTitle = memo((props: Props) => {
inputRef,
});
const isDisabled = useMemo(
() => (isConnectionInProgress && connectionError !== null && !isConnectionStartField) || isConnected,
[isConnectionInProgress, connectionError, isConnectionStartField, isConnected]
);
const onDoubleClick = useCallback(
(e: MouseEvent<HTMLParagraphElement>) => {
e.stopPropagation();
editable.startEditing();
},
[editable]
);
if (!editable.isEditing) {
return (
<Tooltip
label={<InputFieldTooltipContent nodeId={nodeId} fieldName={fieldName} />}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
isDisabled={isDragging}
>
<Text
className={NO_FIT_ON_DOUBLE_CLICK_CLASS}
sx={labelSx}
noOfLines={1}
data-is-invalid={isInvalid}
data-is-disabled={
(isConnectionInProgress && connectionError !== null && !isConnectionStartField) || isConnected
}
onDoubleClick={editable.startEditing}
data-is-disabled={isDisabled}
onDoubleClick={onDoubleClick}
>
{editable.value}
</Text>
@@ -86,7 +101,14 @@ export const InputFieldTitle = memo((props: Props) => {
);
}
return <Input ref={inputRef} variant="outline" {...editable.inputProps} />;
return (
<Input
ref={inputRef}
variant="outline"
{...editable.inputProps}
_focusVisible={{ borderRadius: 'base', h: 'unset', px: 2 }}
/>
);
});
InputFieldTitle.displayName = 'InputFieldTitle';

View File

@@ -1,6 +1,7 @@
import { CompositeNumberInput } from '@invoke-ai/ui-library';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -17,7 +18,7 @@ export const IntegerFieldInput = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
flex="1 1 0"
/>
);

View File

@@ -1,6 +1,7 @@
import { CompositeNumberInput, CompositeSlider } from '@invoke-ai/ui-library';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -18,7 +19,7 @@ export const IntegerFieldInputAndSlider = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
marks
withThumbTooltip
flex="1 1 0"
@@ -31,7 +32,7 @@ export const IntegerFieldInputAndSlider = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
flex="1 1 0"
/>
</>

View File

@@ -1,6 +1,7 @@
import { CompositeSlider } from '@invoke-ai/ui-library';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -17,7 +18,7 @@ export const IntegerFieldSlider = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
marks
withThumbTooltip
flex="1 1 0"

View File

@@ -15,6 +15,10 @@ export const NodeFieldElementResetToInitialValueIconButton = memo(({ element }:
const { nodeId, fieldName } = data.fieldIdentifier;
const { isValueChanged, resetToInitialValue } = useInputFieldInitialFormValue(id, nodeId, fieldName);
if (!isValueChanged) {
return null;
}
return (
<IconButton
variant="link"

View File

@@ -9,8 +9,9 @@ import {
} from 'features/nodes/hooks/useFieldConnectionState';
import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY, MODEL_TYPES } from 'features/nodes/types/constants';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldOutputTemplate } from 'features/nodes/types/field';
import { isModelFieldType } from 'features/nodes/types/field';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -64,7 +65,7 @@ export const OutputFieldHandle = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
const isModelField = useMemo(() => MODEL_TYPES.some((t) => t === fieldTemplate.type.name), [fieldTemplate.type]);
const isModelField = useMemo(() => isModelFieldType(fieldTemplate.type), [fieldTemplate.type]);
const isConnectionInProgress = useIsConnectionInProgress();
if (isConnectionInProgress) {

View File

@@ -1,6 +1,7 @@
import { Input } from '@invoke-ai/ui-library';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { useStringField } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/useStringField';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { StringFieldInputInstance, StringFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -8,7 +9,7 @@ export const StringFieldInput = memo(
(props: FieldComponentProps<StringFieldInputInstance, StringFieldInputTemplate>) => {
const { value, onChange } = useStringField(props);
return <Input className="nodrag nowheel nopan" value={value} onChange={onChange} />;
return <Input className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS} ${NO_WHEEL_CLASS}`} value={value} onChange={onChange} />;
}
);

View File

@@ -1,6 +1,7 @@
import { Textarea } from '@invoke-ai/ui-library';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { useStringField } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/useStringField';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { StringFieldInputInstance, StringFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
@@ -10,11 +11,10 @@ export const StringFieldTextarea = memo(
return (
<Textarea
className="nodrag nowheel nopan"
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS} ${NO_WHEEL_CLASS}`}
value={value}
onChange={onChange}
h="full"
resize="none"
fontSize="sm"
p={2}
/>

View File

@@ -2,6 +2,7 @@ import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { fieldBoardValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { BoardFieldInputInstance, BoardFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,62 +10,110 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import type { FieldComponentProps } from './types';
/**
* The board field values in the UI do not map 1-to-1 to the values the graph expects.
*
* The graph value is either an object in the shape of `{board_id: string}` or undefined.
*
* But in the UI, we have the following options:
* - auto: Use the "auto add" board. During graph building, we pull the auto add board ID from the state and use it.
* - none: Do not assign a board. In the graph, this is represented as undefined.
* - board_id: Assign the specified board. In the graph, this is represented as `{board_id: string}`.
*
* It's also possible that the UI value is undefined, which may be the case for some older workflows. In this case, we
* map it to the "auto" option.
*
* So there is some translation that needs to happen in both directions - when the user selects a board in the UI, and
* when we build the graph. The former is handled in this component, the latter in the `buildNodesGraph` function.
*/
const listAllBoardsQueryArg = { include_archived: true };
const getBoardValue = (val: string) => {
if (val === 'auto' || val === 'none') {
return val;
}
return {
board_id: val,
};
};
const BoardFieldInputComponent = (props: FieldComponentProps<BoardFieldInputInstance, BoardFieldInputTemplate>) => {
const { nodeId, field } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { options, hasBoards } = useListAllBoardsQuery(
{ include_archived: true },
{
selectFromResult: ({ data }) => {
const options: ComboboxOption[] = [
{
label: 'None',
value: 'none',
},
].concat(
(data ?? []).map(({ board_id, board_name }) => ({
label: board_name,
value: board_id,
}))
);
return {
options,
hasBoards: options.length > 1,
};
},
const listAllBoardsQuery = useListAllBoardsQuery(listAllBoardsQueryArg);
const autoOption = useMemo<ComboboxOption>(() => {
return {
label: t('common.auto'),
value: 'auto',
};
}, [t]);
const noneOption = useMemo<ComboboxOption>(() => {
return {
label: `${t('common.none')} (${t('boards.uncategorized')})`,
value: 'none',
};
}, [t]);
const options = useMemo<ComboboxOption[]>(() => {
const _options: ComboboxOption[] = [autoOption, noneOption];
if (listAllBoardsQuery.data) {
for (const board of listAllBoardsQuery.data) {
_options.push({
label: board.board_name,
value: board.board_id,
});
}
}
);
return _options;
}, [autoOption, listAllBoardsQuery.data, noneOption]);
const onChange = useCallback<ComboboxOnChange>(
(v) => {
if (!v) {
// This should never happen
return;
}
const value = getBoardValue(v.value);
dispatch(
fieldBoardValueChanged({
nodeId,
fieldName: field.name,
value: v.value !== 'none' ? { board_id: v.value } : undefined,
value,
})
);
},
[dispatch, field.name, nodeId]
);
const value = useMemo(() => options.find((o) => o.value === field.value?.board_id), [options, field.value]);
const value = useMemo(() => {
const _value = field.value;
if (!_value || _value === 'auto') {
return autoOption;
}
if (_value === 'none') {
return noneOption;
}
const boardOption = options.find((o) => o.value === _value.board_id);
return boardOption ?? autoOption;
}, [field.value, options, autoOption, noneOption]);
const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
return (
<Combobox
className="nowheel nodrag"
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
value={value}
options={options}
onChange={onChange}
placeholder={t('boards.selectBoard')}
noOptionsMessage={noOptionsMessage}
isDisabled={!hasBoards}
/>
);
};

View File

@@ -1,6 +1,7 @@
import { Switch } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { fieldBooleanValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import type { BooleanFieldInputInstance, BooleanFieldInputTemplate } from 'features/nodes/types/field';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
@@ -27,7 +28,13 @@ const BooleanFieldInputComponent = (
[dispatch, field.name, nodeId]
);
return <Switch className="nodrag" onChange={handleValueChanged} isChecked={field.value} />;
return (
<Switch
className={`${NO_DRAG_CLASS} ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}
onChange={handleValueChanged}
isChecked={field.value}
/>
);
};
export default memo(BooleanFieldInputComponent);

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldCLIPEmbedValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { CLIPEmbedModelFieldInputInstance, CLIPEmbedModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -44,7 +45,11 @@ const CLIPEmbedModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<Tooltip label={!disabledTabs.includes('models') && t('modelManager.starterModelsInModelManager')}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value && required}>
<FormControl
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
isDisabled={!options.length}
isInvalid={!value && required}
>
<Combobox
value={value}
placeholder={required ? placeholder : `(Optional) ${placeholder}`}

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldCLIPGEmbedValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { CLIPGEmbedModelFieldInputInstance, CLIPGEmbedModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -45,7 +46,11 @@ const CLIPGEmbedModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<Tooltip label={!disabledTabs.includes('models') && t('modelManager.starterModelsInModelManager')}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value && required}>
<FormControl
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
isDisabled={!options.length}
isInvalid={!value && required}
>
<Combobox
value={value}
placeholder={required ? placeholder : `(Optional) ${placeholder}`}

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldCLIPLEmbedValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { CLIPLEmbedModelFieldInputInstance, CLIPLEmbedModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -45,7 +46,11 @@ const CLIPLEmbedModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<Tooltip label={!disabledTabs.includes('models') && t('modelManager.starterModelsInModelManager')}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value && required}>
<FormControl
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
isDisabled={!options.length}
isInvalid={!value && required}
>
<Combobox
value={value}
placeholder={required ? placeholder : `(Optional) ${placeholder}`}

View File

@@ -3,6 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { hexToRGBA, rgbaToHex } from 'common/util/colorCodeTransformers';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { fieldColorValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { ColorFieldInputInstance, ColorFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useMemo } from 'react';
import type { RgbaColor } from 'react-colorful';
@@ -60,13 +61,18 @@ const ColorFieldInputComponent = (props: FieldComponentProps<ColorFieldInputInst
paddingBlock: 4,
outline: 'none',
}}
className="nodrag"
className={NO_DRAG_CLASS}
color={rgbaToHex(color, true)}
onChange={handleValueChanged}
prefixed
alpha
/>
<RgbaColorPicker className="nodrag" color={color} onChange={handleValueChanged} style={{ width: '100%' }} />
<RgbaColorPicker
className={NO_DRAG_CLASS}
color={color}
onChange={handleValueChanged}
style={{ width: '100%' }}
/>
</Flex>
);
};

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldControlLoRAModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type {
ControlLoRAModelFieldInputInstance,
ControlLoRAModelFieldInputTemplate,
@@ -48,7 +49,11 @@ const ControlLoRAModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<Tooltip label={!disabledTabs.includes('models') && t('modelManager.starterModelsInModelManager')}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value && required}>
<FormControl
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
isDisabled={!options.length}
isInvalid={!value && required}
>
<Combobox
value={value}
placeholder={required ? placeholder : `(Optional) ${placeholder}`}

View File

@@ -2,6 +2,7 @@ import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldControlNetModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ControlNetModelFieldInputInstance, ControlNetModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useControlNetModels } from 'services/api/hooks/modelsByType';
@@ -41,7 +42,7 @@ const ControlNetModelFieldInputComponent = (props: Props) => {
return (
<Tooltip label={value?.description}>
<FormControl className="nowheel nodrag" isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isInvalid={!value}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -1,6 +1,7 @@
import { Select } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { fieldEnumModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { EnumFieldInputInstance, EnumFieldInputTemplate } from 'features/nodes/types/field';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
@@ -26,7 +27,12 @@ const EnumFieldInputComponent = (props: FieldComponentProps<EnumFieldInputInstan
);
return (
<Select className="nowheel nodrag" onChange={handleValueChanged} value={field.value} size="sm">
<Select
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
onChange={handleValueChanged}
value={field.value}
size="sm"
>
{fieldTemplate.options.map((option) => (
<option key={option} value={option}>
{fieldTemplate.ui_choice_labels ? fieldTemplate.ui_choice_labels[option] : option}

View File

@@ -14,6 +14,7 @@ import { useAppStore } from 'app/store/nanostores/store';
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { fieldFloatCollectionValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { FloatFieldCollectionInputInstance, FloatFieldCollectionInputTemplate } from 'features/nodes/types/field';
import { isNil } from 'lodash-es';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
@@ -108,7 +109,7 @@ export const FloatFieldCollectionInputComponent = memo(
return (
<Flex
className="nodrag"
className={NO_DRAG_CLASS}
position="relative"
w="full"
h="auto"
@@ -129,7 +130,7 @@ export const FloatFieldCollectionInputComponent = memo(
<>
<Divider />
<OverlayScrollbarsComponent
className="nowheel"
className={NO_WHEEL_CLASS}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
@@ -199,7 +200,7 @@ const FloatListItemContent = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
flexGrow={1}
/>
</GridItem>

View File

@@ -7,6 +7,7 @@ import { FloatGeneratorParseStringSettings } from 'features/nodes/components/flo
import { FloatGeneratorUniformRandomDistributionSettings } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorUniformRandomDistributionSettings';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { fieldFloatGeneratorValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { FloatGeneratorFieldInputInstance, FloatGeneratorFieldInputTemplate } from 'features/nodes/types/field';
import {
FloatGeneratorArithmeticSequenceType,
@@ -80,7 +81,12 @@ export const FloatGeneratorFieldInputComponent = memo(
return (
<Flex flexDir="column" gap={2}>
<Select className="nowheel nodrag" onChange={onChangeGeneratorType} value={field.value.type} size="sm">
<Select
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
onChange={onChangeGeneratorType}
value={field.value.type}
size="sm"
>
<option value={FloatGeneratorArithmeticSequenceType}>{t('nodes.arithmeticSequence')}</option>
<option value={FloatGeneratorLinearDistributionType}>{t('nodes.linearDistribution')}</option>
<option value={FloatGeneratorUniformRandomDistributionType}>{t('nodes.uniformRandomDistribution')}</option>
@@ -101,12 +107,17 @@ export const FloatGeneratorFieldInputComponent = memo(
<Flex w="full" h="full" p={2} borderWidth={1} borderRadius="base" maxH={128}>
<Flex w="full" h="auto">
<OverlayScrollbarsComponent
className="nodrag nowheel"
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
>
<Text className="nodrag nowheel" fontFamily="monospace" userSelect="text" cursor="text">
<Text
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
fontFamily="monospace"
userSelect="text"
cursor="text"
>
{resolvedValuesAsString}
</Text>
</OverlayScrollbarsComponent>

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldMainModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { FluxMainModelFieldInputInstance, FluxMainModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useFluxModels } from 'services/api/hooks/modelsByType';
@@ -39,7 +40,7 @@ const FluxMainModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isDisabled={!options.length} isInvalid={!value}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldFluxVAEModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { FluxVAEModelFieldInputInstance, FluxVAEModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -43,7 +44,7 @@ const FluxVAEModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<Tooltip label={!disabledTabs.includes('models') && t('modelManager.starterModelsInModelManager')}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isDisabled={!options.length} isInvalid={!value}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -1,4 +1,5 @@
import { FormControl, FormLabel, IconButton, Spacer, Textarea } from '@invoke-ai/ui-library';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import { toast } from 'features/toast/toast';
import { isString } from 'lodash-es';
import type { ChangeEvent } from 'react';
@@ -71,7 +72,7 @@ export const GeneratorTextareaWithFileUpload = memo(({ value, onChange }: Props)
<input {...getInputProps()} />
</FormLabel>
<Textarea
className="nowheel nodrag nopan"
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS} ${NO_WHEEL_CLASS}`}
value={value}
onChange={onChangeInput}
p={2}

View File

@@ -2,6 +2,7 @@ import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldIPAdapterModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { IPAdapterModelFieldInputInstance, IPAdapterModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useIPAdapterModels } from 'services/api/hooks/modelsByType';
@@ -41,7 +42,7 @@ const IPAdapterModelFieldInputComponent = (
return (
<Tooltip label={value?.description}>
<FormControl className="nowheel nodrag" isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isInvalid={!value}>
<Combobox value={value} placeholder="Pick one" options={options} onChange={onChange} />
</FormControl>
</Tooltip>

View File

@@ -13,6 +13,7 @@ import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice';
import type { ImageField } from 'features/nodes/types/common';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ImageFieldCollectionInputInstance, ImageFieldCollectionInputTemplate } from 'features/nodes/types/field';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useCallback, useMemo } from 'react';
@@ -71,7 +72,7 @@ export const ImageFieldCollectionInputComponent = memo(
return (
<Flex
className="nodrag"
className={NO_DRAG_CLASS}
position="relative"
w="full"
h="full"
@@ -93,14 +94,14 @@ export const ImageFieldCollectionInputComponent = memo(
{field.value && field.value.length > 0 && (
<Box w="full" h="auto" p={1} sx={sx} data-error={isInvalid} borderRadius="base">
<OverlayScrollbarsComponent
className="nowheel"
className={NO_WHEEL_CLASS}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
>
<Grid w="full" h="full" templateColumns="repeat(4, 1fr)" gap={1}>
{field.value.map((value, index) => (
<GridItem key={index} position="relative" className="nodrag">
<GridItem key={index} position="relative" className={NO_DRAG_CLASS}>
<ImageGridItemContent value={value} index={index} onRemoveImage={onRemoveImage} />
</GridItem>
))}

View File

@@ -7,12 +7,11 @@ import type { SetNodeImageFieldImageDndTargetData } from 'features/dnd/dnd';
import { setNodeImageFieldImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { DndImage } from 'features/dnd/DndImage';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { $isConnected } from 'services/events/stores';
@@ -24,6 +23,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
const { nodeId, field, fieldTemplate } = props;
const dispatch = useAppDispatch();
const isConnected = useStore($isConnected);
const { currentData: imageDTO, isError } = useGetImageDTOQuery(field.value?.image_name ?? skipToken);
const handleReset = useCallback(() => {
dispatch(
@@ -64,15 +64,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
);
return (
<Flex
position="relative"
className="nodrag"
w="full"
h="full"
minH={16}
alignItems="stretch"
justifyContent="center"
>
<Flex position="relative" className={NO_DRAG_CLASS} w="full" h={32} alignItems="stretch">
{!imageDTO && (
<UploadImageButton
w="full"
@@ -84,17 +76,22 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
)}
{imageDTO && (
<>
<DndImage imageDTO={imageDTO} minW={8} minH={8} />
<DndImageIcon
onClick={handleReset}
icon={imageDTO ? <PiArrowCounterClockwiseBold /> : undefined}
tooltip="Reset Image"
<Flex borderRadius="base" borderWidth={1} borderStyle="solid">
<DndImage imageDTO={imageDTO} asThumbnail />
</Flex>
<Text
position="absolute"
flexDir="column"
top={1}
background="base.900"
color="base.50"
fontSize="sm"
fontWeight="semibold"
insetInlineEnd={1}
gap={1}
/>
insetBlockEnd={1}
opacity={0.7}
px={2}
borderRadius="base"
pointerEvents="none"
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
</>
)}
<DndDropTarget

View File

@@ -0,0 +1,109 @@
import { Flex, Select, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
import { ImageGeneratorImagesFromBoardSettings } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageGeneratorImagesFromBoardSettings';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { fieldImageGeneratorValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ImageGeneratorFieldInputInstance, ImageGeneratorFieldInputTemplate } from 'features/nodes/types/field';
import {
getImageGeneratorDefaults,
ImageGeneratorImagesFromBoardType,
resolveImageGeneratorField,
} from 'features/nodes/types/field';
import { debounce } from 'lodash-es';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
const overlayscrollbarsOptions = getOverlayScrollbarsParams().options;
export const ImageGeneratorFieldInputComponent = memo(
(props: FieldComponentProps<ImageGeneratorFieldInputInstance, ImageGeneratorFieldInputTemplate>) => {
const { nodeId, field } = props;
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onChange = useCallback(
(value: ImageGeneratorFieldInputInstance['value']) => {
dispatch(
fieldImageGeneratorValueChanged({
nodeId,
fieldName: field.name,
value,
})
);
},
[dispatch, field.name, nodeId]
);
const onChangeGeneratorType = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const value = getImageGeneratorDefaults(e.target.value as ImageGeneratorFieldInputInstance['value']['type']);
dispatch(
fieldImageGeneratorValueChanged({
nodeId,
fieldName: field.name,
value,
})
);
},
[dispatch, field.name, nodeId]
);
const [resolvedValuesAsString, setResolvedValuesAsString] = useState<string | null>(null);
const resolveAndSetValuesAsString = useMemo(
() =>
debounce(async (field: ImageGeneratorFieldInputInstance) => {
const resolvedValues = await resolveImageGeneratorField(field, dispatch);
if (resolvedValues.length === 0) {
setResolvedValuesAsString(`<${t('nodes.generatorNoValues')}>`);
} else {
setResolvedValuesAsString(`<${t('nodes.generatorImages', { count: resolvedValues.length })}>`);
}
}, 300),
[dispatch, t]
);
useEffect(() => {
resolveAndSetValuesAsString(field);
}, [field, resolveAndSetValuesAsString]);
return (
<Flex flexDir="column" gap={2}>
<Select
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
onChange={onChangeGeneratorType}
value={field.value.type}
size="sm"
>
<option value={ImageGeneratorImagesFromBoardType}>{t('nodes.generatorImagesFromBoard')}</option>
</Select>
{field.value.type === ImageGeneratorImagesFromBoardType && (
<ImageGeneratorImagesFromBoardSettings state={field.value} onChange={onChange} />
)}
<Flex w="full" h="full" p={2} borderWidth={1} borderRadius="base" maxH={128}>
<Flex w="full" h="auto">
<OverlayScrollbarsComponent
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
>
<Text
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
fontFamily="monospace"
userSelect="text"
cursor="text"
>
{resolvedValuesAsString}
</Text>
</OverlayScrollbarsComponent>
</Flex>
</Flex>
</Flex>
);
}
);
ImageGeneratorFieldInputComponent.displayName = 'ImageGeneratorFieldInputComponent';

View File

@@ -0,0 +1,155 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ImageGeneratorImagesFromBoard } from 'features/nodes/types/field';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
type ImageGeneratorImagesFromBoardSettingsProps = {
state: ImageGeneratorImagesFromBoard;
onChange: (state: ImageGeneratorImagesFromBoard) => void;
};
export const ImageGeneratorImagesFromBoardSettings = memo(
({ state, onChange }: ImageGeneratorImagesFromBoardSettingsProps) => {
const { t } = useTranslation();
const onChangeCategory = useCallback(
(category: 'images' | 'assets') => {
onChange({ ...state, category });
},
[onChange, state]
);
const onChangeBoardId = useCallback(
(board_id: string) => {
onChange({ ...state, board_id });
},
[onChange, state]
);
return (
<Flex gap={2} flexDir="column">
<FormControl orientation="vertical">
<FormLabel>{t('common.board')}</FormLabel>
<BoardCombobox board_id={state.board_id} onChange={onChangeBoardId} />
</FormControl>
<FormControl orientation="vertical">
<FormLabel>{t('nodes.generatorImagesCategory')}</FormLabel>
<CategoryCombobox category={state.category} onChange={onChangeCategory} />
</FormControl>
</Flex>
);
}
);
ImageGeneratorImagesFromBoardSettings.displayName = 'ImageGeneratorImagesFromBoardSettings';
const listAllBoardsQueryArg = { include_archived: false };
const BoardCombobox = ({
board_id,
onChange: _onChange,
}: {
board_id: string;
onChange: (board_id: string) => void;
}) => {
const { t } = useTranslation();
const listAllBoardsQuery = useListAllBoardsQuery(listAllBoardsQueryArg);
const noneOption = useMemo<ComboboxOption>(() => {
return {
label: `${t('common.none')} (${t('boards.uncategorized')})`,
value: 'none',
};
}, [t]);
const options = useMemo<ComboboxOption[]>(() => {
const _options: ComboboxOption[] = [noneOption];
if (listAllBoardsQuery.data) {
for (const board of listAllBoardsQuery.data) {
_options.push({
label: board.board_name,
value: board.board_id,
});
}
}
return _options;
}, [listAllBoardsQuery.data, noneOption]);
const onChange = useCallback<ComboboxOnChange>(
(v) => {
if (!v) {
// This should never happen
return;
}
_onChange(v.value);
},
[_onChange]
);
const value = useMemo(() => {
if (board_id === 'none') {
return noneOption;
}
const boardOption = options.find((o) => o.value === board_id);
return boardOption ?? noneOption;
}, [board_id, options, noneOption]);
const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
return (
<Combobox
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
value={value}
options={options}
onChange={onChange}
placeholder={t('boards.selectBoard')}
noOptionsMessage={noOptionsMessage}
/>
);
};
const CategoryCombobox = ({
category,
onChange: _onChange,
}: {
category: 'images' | 'assets';
onChange: (category: 'images' | 'assets') => void;
}) => {
const { t } = useTranslation();
const options = useMemo<ComboboxOption[]>(
() => [
{ label: t('gallery.images'), value: 'images' },
{ label: t('gallery.assets'), value: 'assets' },
],
[t]
);
const onChange = useCallback<ComboboxOnChange>(
(v) => {
if (!v || (v.value !== 'images' && v.value !== 'assets')) {
// This should never happen
return;
}
_onChange(v.value);
},
[_onChange]
);
const value = useMemo(() => options.find((o) => o.value === category), [options, category]);
const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
return (
<Combobox
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
value={value}
options={options}
onChange={onChange}
placeholder={t('boards.selectBoard')}
noOptionsMessage={noOptionsMessage}
/>
);
};

View File

@@ -14,6 +14,7 @@ import { useAppStore } from 'app/store/nanostores/store';
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { fieldIntegerCollectionValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type {
IntegerFieldCollectionInputInstance,
IntegerFieldCollectionInputTemplate,
@@ -112,7 +113,7 @@ export const IntegerFieldCollectionInputComponent = memo(
return (
<Flex
className="nodrag"
className={NO_DRAG_CLASS}
position="relative"
w="full"
h="auto"
@@ -133,7 +134,7 @@ export const IntegerFieldCollectionInputComponent = memo(
<>
<Divider />
<OverlayScrollbarsComponent
className="nowheel"
className={NO_WHEEL_CLASS}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
@@ -203,7 +204,7 @@ const IntegerListItemContent = memo(
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
className={NO_DRAG_CLASS}
flexGrow={1}
/>
</GridItem>

View File

@@ -7,6 +7,7 @@ import { IntegerGeneratorParseStringSettings } from 'features/nodes/components/f
import { IntegerGeneratorUniformRandomDistributionSettings } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerGeneratorUniformRandomDistributionSettings';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { fieldIntegerGeneratorValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type {
IntegerGeneratorFieldInputInstance,
IntegerGeneratorFieldInputTemplate,
@@ -82,7 +83,12 @@ export const IntegerGeneratorFieldInputComponent = memo(
return (
<Flex flexDir="column" gap={2}>
<Select className="nowheel nodrag" onChange={onChangeGeneratorType} value={field.value.type} size="sm">
<Select
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
onChange={onChangeGeneratorType}
value={field.value.type}
size="sm"
>
<option value={IntegerGeneratorArithmeticSequenceType}>{t('nodes.arithmeticSequence')}</option>
<option value={IntegerGeneratorLinearDistributionType}>{t('nodes.linearDistribution')}</option>
<option value={IntegerGeneratorUniformRandomDistributionType}>{t('nodes.uniformRandomDistribution')}</option>
@@ -103,12 +109,17 @@ export const IntegerGeneratorFieldInputComponent = memo(
<Flex w="full" h="full" p={2} borderWidth={1} borderRadius="base" maxH={128}>
<Flex w="full" h="auto">
<OverlayScrollbarsComponent
className="nodrag nowheel"
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
>
<Text className="nodrag nowheel" fontFamily="monospace" userSelect="text" cursor="text">
<Text
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
fontFamily="monospace"
userSelect="text"
cursor="text"
>
{resolvedValuesAsString}
</Text>
</OverlayScrollbarsComponent>

View File

@@ -2,6 +2,7 @@ import { Combobox, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldLoRAModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { LoRAModelFieldInputInstance, LoRAModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useLoRAModels } from 'services/api/hooks/modelsByType';
@@ -39,7 +40,7 @@ const LoRAModelFieldInputComponent = (props: Props) => {
});
return (
<FormControl className="nowheel nodrag" isInvalid={!value} isDisabled={!options.length}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isInvalid={!value} isDisabled={!options.length}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldMainModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { MainModelFieldInputInstance, MainModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useNonSDXLMainModels } from 'services/api/hooks/modelsByType';
@@ -39,7 +40,7 @@ const MainModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isDisabled={!options.length} isInvalid={!value}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -3,6 +3,7 @@ import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldModelIdentifierValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ModelIdentifierFieldInputInstance, ModelIdentifierFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useMemo } from 'react';
import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
@@ -50,7 +51,7 @@ const ModelIdentifierFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isDisabled={!options.length} isInvalid={!value}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldRefinerModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type {
SDXLRefinerModelFieldInputInstance,
SDXLRefinerModelFieldInputTemplate,
@@ -42,7 +43,7 @@ const RefinerModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isDisabled={!options.length} isInvalid={!value}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldMainModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { SD3MainModelFieldInputInstance, SD3MainModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useSD3Models } from 'services/api/hooks/modelsByType';
@@ -40,7 +41,7 @@ const SD3MainModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<FormControl
className="nowheel nodrag"
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
isDisabled={!options.length}
isInvalid={!value && props.fieldTemplate.required}
>

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldMainModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { SDXLMainModelFieldInputInstance, SDXLMainModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useSDXLModels } from 'services/api/hooks/modelsByType';
@@ -39,7 +40,7 @@ const SDXLMainModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isDisabled={!options.length} isInvalid={!value}>
<Combobox
value={value}
placeholder={placeholder}

View File

@@ -2,6 +2,7 @@ import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { fieldSchedulerValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { SchedulerFieldInputInstance, SchedulerFieldInputTemplate } from 'features/nodes/types/field';
import { SCHEDULER_OPTIONS } from 'features/parameters/types/constants';
import { isParameterScheduler } from 'features/parameters/types/parameterSchemas';
@@ -34,7 +35,7 @@ const SchedulerFieldInputComponent = (props: Props) => {
const value = useMemo(() => SCHEDULER_OPTIONS.find((o) => o.value === field?.value), [field?.value]);
return (
<FormControl className="nowheel nodrag">
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS} ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}>
<Combobox value={value} options={SCHEDULER_OPTIONS} onChange={onChange} />
</FormControl>
);

View File

@@ -2,6 +2,7 @@ import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldSpandrelImageToImageModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type {
SpandrelImageToImageModelFieldInputInstance,
SpandrelImageToImageModelFieldInputTemplate,
@@ -45,7 +46,7 @@ const SpandrelImageToImageModelFieldInputComponent = (
return (
<Tooltip label={value?.description}>
<FormControl className="nowheel nodrag" isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isInvalid={!value}>
<Combobox value={value} placeholder="Pick one" options={options} onChange={onChange} />
</FormControl>
</Tooltip>

View File

@@ -4,6 +4,7 @@ import { useAppStore } from 'app/store/nanostores/store';
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { fieldStringCollectionValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type {
StringFieldCollectionInputInstance,
StringFieldCollectionInputTemplate,
@@ -59,7 +60,7 @@ export const StringFieldCollectionInputComponent = memo(
return (
<Flex
className="nodrag"
className={NO_DRAG_CLASS}
position="relative"
w="full"
h="auto"
@@ -80,7 +81,7 @@ export const StringFieldCollectionInputComponent = memo(
<>
<Divider />
<OverlayScrollbarsComponent
className="nowheel"
className={NO_WHEEL_CLASS}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}

View File

@@ -1,10 +1,8 @@
import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { GeneratorTextareaWithFileUpload } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/GeneratorTextareaWithFileUpload';
import type { StringGeneratorDynamicPromptsCombinatorial } from 'features/nodes/types/field';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDynamicPromptsQuery } from 'services/api/endpoints/utilities';
import { useDebounce } from 'use-debounce';
type StringGeneratorDynamicPromptsCombinatorialSettingsProps = {
state: StringGeneratorDynamicPromptsCombinatorial;
@@ -13,41 +11,20 @@ type StringGeneratorDynamicPromptsCombinatorialSettingsProps = {
export const StringGeneratorDynamicPromptsCombinatorialSettings = memo(
({ state, onChange }: StringGeneratorDynamicPromptsCombinatorialSettingsProps) => {
const { t } = useTranslation();
const loadingValues = useMemo(() => [`<${t('nodes.generatorLoading')}>`], [t]);
const onChangeInput = useCallback(
(input: string) => {
onChange({ ...state, input, values: loadingValues });
onChange({ ...state, input });
},
[onChange, state, loadingValues]
[onChange, state]
);
const onChangeMaxPrompts = useCallback(
(v: number) => {
onChange({ ...state, maxPrompts: v, values: loadingValues });
onChange({ ...state, maxPrompts: v });
},
[onChange, state, loadingValues]
[onChange, state]
);
const arg = useMemo(() => {
return { prompt: state.input, max_prompts: state.maxPrompts, combinatorial: true };
}, [state]);
const [debouncedArg] = useDebounce(arg, 300);
const { data, isLoading } = useDynamicPromptsQuery(debouncedArg);
useEffect(() => {
if (isLoading) {
return;
}
if (!data) {
onChange({ ...state, values: [] });
return;
}
onChange({ ...state, values: data.prompts });
}, [data, isLoading, onChange, state]);
return (
<Flex gap={2} flexDir="column">
<FormControl orientation="vertical">

View File

@@ -1,12 +1,9 @@
import { Checkbox, CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { GeneratorTextareaWithFileUpload } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/GeneratorTextareaWithFileUpload';
import { useViewContext } from 'features/nodes/contexts/ViewContext';
import type { StringGeneratorDynamicPromptsRandom } from 'features/nodes/types/field';
import { isNil, random } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { isNil } from 'lodash-es';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDynamicPromptsQuery } from 'services/api/endpoints/utilities';
import { useDebounce } from 'use-debounce';
type StringGeneratorDynamicPromptsRandomSettingsProps = {
state: StringGeneratorDynamicPromptsRandom;
@@ -15,52 +12,29 @@ type StringGeneratorDynamicPromptsRandomSettingsProps = {
export const StringGeneratorDynamicPromptsRandomSettings = memo(
({ state, onChange }: StringGeneratorDynamicPromptsRandomSettingsProps) => {
const { t } = useTranslation();
const view = useViewContext();
const loadingValues = useMemo(() => [`<${t('nodes.generatorLoading')}>`], [t]);
const onChangeInput = useCallback(
(input: string) => {
onChange({ ...state, input, values: loadingValues });
onChange({ ...state, input });
},
[onChange, state, loadingValues]
[onChange, state]
);
const onChangeCount = useCallback(
(v: number) => {
onChange({ ...state, count: v, values: loadingValues });
onChange({ ...state, count: v });
},
[onChange, state, loadingValues]
[onChange, state]
);
const onToggleSeed = useCallback(() => {
onChange({ ...state, seed: isNil(state.seed) ? 0 : null, values: loadingValues });
}, [onChange, state, loadingValues]);
onChange({ ...state, seed: isNil(state.seed) ? 0 : null });
}, [onChange, state]);
const onChangeSeed = useCallback(
(seed?: number | null) => {
onChange({ ...state, seed, values: loadingValues });
onChange({ ...state, seed });
},
[onChange, state, loadingValues]
[onChange, state]
);
const arg = useMemo(() => {
return { prompt: state.input, max_prompts: state.count, combinatorial: false, seed: state.seed ?? random() };
}, [state.count, state.input, state.seed]);
const [debouncedArg] = useDebounce(arg, 300);
const { data, isLoading } = useDynamicPromptsQuery(debouncedArg);
useEffect(() => {
if (isLoading) {
return;
}
if (!data) {
onChange({ ...state, values: [] });
return;
}
onChange({ ...state, values: data.prompts });
}, [data, isLoading, onChange, state, view]);
return (
<Flex gap={2} flexDir="column">
<Flex gap={2}>

View File

@@ -6,6 +6,7 @@ import { StringGeneratorDynamicPromptsRandomSettings } from 'features/nodes/comp
import { StringGeneratorParseStringSettings } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringGeneratorParseStringSettings';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { fieldStringGeneratorValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { StringGeneratorFieldInputInstance, StringGeneratorFieldInputTemplate } from 'features/nodes/types/field';
import {
getStringGeneratorDefaults,
@@ -14,12 +15,11 @@ import {
StringGeneratorDynamicPromptsRandomType,
StringGeneratorParseStringType,
} from 'features/nodes/types/field';
import { isNil } from 'lodash-es';
import { debounce } from 'lodash-es';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebounce } from 'use-debounce';
const overlayscrollbarsOptions = getOverlayScrollbarsParams().options;
@@ -56,29 +56,36 @@ export const StringGeneratorFieldInputComponent = memo(
[dispatch, field.name, nodeId]
);
const [debouncedField] = useDebounce(field, 300);
const resolvedValuesAsString = useMemo(() => {
if (debouncedField.value.type === StringGeneratorDynamicPromptsRandomType && isNil(debouncedField.value.seed)) {
const { count } = debouncedField.value;
return `<${t('nodes.generatorNRandomValues', { count })}>`;
}
const resolvedValues = resolveStringGeneratorField(debouncedField);
if (resolvedValues.length === 0) {
return `<${t('nodes.generatorNoValues')}>`;
} else {
return resolvedValues.join(', ');
}
}, [debouncedField, t]);
const [resolvedValuesAsString, setResolvedValuesAsString] = useState<string | null>(null);
const resolveAndSetValuesAsString = useMemo(
() =>
debounce(async (field: StringGeneratorFieldInputInstance) => {
const resolvedValues = await resolveStringGeneratorField(field, dispatch);
if (resolvedValues.length === 0) {
setResolvedValuesAsString(`<${t('nodes.generatorNoValues')}>`);
} else {
setResolvedValuesAsString(resolvedValues.join(', '));
}
}, 300),
[dispatch, t]
);
useEffect(() => {
resolveAndSetValuesAsString(field);
}, [field, resolveAndSetValuesAsString]);
return (
<Flex flexDir="column" gap={2}>
<Select className="nowheel nodrag" onChange={onChangeGeneratorType} value={field.value.type} size="sm">
<Select
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
onChange={onChangeGeneratorType}
value={field.value.type}
size="sm"
>
<option value={StringGeneratorParseStringType}>{t('nodes.parseString')}</option>
{/* <option value={StringGeneratorDynamicPromptsRandomType}>{t('nodes.dynamicPromptsRandom')}</option>
<option value={StringGeneratorDynamicPromptsRandomType}>{t('nodes.dynamicPromptsRandom')}</option>
<option value={StringGeneratorDynamicPromptsCombinatorialType}>
{t('nodes.dynamicPromptsCombinatorial')}
</option> */}
</option>
</Select>
{field.value.type === StringGeneratorParseStringType && (
<StringGeneratorParseStringSettings state={field.value} onChange={onChange} />
@@ -92,12 +99,17 @@ export const StringGeneratorFieldInputComponent = memo(
<Flex w="full" h="full" p={2} borderWidth={1} borderRadius="base" maxH={128}>
<Flex w="full" h="auto">
<OverlayScrollbarsComponent
className="nodrag nowheel"
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
>
<Text className="nodrag nowheel" fontFamily="monospace" userSelect="text" cursor="text">
<Text
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
fontFamily="monospace"
userSelect="text"
cursor="text"
>
{resolvedValuesAsString}
</Text>
</OverlayScrollbarsComponent>

View File

@@ -2,6 +2,7 @@ import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldT2IAdapterModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { T2IAdapterModelFieldInputInstance, T2IAdapterModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useT2IAdapterModels } from 'services/api/hooks/modelsByType';
@@ -42,7 +43,7 @@ const T2IAdapterModelFieldInputComponent = (
return (
<Tooltip label={value?.description}>
<FormControl className="nowheel nodrag" isInvalid={!value}>
<FormControl className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`} isInvalid={!value}>
<Combobox value={value} placeholder="Pick one" options={options} onChange={onChange} />
</FormControl>
</Tooltip>

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldT5EncoderValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { T5EncoderModelFieldInputInstance, T5EncoderModelFieldInputTemplate } from 'features/nodes/types/field';
import { selectIsModelsTabDisabled } from 'features/system/store/configSlice';
import { memo, useCallback } from 'react';
@@ -44,7 +45,11 @@ const T5EncoderModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<Tooltip label={!isModelsTabDisabled && t('modelManager.starterModelsInModelManager')}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value && required}>
<FormControl
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
isDisabled={!options.length}
isInvalid={!value && required}
>
<Combobox
value={value}
placeholder={required ? placeholder : `(Optional) ${placeholder}`}

View File

@@ -2,6 +2,7 @@ import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { fieldVaeModelValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { VAEModelFieldInputInstance, VAEModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useVAEModels } from 'services/api/hooks/modelsByType';
@@ -40,7 +41,11 @@ const VAEModelFieldInputComponent = (props: Props) => {
return (
<Flex w="full" alignItems="center" gap={2}>
<FormControl className="nowheel nodrag" isDisabled={!options.length} isInvalid={!value && required}>
<FormControl
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
isDisabled={!options.length}
isInvalid={!value && required}
>
<Combobox
value={value}
placeholder={required ? placeholder : `(Optional) ${placeholder}`}

View File

@@ -1,13 +1,16 @@
import { Box, Flex, Textarea } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import type { Node, NodeProps } from '@xyflow/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import { notesNodeValueChanged } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
import type { NotesNodeData } from 'features/nodes/types/invocation';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { memo, useCallback, useMemo } from 'react';
const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
const { id: nodeId, data, selected } = props;
@@ -20,6 +23,16 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
[dispatch, nodeId]
);
const selectNodeExists = useMemo(
() => createSelector(selectNodes, (nodes) => Boolean(nodes.find((n) => n.id === nodeId))),
[nodeId]
);
const nodeExists = useAppSelector(selectNodeExists);
if (!nodeExists) {
return null;
}
return (
<NodeWrapper nodeId={nodeId} selected={selected}>
<Flex
@@ -38,7 +51,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
<>
<Flex
layerStyle="nodeBody"
className="nopan"
className={NO_PAN_CLASS}
cursor="auto"
flexDirection="column"
borderBottomRadius="base"
@@ -47,8 +60,15 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
p={2}
gap={1}
>
<Flex className="nopan" w="full" h="full" flexDir="column">
<Textarea className="nodrag" value={notes} onChange={handleChange} rows={8} resize="none" fontSize="sm" />
<Flex className={NO_PAN_CLASS} w="full" h="full" flexDir="column">
<Textarea
className={NO_DRAG_CLASS}
value={notes}
onChange={handleChange}
rows={8}
resize="none"
fontSize="sm"
/>
</Flex>
</Flex>
</>

View File

@@ -3,6 +3,7 @@ import { Icon, IconButton } from '@invoke-ai/ui-library';
import { useUpdateNodeInternals } from '@xyflow/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { nodeIsOpenChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import { memo, useCallback } from 'react';
import { PiCaretUpBold } from 'react-icons/pi';
@@ -31,7 +32,7 @@ const NodeCollapseButton = ({ nodeId, isOpen }: Props) => {
return (
<IconButton
className="nodrag"
className={`${NO_DRAG_CLASS} ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}
onClick={handleClick}
aria-label="Minimize"
minW={8}

View File

@@ -6,6 +6,7 @@ import { useBatchGroupId } from 'features/nodes/hooks/useBatchGroupId';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -48,9 +49,14 @@ const NodeTitle = ({ nodeId, title }: Props) => {
}, [batchGroupId, editable.value, t]);
return (
<Flex overflow="hidden" w="full" h="full" alignItems="center" justifyContent="center" cursor="text">
<Flex overflow="hidden" w="full" h="full" alignItems="center" justifyContent="center">
{!editable.isEditing && (
<Text fontWeight="semibold" color={batchGroupColorToken} onDoubleClick={editable.startEditing}>
<Text
className={NO_FIT_ON_DOUBLE_CLICK_CLASS}
fontWeight="semibold"
color={batchGroupColorToken}
onDoubleClick={editable.startEditing}
>
{titleWithBatchGroupId}
</Text>
)}

View File

@@ -1,14 +1,11 @@
import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useReactFlow, type NodeChange } from '@xyflow/react';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useAppSelector } from 'app/store/storeHooks';
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { nodesChanged } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
import { selectNodeOpacity } from 'features/nodes/store/workflowSettingsSlice';
import { DRAG_HANDLE_CLASSNAME, NODE_WIDTH } from 'features/nodes/types/constants';
import type { AnyNode } from 'features/nodes/types/invocation';
import { DRAG_HANDLE_CLASSNAME, NO_FIT_ON_DOUBLE_CLICK_CLASS, NODE_WIDTH } from 'features/nodes/types/constants';
import { zNodeStatus } from 'features/nodes/types/invocation';
import type { MouseEvent, PropsWithChildren } from 'react';
import { memo, useCallback } from 'react';
@@ -19,8 +16,8 @@ type NodeWrapperProps = PropsWithChildren & {
width?: ChakraProps['w'];
};
// Animations are disabled as a performance optimization - they can cause massive slowdowns in large workflows - even
// when the animations are GPU-accelerated CSS.
// Certain CSS transitions are disabled as a performance optimization - they can cause massive slowdowns in large
// workflows even when the animations are GPU-accelerated CSS.
const containerSx: SystemStyleObject = {
h: 'full',
@@ -28,6 +25,43 @@ const containerSx: SystemStyleObject = {
borderRadius: 'base',
transitionProperty: 'none',
cursor: 'grab',
// The action buttons are hidden by default and shown on hover
'& .node-selection-overlay': {
display: 'none',
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'base',
transitionProperty: 'none',
pointerEvents: 'none',
opacity: 0.5,
},
'&[data-is-mouse-over-node="true"] .node-selection-overlay': {
opacity: 1,
display: 'block',
},
'&[data-is-mouse-over-form-field="true"] .node-selection-overlay': {
opacity: 1,
display: 'block',
bg: 'invokeBlueAlpha.100',
},
_hover: {
'& .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 2px var(--invoke-colors-blue-300)',
},
'&[data-is-selected="true"] .node-selection-overlay': {
display: 'block',
opacity: 1,
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
},
},
'&[data-is-selected="true"] .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
},
};
const shadowsSx: SystemStyleObject = {
@@ -53,96 +87,67 @@ const inProgressSx: SystemStyleObject = {
transitionProperty: 'none',
opacity: 0.7,
zIndex: -1,
visibility: 'hidden',
display: 'none',
shadow: '0 0 0 2px var(--invoke-colors-yellow-400), 0 0 20px 2px var(--invoke-colors-orange-700)',
'&[data-is-in-progress="true"]': {
visibility: 'visible',
},
};
const selectionOverlaySx: SystemStyleObject = {
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'base',
transitionProperty: 'none',
pointerEvents: 'none',
visibility: 'hidden',
opacity: 0.5,
'&[data-is-selected="true"], &[data-is-hovered="true"]': { visibility: 'visible' },
'&[data-is-selected="true"]': { shadow: '0 0 0 3px var(--invoke-colors-blue-300)' },
'&[data-is-hovered="true"]': { shadow: '0 0 0 2px var(--invoke-colors-blue-300)' },
'&[data-is-selected="true"][data-is-hovered="true"]': {
opacity: 1,
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
display: 'block',
},
};
const NodeWrapper = (props: NodeWrapperProps) => {
const { nodeId, width, children, selected } = props;
const store = useAppStore();
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId);
const mouseOverNode = useMouseOverNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
const zoomToNode = useZoomToNode();
const executionState = useNodeExecutionState(nodeId);
const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS;
const dispatch = useAppDispatch();
const opacity = useAppSelector(selectNodeOpacity);
const { onCloseGlobal } = useGlobalMenuClose();
const globalMenu = useGlobalMenuClose();
const { setCenter } = useReactFlow();
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (!e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) {
const nodes = selectNodes(store.getState());
const nodeChanges: NodeChange<AnyNode>[] = [];
nodes.forEach(({ id, selected }) => {
if (selected !== (id === nodeId)) {
nodeChanges.push({ type: 'select', id, selected: id === nodeId });
}
});
if (nodeChanges.length > 0) {
dispatch(nodesChanged(nodeChanges));
}
const onDoubleClick = useCallback(
(e: MouseEvent) => {
if (!(e.target instanceof HTMLElement)) {
// We have to manually narrow the type here thanks to a TS quirk
return;
}
onCloseGlobal();
},
[onCloseGlobal, store, dispatch, nodeId]
);
const handleDoubleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (!e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) {
const nodes = selectNodes(store.getState());
const node = nodes.find((node) => node.id === nodeId);
if (node) {
setCenter(node.position.x + NODE_WIDTH / 2, node.position.y, { zoom: 1 });
}
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement ||
e.target instanceof HTMLButtonElement ||
e.target instanceof HTMLAnchorElement
) {
// Don't fit the view if the user is editing a text field, select, button, or link
return;
}
onCloseGlobal();
if (e.target.closest(`.${NO_FIT_ON_DOUBLE_CLICK_CLASS}`) !== null) {
// This target is marked as not fitting the view on double click
return;
}
zoomToNode(nodeId);
},
[onCloseGlobal, store, nodeId, setCenter]
[nodeId, zoomToNode]
);
return (
<Box
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseOver}
onMouseLeave={handleMouseOut}
onClick={globalMenu.onCloseGlobal}
onDoubleClick={onDoubleClick}
onMouseOver={mouseOverNode.handleMouseOver}
onMouseOut={mouseOverNode.handleMouseOut}
className={DRAG_HANDLE_CLASSNAME}
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
data-is-selected={selected}
data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField}
>
<Box sx={shadowsSx} />
<Box sx={inProgressSx} data-is-in-progress={isInProgress} />
{children}
<Box sx={selectionOverlaySx} data-is-selected={selected} data-is-hovered={isMouseOverNode} />
<Box className="node-selection-overlay" />
</Box>
);
};

View File

@@ -1,4 +1,4 @@
import { Button, ButtonGroup, Flex, IconButton } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate';
import { updateAllNodesRequested } from 'features/nodes/store/actions';
@@ -9,32 +9,24 @@ import { PiWarningBold } from 'react-icons/pi';
const UpdateNodesButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
// const nodesNeedUpdate = useGetNodesNeedUpdate();
const nodesNeedUpdate = useGetNodesNeedUpdate();
const handleClickUpdateNodes = useCallback(() => {
dispatch(updateAllNodesRequested());
}, [dispatch]);
// if (!nodesNeedUpdate) {
// return null;
// }
if (!nodesNeedUpdate) {
return null;
}
return (
<Flex flexDir="column">
<Button
leftIcon={<PiWarningBold />}
tooltip={t('nodes.updateAllNodes')}
aria-label={t('nodes.updateAllNodes')}
onClick={handleClickUpdateNodes}
pointerEvents="auto"
colorScheme="red"
>
1 Missing Field
</Button>
<ButtonGroup>
<Button>{`<`}</Button>
<Button>{`>`}</Button>
</ButtonGroup>
</Flex>
<IconButton
tooltip={t('nodes.updateAllNodes')}
aria-label={t('nodes.updateAllNodes')}
icon={<PiWarningBold />}
onClick={handleClickUpdateNodes}
pointerEvents="auto"
colorScheme="warning"
/>
);
};

View File

@@ -1,7 +1,6 @@
import { Box } from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { ViewContextProvider } from 'features/nodes/contexts/ViewContext';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
import type { CSSProperties } from 'react';
import { memo, useCallback, useRef } from 'react';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
@@ -23,27 +22,25 @@ export const EditModeLeftPanelContent = memo(() => {
}, []);
return (
<ViewContextProvider view="edit-mode-linear">
<Box position="relative" w="full" h="full">
<ScrollableContent>
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowFieldsLinearViewPanel />
</Panel>
<ResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<WorkflowNodeInspectorPanel />
</Panel>
</PanelGroup>
</ScrollableContent>
</Box>
</ViewContextProvider>
<Box position="relative" w="full" h="full">
<ScrollableContent>
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowFieldsLinearViewPanel />
</Panel>
<HorizontalResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<WorkflowNodeInspectorPanel />
</Panel>
</PanelGroup>
</ScrollableContent>
</Box>
);
});

View File

@@ -1,67 +0,0 @@
import { Badge, Flex, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowMode, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold, PiPencilBold } from 'react-icons/pi';
export const ModeToggle = () => {
const dispatch = useAppDispatch();
const mode = useAppSelector(selectWorkflowMode);
const { t } = useTranslation();
const onClickEdit = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(workflowModeChanged('edit'));
},
[dispatch]
);
const onClickView = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(workflowModeChanged('view'));
},
[dispatch]
);
return (
<Flex justifyContent="flex-end">
{mode === 'view' && (
<Flex pos="relative">
<Badge
pos="absolute"
insetInlineStart="-8px"
insetBlockStart="-8px"
colorScheme="invokeRed"
zIndex="docked"
shadow="dark-lg"
userSelect="none"
>
!
</Badge>
<IconButton
aria-label={t('nodes.editMode')}
tooltip={t('nodes.editMode')}
onClick={onClickEdit}
icon={<PiPencilBold />}
variant="outline"
size="sm"
/>
</Flex>
)}
{mode === 'edit' && (
<IconButton
aria-label={t('nodes.viewMode')}
tooltip={t('nodes.viewMode')}
onClick={onClickView}
icon={<PiEyeBold />}
variant="outline"
size="sm"
/>
)}
</Flex>
);
};

View File

@@ -0,0 +1,34 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useNewWorkflow } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import type { MouseEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFilePlusBold } from 'react-icons/pi';
export const NewWorkflowButton = memo(() => {
const newWorkflow = useNewWorkflow();
const { t } = useTranslation();
const onClickNewWorkflow = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
// We need to stop the event from propagating to the parent element, else the click will open the list menu
e.stopPropagation();
newWorkflow.createWithDialog();
},
[newWorkflow]
);
return (
<IconButton
onClick={onClickNewWorkflow}
variant="ghost"
size="sm"
aria-label={t('nodes.newWorkflow')}
tooltip={t('nodes.newWorkflow')}
icon={<PiFilePlusBold />}
/>
);
});
NewWorkflowButton.displayName = 'NewWorkflowButton';

View File

@@ -1,56 +0,0 @@
import { Flex, IconButton, Spacer, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ModeToggle } from 'features/nodes/components/sidePanel/ModeToggle';
import { selectWorkflowDescription, selectWorkflowMode, selectWorkflowName } from 'features/nodes/store/workflowSlice';
import { useNewWorkflow } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFilePlusBold } from 'react-icons/pi';
import SaveWorkflowButton from './SaveWorkflowButton';
export const ActiveWorkflow = () => {
const activeWorkflowName = useAppSelector(selectWorkflowName);
const activeWorkflowDescription = useAppSelector(selectWorkflowDescription);
const mode = useAppSelector(selectWorkflowMode);
const newWorkflow = useNewWorkflow();
const { t } = useTranslation();
const onClickNewWorkflow = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
// We need to stop the event from propagating to the parent element, else the click will open the list menu
e.stopPropagation();
newWorkflow.createWithDialog();
},
[newWorkflow]
);
return (
<Flex w="full" alignItems="center" gap={2} minW={0}>
{activeWorkflowName ? (
<Tooltip label={activeWorkflowDescription}>
<Text colorScheme="invokeBlue" fontWeight="semibold" fontSize="md" justifySelf="flex-start">
{activeWorkflowName}
</Text>
</Tooltip>
) : (
<Text fontSize="sm" fontWeight="semibold" color="base.300">
{t('workflows.chooseWorkflowFromLibrary')}
</Text>
)}
<Spacer />
{mode === 'edit' && <SaveWorkflowButton />}
<ModeToggle />
<IconButton
onClick={onClickNewWorkflow}
variant="outline"
size="sm"
aria-label={t('nodes.newWorkflow')}
tooltip={t('nodes.newWorkflow')}
icon={<PiFilePlusBold />}
/>
</Flex>
);
};

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