Compare commits

...

185 Commits

Author SHA1 Message Date
Mary Hipp
59bd6b935d wip missing fields prototype 2025-02-19 16:00:33 -05:00
Mary Hipp
4bba7de070 fix omnipresent pencil 2025-02-19 09:52:37 -05:00
psychedelicious
e1f2b232c8 feat(ui): color picker improvements
- Support transparency w/ color picker. To do this, we need to hide the bg layer before sampling. In testing, this has a negligible performance impact.
- Add an RGBA value readout next to the color picker ring.
2025-02-18 15:38:50 +11:00
psychedelicious
2c5b0195fc fix(ui): straight lines drawn with shift-click get cut off when canvas moved between clicks
Need to opt-out of the clipping logic when using shift-click to not cut off the line.
2025-02-18 15:38:50 +11:00
psychedelicious
56792b2d2c fix(ui): mask layers not showing up until you zoom
Unfortunately I couldn't reliably reproduce the issue, so I'm not 100% sure this fixes it. But I think there is a race condition that results in `updateCompositingRectSize` erroneously seeing the layer has no objects and skipping the update.

To address this, the compositing rect fill/size/pos are all now force-updated when the fill/objects are changed. Theoretically it should be impossible for the issue to occur now.
2025-02-18 15:38:50 +11:00
psychedelicious
d71e8b4980 fix(ui): cursor visibility
- Fix an issue where the cursor disappeared when selecting a non-renderable entity. For example, when selecting a reference image layer and certain tools, the cursor would disappear.
- Ensure color picker works no matter what layer types are selected.

The logic for showing/hiding the cursor needed to be rearranged a bit for this fix.
2025-02-18 15:38:50 +11:00
Mary Hipp
ca50f8193c add AppFeature for retryQueueItem in case we want to easily disable 2025-02-18 09:14:03 +11:00
psychedelicious
7ee636b68b feat(ui): add retry buttons to queue tab
- Add the new HTTP endpoint to the queue client
- Add buttons to the queue items to retry them
2025-02-18 09:14:03 +11:00
psychedelicious
926f69677a chore(ui): typegen 2025-02-18 09:14:03 +11:00
psychedelicious
675ac348de feat(app): add retry queue item functionality
Retrying a queue item means cloning it, resetting all execution-related state. Retried queue items reference the item they were retried from by id. This relationship is not enforced by any DB constraints.

- Add `retried_from_item_id` to `session_queue` table in DB in a migration.
- Add `retry_items_by_id` method to session queue service. Accepts a list of queue item IDs and clones them (minus execution state). Returns a list of retried items. Items that are not in a canceled or failed state are skipped.
- Add `retry_items_by_id` HTTP endpoint that maps 1-to-1 to the queue service method.
- Add `queue_items_retried` event, which includes the list of retried items.
2025-02-18 09:14:03 +11:00
psychedelicious
62e5b9da18 docs(ui): add comments for recent perf optimizations 2025-02-17 09:28:13 +11:00
psychedelicious
65eabde297 per(ui): move field desc content to own component 2025-02-17 09:28:13 +11:00
psychedelicious
6bebd2bfc8 chore(ui): lint 2025-02-17 09:28:13 +11:00
psychedelicious
cd785ba64b perf(ui): optimize field handle/title/etc rendering 2025-02-17 09:28:13 +11:00
psychedelicious
726b4637db perf(ui): optimize workflow editor inspector panel rendering 2025-02-17 09:28:13 +11:00
psychedelicious
b50241fe6a perf(ui): make field description popver rendering lazy 2025-02-17 09:28:13 +11:00
psychedelicious
5b8735db3b perf(ui): optimize node update checking 2025-02-17 09:28:13 +11:00
psychedelicious
ce286363d0 perf(ui): optimize checking if a field value is changed by wrapping in single selector 2025-02-17 09:28:13 +11:00
psychedelicious
2fa47cf270 perf(ui): use lazy rendering for builder element settings popovers 2025-02-17 09:28:13 +11:00
psychedelicious
3446486f40 perf(ui): do not use memoized selector for control adapter state 2025-02-17 09:28:13 +11:00
psychedelicious
a0cdcdef57 perf(ui): debounce invoke readiness calculations 2025-02-17 09:28:13 +11:00
psychedelicious
abbb3609c8 fix(ui): race condition that causes non-user-facing error when handling canvas filter cancelations
The abortController could be null by the time we attempt to abort it
2025-02-17 09:28:13 +11:00
psychedelicious
700ad78f87 Revert "perf(ui): connection line issue on chrome"
This reverts commit 9d482e5fe621c2dbbde18ed17301a12b0e7f2580.
2025-02-17 09:28:13 +11:00
psychedelicious
cfb08f326e perf(ui): fix issue w/ add node cmdk component (more fixed) 2025-02-17 09:28:13 +11:00
psychedelicious
aae4fa3cca perf(ui): reduce animations which slow down reactflow 2025-02-17 09:28:13 +11:00
psychedelicious
109adc5a93 perf(ui): fix issue w/ add node cmdk component 2025-02-17 09:28:13 +11:00
psychedelicious
acb7ef8837 perf(ui): slightly more efficient gallery pagination componsts 2025-02-17 09:28:13 +11:00
psychedelicious
3c5e829c72 feat(ui): use new more efficient RTK upsert methods 2025-02-17 09:28:13 +11:00
psychedelicious
10d9e75391 fix(ui): rtk upgrade TS issues 2025-02-17 09:28:13 +11:00
psychedelicious
b6a892a673 chore(ui): bump @reduxjs/toolkit to latest 2025-02-17 09:28:13 +11:00
psychedelicious
479d5cc362 perf(ui): isolate a lot of root-level hooks in a memoized component 2025-02-17 09:28:13 +11:00
psychedelicious
01e4fd100f perf(ui): optimized invocation node component structure 2025-02-17 09:28:13 +11:00
psychedelicious
8ecf9fb7e3 perf(ui): connection line issue on chrome 2025-02-17 09:28:13 +11:00
psychedelicious
436d5ee0c6 chore(ui): lint 2025-02-17 09:28:13 +11:00
psychedelicious
0671fec844 perf(ui): workflow editor misc
- Optimize component and hook structure for input fields to reduce rerenders of component tree
- Remove memoization on some selectors where it serves no purpose (bc the object will have a stable identity until it changes, at which point we need to re-render anyways)
- Shift the connection error selector logic around to rely more on the stable identity of pending connection objects
2025-02-17 09:28:13 +11:00
Eugene Brodsky
4dbde53f9b fix(docker): use the node22 image for the frontend build 2025-02-15 17:21:34 -05:00
psychedelicious
f6c4682b99 fix(ui): builder alpha status alert not visible when many elements added 2025-02-14 15:33:02 +11:00
psychedelicious
b3288ed64e chore: bump version to v5.7.0a1 2025-02-14 15:33:02 +11:00
psychedelicious
f3dfb1b6ea chore(ui): knip 2025-02-14 14:50:56 +11:00
psychedelicious
65a37ca4ff feat(ui): give vertical dividers a min height 2025-02-14 14:50:56 +11:00
psychedelicious
9adbe31fec tweak(ui): form element edit mode styling 2025-02-14 14:50:56 +11:00
psychedelicious
0a2925f02b feat(ui): add warning about alpha status of builder 2025-02-14 14:50:56 +11:00
psychedelicious
877dcc73c3 feat(ui): check image access for image collections when loading workflows 2025-02-14 14:50:56 +11:00
psychedelicious
aec2136323 fix(ui): force refetch when checking image access to ensure stale RTK query cache isn't use 2025-02-14 14:50:56 +11:00
psychedelicious
8ef5c54ffe feat(ui): add delete button to missing image placeholder for image collection fields 2025-02-14 14:50:56 +11:00
psychedelicious
6faed4f1ec fix(ui): remove images from node image collections when deleted 2025-02-14 14:50:56 +11:00
psychedelicious
aa71db4d31 tidy(ui): remove nonfunctional conditionals 2025-02-14 14:50:56 +11:00
psychedelicious
6407ab4a2e tweak(ui): builder padding 2025-02-14 14:50:56 +11:00
psychedelicious
a91b0f25cb feat(ui): consolidate row/column dnd draggables into container 2025-02-14 14:50:56 +11:00
psychedelicious
ef664863b5 feat(ui): remove separate flag for form vs workflow edit mode 2025-02-14 14:50:56 +11:00
psychedelicious
bf8ba1bb37 feat(ui): text and heading element default content is empty string 2025-02-14 14:50:56 +11:00
psychedelicious
54747bd521 feat(ui): remove element id from edit mode header 2025-02-14 14:50:56 +11:00
psychedelicious
d040a6953f tweak(ui): styling for edit mode 2025-02-14 14:50:56 +11:00
psychedelicious
828497cf89 feat(ui): remove node field reset button from edit mode header 2025-02-14 14:50:56 +11:00
psychedelicious
28950a4891 fix(ui): ignore dropping on self 2025-02-14 14:50:56 +11:00
psychedelicious
1c92838bf9 tidy(ui): builder dnd monitor logic rearrange 2025-02-14 14:50:56 +11:00
psychedelicious
71f6737e19 feat(ui): remove the showLabel flag for node fields 2025-02-14 14:50:56 +11:00
psychedelicious
dcac65f46b feat(ui): add initial values for builder fields 2025-02-14 14:50:56 +11:00
psychedelicious
46f549a57a feat(ui): better placeholders for text/heading 2025-02-14 14:50:56 +11:00
psychedelicious
fb93101085 tweak(ui): layout of workflow builder field settings 2025-02-14 14:50:56 +11:00
psychedelicious
9aabcfa4b8 feat(ui): default form field settings 2025-02-14 14:50:56 +11:00
psychedelicious
64587b37db refactor(ui): remove confusing containerId from various builder actions 2025-02-14 14:50:56 +11:00
psychedelicious
c673b6e11d feat(ui): demote dnd logs to trace 2025-02-14 14:50:56 +11:00
psychedelicious
a3a49ddda0 tidy(ui): useNodeFieldDnd 2025-02-14 14:50:56 +11:00
psychedelicious
330a0f0028 tidy(ui): extract util in dnd 2025-02-14 14:50:56 +11:00
psychedelicious
1104d2a00f feat(ui): initial values for form fields (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
aed802fa74 feat(ui): rearrange builder buttons to be less annoying 2025-02-14 14:50:56 +11:00
psychedelicious
498d99c828 fix(ui): handle form fields not existing on node on workflow load 2025-02-14 14:50:56 +11:00
psychedelicious
3d19b98208 chore(ui): lint 2025-02-14 14:50:56 +11:00
psychedelicious
85f5bb4a02 fix(ui): incorrect node data used during update 2025-02-14 14:50:56 +11:00
psychedelicious
269f718d2c tidy(ui): node description components 2025-02-14 14:50:56 +11:00
psychedelicious
211bb8a204 feat(ui): auto-update nodes on loading workflow 2025-02-14 14:50:56 +11:00
psychedelicious
ef0ef875dd feat(ui): migrated linear view exposed fields to builder form on load 2025-02-14 14:50:56 +11:00
psychedelicious
9c62648283 fix(ui): do not error in node/field selectors are used outside field gate components 2025-02-14 14:50:56 +11:00
psychedelicious
4ca45f7651 feat(ui): be double extra sure migrated workflows are parsed before loading 2025-02-14 14:50:56 +11:00
psychedelicious
2abe2f52f7 feat(ui): workflow builder layout 2025-02-14 14:50:56 +11:00
psychedelicious
6f1c814af4 revert(ui): code lint that broke stuff 2025-02-14 14:50:56 +11:00
psychedelicious
1ad6ccc426 tidy(ui): dnd code lint 2025-02-14 14:50:56 +11:00
psychedelicious
aedee536a0 tidy(ui): rename builder dnd file 2025-02-14 14:50:56 +11:00
psychedelicious
d2b15fba12 tidy(ui): improve dnd hook names 2025-02-14 14:50:56 +11:00
psychedelicious
a674e781a1 tidy(ui): dnd logic formatting 2025-02-14 14:50:56 +11:00
psychedelicious
0db74f0cde refactor(ui): add vars in dnd logic for conciseness 2025-02-14 14:50:56 +11:00
psychedelicious
d66db67d1a refactor(ui): clean up dnd logic 2025-02-14 14:50:56 +11:00
psychedelicious
2507a7f674 tidy(ui): rename root utils in dnd 2025-02-14 14:50:56 +11:00
psychedelicious
145503a0a0 refactor(ui): add dispatchAndFlash util for dnd 2025-02-14 14:50:56 +11:00
psychedelicious
32e8dd5647 fix(ui): divider rendering 2025-02-14 14:50:56 +11:00
psychedelicious
fe87adcb52 feat(ui): builder edit/view buttons 2025-02-14 14:50:56 +11:00
psychedelicious
e95255f6e8 feat(ui): make drop targets that are in root sticky 2025-02-14 14:50:56 +11:00
psychedelicious
efec224523 fix(ui): remove node field from form correctly when node is deleted 2025-02-14 14:50:56 +11:00
psychedelicious
e948e236e7 feat(ui): iterate on builder data structure 2025-02-14 14:50:56 +11:00
psychedelicious
189eb85663 feat(ui): delete form elements when node is deleted from workflow 2025-02-14 14:50:56 +11:00
psychedelicious
94f90f4082 feat(ui): string field settings 2025-02-14 14:50:56 +11:00
psychedelicious
1eb491fdaa feat(ui): builder empty state (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
176248a023 feat(ui): empty state for drop containers 2025-02-14 14:50:56 +11:00
psychedelicious
3c676ed11a fix(ui): drop target jank 2025-02-14 14:50:56 +11:00
psychedelicious
7a9340b850 fix(ui): tsc issues 2025-02-14 14:50:56 +11:00
psychedelicious
2c0b474f55 feat(ui): editable node form field labels & descriptions 2025-02-14 14:50:56 +11:00
psychedelicious
74c76611a9 feat(ui): add float field display settings 2025-02-14 14:50:56 +11:00
psychedelicious
1c7176b3f4 feat(ui): use useEditable in builder 2025-02-14 14:50:56 +11:00
psychedelicious
30363a0018 feat(ui): builder field settings (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
b46dbcc76d fix(ui): divider layout 2025-02-14 14:50:56 +11:00
psychedelicious
09879f4e19 feat(ui): builder field settings (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
4daa82c912 feat(ui): builder field settings (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
1cb04d9a4a refactor(ui): updated component structure for input and output fields 2025-02-14 14:50:56 +11:00
psychedelicious
3e6969128c feat(ui): remove sizes from text & heading 2025-02-14 14:50:56 +11:00
psychedelicious
e14c490ac6 fix(ui): drop indicator getting greyed out when dragging over self 2025-02-14 14:50:56 +11:00
psychedelicious
3ef3b97c58 feat(ui): editable heading and text elements 2025-02-14 14:50:56 +11:00
psychedelicious
3baaefb0cc chore(ui): bump @invoke-ai/ui-library 2025-02-14 14:50:56 +11:00
psychedelicious
98b0a8ffb2 feat(ui): plumbing for editable form elements 2025-02-14 14:50:56 +11:00
psychedelicious
4f85bf078a tidy(ui): import reactflow css in main theme provider 2025-02-14 14:50:56 +11:00
psychedelicious
f0563d41db fix(ui): circular dep 2025-02-14 14:50:56 +11:00
psychedelicious
a7a71ca935 perf(ui): faster InputFieldRenderer
Use non-zod type guards for input field types and fail early when possible
2025-02-14 14:50:56 +11:00
psychedelicious
c04822054b chore(ui): lint 2025-02-14 14:50:56 +11:00
psychedelicious
132e9bebd7 chore(ui): lint 2025-02-14 14:50:56 +11:00
psychedelicious
0dc45ac903 fix(ui): node-autoconnect showing invalid connection options 2025-02-14 14:50:56 +11:00
psychedelicious
4f9d81917c fix(ui): do not render dashed edges unless animation is enabled 2025-02-14 14:50:56 +11:00
psychedelicious
d3c22eceaf tweak(ui): node selection colors 2025-02-14 14:50:56 +11:00
psychedelicious
fb77d271ab refactor(ui): edge rendering
- Fix issues with positioning of labels
- Optimize styling to be less reliant on JS
2025-02-14 14:50:56 +11:00
psychedelicious
0371881349 chore(ui): upgrade reactflow to v12 2025-02-14 14:50:56 +11:00
psychedelicious
4b178fdeca fix(ui): hide nonfunctional delete button on root form element 2025-02-14 14:50:56 +11:00
psychedelicious
b53e36aaaa tidy(ui): remove unused mock form builder data 2025-02-14 14:50:56 +11:00
psychedelicious
c061cd5e54 fix(ui): use redux store for form 2025-02-14 14:50:56 +11:00
psychedelicious
ddda915ebd fix(ui): start workflow w/ single column as root 2025-02-14 14:50:56 +11:00
psychedelicious
9a2d8844a2 fix(ui): allow root element to be drop target 2025-02-14 14:50:56 +11:00
psychedelicious
48583df02e feat(ui): support adding form elements and node fields with dnd 2025-02-14 14:50:56 +11:00
psychedelicious
f9432d10d2 feat(ui): improved drop target styling 2025-02-14 14:50:56 +11:00
psychedelicious
0d28cd7ebe fix(ui): do not allow reparenting to self 2025-02-14 14:50:56 +11:00
psychedelicious
c9f9a2f2d4 feat(ui): dnd drop target styling 2025-02-14 14:50:56 +11:00
psychedelicious
a05d10f648 feat(ui): improved dnd hitbox for edges when center drop is allowed 2025-02-14 14:50:56 +11:00
psychedelicious
14845932fb feat(ui): dnd almost fully working (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
2aa1fc9301 feat(ui): dnd mostly working (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
98139562f3 feat(ui): dim form element while dragging 2025-02-14 14:50:56 +11:00
psychedelicious
8365bba5ba feat(ui): hacking on dnd (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
9f07e83a23 feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
1f995d0257 feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
6ae2d5ef9d feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
55973b4c66 feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
d8c6531b70 feat(ui): getPrefixedId supports custom separator 2025-02-14 14:50:56 +11:00
psychedelicious
81e385a756 feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
f6cb1a455f feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
bf60be99dc feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
bee0e8248f feat(ui): iterate on builder (WIP) 2025-02-14 14:50:56 +11:00
psychedelicious
1e658cf9e7 chore(ui): lint 2025-02-14 14:50:56 +11:00
psychedelicious
f130fa4d66 feat(ui): rough out workflow builder data structure 2025-02-14 14:50:56 +11:00
psychedelicious
02a47a6806 refactor(ui): split integer, float and string field components in prep for builder 2025-02-14 14:50:56 +11:00
psychedelicious
1063498458 revert(ui): rip out linear view config stuff 2025-02-14 14:50:56 +11:00
psychedelicious
e9a13ec882 refactor(ui): split up float and integer field renderers 2025-02-14 14:50:56 +11:00
psychedelicious
bd0765b744 feat(ui): rough out workflow builder data structure & dummy data 2025-02-14 14:50:56 +11:00
psychedelicious
6e1388f4fc fix(ui): dynamic prompts infinite recursion (wip) 2025-02-14 14:50:56 +11:00
psychedelicious
2a9f2b2fe2 feat(ui): use workflows view context 2025-02-14 14:50:56 +11:00
psychedelicious
0a6b0dc3bf feat(ui): get configurable notes display working 2025-02-14 14:50:56 +11:00
psychedelicious
8753406a6c fix(ui): color field component layout 2025-02-14 14:50:56 +11:00
psychedelicious
e2b09bed62 refactor(ui): continued reorg of components & hooks 2025-02-14 14:50:56 +11:00
psychedelicious
011910a08c refactor(ui): continued reorg of components & hooks 2025-02-14 14:50:56 +11:00
psychedelicious
bfd70be50b fix(ui): remove accidental change to zFieldInput schema 2025-02-14 14:50:56 +11:00
psychedelicious
9c53bd6a3b refactor(ui): workflows left panel internal components structure 2025-02-14 14:50:56 +11:00
psychedelicious
e479cb5fe4 refactor(ui): workflows component structure (WIP)
- Simplify and de-insane-ify component structure, hooks, selectors, etc.
- Some perf improvements by using data attributes for styling instead of dynamic CSS-in-JS.
- Add field notes and start of linear view config, got blocked when I ran into deeper layout issues that made it very difficult to handle field configs. So those are WIP in this commit.
2025-02-14 14:50:56 +11:00
psychedelicious
52947f40c3 perf(ui): use data attribute for input field wrapper styles 2025-02-14 14:50:56 +11:00
psychedelicious
bce9a23b25 feat(ui): add ViewContext so components can know where they are being rendered (user-linear view, editor-linear view, or editor-nodes view) 2025-02-14 14:50:56 +11:00
psychedelicious
2d05579568 feat(ui): clean up user-linear view styling 2025-02-14 14:50:56 +11:00
psychedelicious
11aabb5693 feat(ui): show notes icon on user-linear view, replacing info icon 2025-02-14 14:50:56 +11:00
psychedelicious
1e1e31d5b7 feat(ui): show notes icon on editor linear view 2025-02-14 14:50:56 +11:00
psychedelicious
fe86cf6d99 feat(ui): add notes popover to field title bar 2025-02-14 14:50:56 +11:00
psychedelicious
cfb63c1b81 feat(ui): add notes state to fields 2025-02-14 14:50:56 +11:00
Ryan Dick
b44415415a Use a default tile size of 1024 for VAE encode/decode operations in upscaling workflows. Previously, the model default was used (512 for SD1, 1024 for SDXL). Larger tile sizes help to prevent tiling artifacts. 2025-02-14 14:23:42 +11:00
psychedelicious
9353298b4f chore: bump version to v5.6.2 2025-02-14 13:13:33 +11:00
Eugene Brodsky
cf22e09b28 chore(ui): upgrade vite, vitest, and related plugins to latest versions 2025-02-14 11:09:51 +11:00
Linos
6e5ca7ece8 translationBot(ui): update translation (Vietnamese)
Currently translated at 99.8% (1753 of 1755 strings)

translationBot(ui): update translation (Vietnamese)

Currently translated at 99.8% (1751 of 1753 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-13 19:26:55 +11:00
Thomas Bolteau
b81209e751 translationBot(ui): update translation (French)
Currently translated at 91.7% (1609 of 1753 strings)

Co-authored-by: Thomas Bolteau <thomas.bolteau50@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/fr/
Translation: InvokeAI/Web UI
2025-02-13 19:26:55 +11:00
Riccardo Giovanetti
c4040eb2f0 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-13 19:26:55 +11:00
Billy Lisner
046ea611f9 Remove files 2025-02-13 19:24:01 +11:00
Billy Lisner
1439da5e88 Make ruff gods happy 2025-02-13 19:24:01 +11:00
Billy Lisner
69a504710f More detailed error messages 2025-02-13 19:24:01 +11:00
Billy Lisner
842b770938 Update OpenAI Schema 2025-02-13 19:24:01 +11:00
Billy Lisner
ba39331594 Make ruff happy 2025-02-13 19:24:01 +11:00
Billy Lisner
8ee9509eec Add Metadata Field Extractor 2025-02-13 19:24:01 +11:00
psychedelicious
7b5dcffb3f fix(ui): prevent overflow on document root 2025-02-13 09:10:38 +11:00
Mary Hipp
6927e95444 update defaults 2025-02-12 15:49:15 -05:00
Mary Hipp
76618fee9c feat(ui): separate upscaling settings so that tab does not inherit from main generation settings 2025-02-12 15:49:15 -05:00
Maxim Evtush
b51312f1ba Update model_images_common.py 2025-02-11 20:03:11 +11:00
Maxim Evtush
c2b71854be Update useGalleryNavigation.ts 2025-02-11 20:03:11 +11:00
Maxim Evtush
df793c898f Update denoise_latents.py 2025-02-11 20:03:11 +11:00
Maxim Evtush
d6181e4d64 Update useImageViewer.ts 2025-02-11 20:03:11 +11:00
Maxim Evtush
0a4ea9ac6f Update validateWorkflow.ts 2025-02-11 20:03:11 +11:00
dunkeroni
9e6f3e9338 image channel multiply node loads as RGBA now 2025-02-11 18:32:56 +11:00
262 changed files with 9222 additions and 4685 deletions

View File

@@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
#### Build the Web UI ------------------------------------
FROM node:20-slim AS web-builder
FROM docker.io/node:22-slim AS web-builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack use pnpm@8.x

View File

@@ -16,6 +16,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
ClearResult,
EnqueueBatchResult,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
SessionQueueItem,
SessionQueueItemDTO,
@@ -135,6 +136,19 @@ async def cancel_by_destination(
)
@session_queue_router.put(
"/{queue_id}/retry_items_by_id",
operation_id="retry_items_by_id",
responses={200: {"model": RetryItemsResult}},
)
async def retry_items_by_id(
queue_id: str = Path(description="The queue id to perform this operation on"),
item_ids: list[int] = Body(description="The queue item ids to retry"),
) -> RetryItemsResult:
"""Immediately cancels all queue items with the given origin"""
return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids)
@session_queue_router.put(
"/{queue_id}/clear",
operation_id="clear",

View File

@@ -898,7 +898,7 @@ class DenoiseLatentsInvocation(BaseInvocation):
### inpaint
mask, masked_latents, is_gradient_mask = self.prep_inpaint_mask(context, latents)
# NOTE: We used to identify inpainting models by inpecting the shape of the loaded UNet model weights. Now we
# NOTE: We used to identify inpainting models by inspecting the shape of the loaded UNet model weights. Now we
# use the ModelVariantType config. During testing, there was a report of a user with models that had an
# incorrect ModelVariantType value. Re-installing the model fixed the issue. If this issue turns out to be
# prevalent, we will have to revisit how we initialize the inpainting extensions.

View File

@@ -918,7 +918,7 @@ class ImageChannelMultiplyInvocation(BaseInvocation, WithMetadata, WithBoard):
invert_channel: bool = InputField(default=False, description="Invert the channel after scaling")
def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
image = context.images.get_pil(self.image.image_name, "RGBA")
# extract the channel and mode from the input and reference tuple
mode = CHANNEL_FORMATS[self.channel][0]

View File

@@ -18,6 +18,7 @@ from invokeai.app.invocations.fields import (
UIType,
)
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.invocations.primitives import StringOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES
from invokeai.version.invokeai_version import __version__
@@ -275,3 +276,33 @@ class CoreMetadataInvocation(BaseInvocation):
return MetadataOutput(metadata=MetadataField.model_validate(as_dict))
model_config = ConfigDict(extra="allow")
@invocation(
"metadata_field_extractor",
title="Metadata Field Extractor",
tags=["metadata"],
category="metadata",
version="1.0.0",
)
class MetadataFieldExtractorInvocation(BaseInvocation):
"""Extracts the text value from an image's metadata given a key.
Raises an error if the image has no metadata or if the value is not a string (nesting not permitted)."""
image: ImageField = InputField(description="The image to extract metadata from")
key: str = InputField(description="The key in the image's metadata to extract the value from")
def invoke(self, context: InvocationContext) -> StringOutput:
image_name = self.image.image_name
metadata = context.images.get_metadata(image_name=image_name)
if not metadata:
raise ValueError(f"No metadata found on image {image_name}")
try:
val = metadata.root[self.key]
if not isinstance(val, str):
raise ValueError(f"Metadata at key '{self.key}' must be a string")
return StringOutput(value=val)
except KeyError as e:
raise ValueError(f"No key '{self.key}' found in the metadata for {image_name}") from e

View File

@@ -28,6 +28,7 @@ from invokeai.app.services.events.events_common import (
ModelLoadCompleteEvent,
ModelLoadStartedEvent,
QueueClearedEvent,
QueueItemsRetriedEvent,
QueueItemStatusChangedEvent,
)
@@ -39,6 +40,7 @@ if TYPE_CHECKING:
from invokeai.app.services.session_queue.session_queue_common import (
BatchStatus,
EnqueueBatchResult,
RetryItemsResult,
SessionQueueItem,
SessionQueueStatus,
)
@@ -99,6 +101,10 @@ class EventServiceBase:
"""Emitted when a batch is enqueued"""
self.dispatch(BatchEnqueuedEvent.build(enqueue_result))
def emit_queue_items_retried(self, retry_result: "RetryItemsResult") -> None:
"""Emitted when a list of queue items are retried"""
self.dispatch(QueueItemsRetriedEvent.build(retry_result))
def emit_queue_cleared(self, queue_id: str) -> None:
"""Emitted when a queue is cleared"""
self.dispatch(QueueClearedEvent.build(queue_id))

View File

@@ -10,6 +10,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
QUEUE_ITEM_STATUS,
BatchStatus,
EnqueueBatchResult,
RetryItemsResult,
SessionQueueItem,
SessionQueueStatus,
)
@@ -290,6 +291,22 @@ class BatchEnqueuedEvent(QueueEventBase):
)
@payload_schema.register
class QueueItemsRetriedEvent(QueueEventBase):
"""Event model for queue_items_retried"""
__event_name__ = "queue_items_retried"
retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
@classmethod
def build(cls, retry_result: RetryItemsResult) -> "QueueItemsRetriedEvent":
return cls(
queue_id=retry_result.queue_id,
retried_item_ids=retry_result.retried_item_ids,
)
@payload_schema.register
class QueueClearedEvent(QueueEventBase):
"""Event model for queue_cleared"""

View File

@@ -1,4 +1,4 @@
# TODO: Should these excpetions subclass existing python exceptions?
# TODO: Should these exceptions subclass existing python exceptions?
class ModelImageFileNotFoundException(Exception):
"""Raised when an image file is not found in storage."""

View File

@@ -14,6 +14,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
IsEmptyResult,
IsFullResult,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
SessionQueueItem,
SessionQueueItemDTO,
@@ -139,3 +140,8 @@ class SessionQueueBase(ABC):
def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem:
"""Sets the session for a session queue item. Use this to update the session state."""
pass
@abstractmethod
def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
"""Retries the given queue items"""
pass

View File

@@ -234,6 +234,9 @@ class SessionQueueItemWithoutGraph(BaseModel):
field_values: Optional[list[NodeFieldValue]] = Field(
default=None, description="The field values that were used for this queue item"
)
retried_from_item_id: Optional[int] = Field(
default=None, description="The item_id of the queue item that this item was retried from"
)
@classmethod
def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO":
@@ -344,6 +347,11 @@ class EnqueueBatchResult(BaseModel):
priority: int = Field(description="The priority of the enqueued batch")
class RetryItemsResult(BaseModel):
queue_id: str = Field(description="The ID of the queue")
retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
class ClearResult(BaseModel):
"""Result of clearing the session queue"""
@@ -481,6 +489,7 @@ class SessionQueueValueToInsert(NamedTuple):
workflow: Optional[str] # workflow json
origin: str | None
destination: str | None
retried_from_item_id: int | None = None
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
@@ -493,16 +502,16 @@ def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new
session.id = uuid_string()
values_to_insert.append(
SessionQueueValueToInsert(
queue_id, # queue_id
session.model_dump_json(warnings=False, exclude_none=True), # session (json)
session.id, # session_id
batch.batch_id, # batch_id
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
json.dumps(field_values, default=to_jsonable_python) if field_values else None, # field_values (json)
priority, # priority
json.dumps(workflow, default=to_jsonable_python) if workflow else None, # workflow (json)
batch.origin, # origin
batch.destination, # destination
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,
)
)
return values_to_insert

View File

@@ -1,7 +1,10 @@
import json
import sqlite3
import threading
from typing import Optional, Union, cast
from pydantic_core import to_jsonable_python
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase
from invokeai.app.services.session_queue.session_queue_common import (
@@ -18,11 +21,13 @@ from invokeai.app.services.session_queue.session_queue_common import (
IsEmptyResult,
IsFullResult,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
SessionQueueItem,
SessionQueueItemDTO,
SessionQueueItemNotFoundError,
SessionQueueStatus,
SessionQueueValueToInsert,
calc_session_count,
prepare_values_to_insert,
)
@@ -130,8 +135,8 @@ class SqliteSessionQueue(SessionQueueBase):
self.__cursor.executemany(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values_to_insert,
)
@@ -761,3 +766,71 @@ class SqliteSessionQueue(SessionQueueBase):
canceled=counts.get("canceled", 0),
total=total,
)
def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
"""Retries the given queue items"""
try:
self.__lock.acquire()
values_to_insert: list[SessionQueueValueToInsert] = []
retried_item_ids: list[int] = []
for item_id in item_ids:
queue_item = self.get_queue_item(item_id)
if queue_item.status not in ("failed", "canceled"):
continue
retried_item_ids.append(item_id)
field_values_json = (
json.dumps(queue_item.field_values, default=to_jsonable_python) if queue_item.field_values else None
)
workflow_json = (
json.dumps(queue_item.workflow, default=to_jsonable_python) if queue_item.workflow else None
)
cloned_session = GraphExecutionState(graph=queue_item.session.graph)
cloned_session_json = cloned_session.model_dump_json(warnings=False, exclude_none=True)
retried_from_item_id = (
queue_item.retried_from_item_id
if queue_item.retried_from_item_id is not None
else queue_item.item_id
)
value_to_insert = 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,
)
values_to_insert.append(value_to_insert)
# TODO(psyche): Handle max queue size?
self.__cursor.executemany(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values_to_insert,
)
self.__conn.commit()
except Exception:
self.__conn.rollback()
raise
finally:
self.__lock.release()
retry_result = RetryItemsResult(
queue_id=queue_id,
retried_item_ids=retried_item_ids,
)
self.__invoker.services.events.emit_queue_items_retried(retry_result)
return retry_result

View File

@@ -18,6 +18,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_12 import
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_13 import build_migration_13
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_14 import build_migration_14
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_15 import build_migration_15
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_16 import build_migration_16
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@@ -53,6 +54,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_13())
migrator.register_migration(build_migration_14())
migrator.register_migration(build_migration_15())
migrator.register_migration(build_migration_16())
migrator.run_migrations()
return db

View File

@@ -0,0 +1,31 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration16Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_retried_from_item_id_col(cursor)
def _add_retried_from_item_id_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `retried_from_item_id` column to the session queue table.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN retried_from_item_id INTEGER;")
def build_migration_16() -> Migration:
"""
Build the migration from database version 15 to 16.
This migration does the following:
- Adds `retried_from_item_id` column to the session queue table.
"""
migration_16 = Migration(
from_version=15,
to_version=16,
callback=Migration16Callback(),
)
return migration_16

View File

@@ -11,9 +11,11 @@
<link id="invoke-favicon" rel="icon" type="icon" href="assets/images/invoke-favicon.svg" />
<style>
html,
body {
body,
#root {
padding: 0;
margin: 0;
overflow: hidden;
}
</style>
</head>
@@ -23,4 +25,4 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@@ -58,10 +58,11 @@
"@dagrejs/dagre": "^1.1.4",
"@dagrejs/graphlib": "^2.2.4",
"@fontsource-variable/inter": "^5.1.0",
"@invoke-ai/ui-library": "^0.0.44",
"@invoke-ai/ui-library": "^0.0.46",
"@nanostores/react": "^0.7.3",
"@reduxjs/toolkit": "2.2.3",
"@reduxjs/toolkit": "2.5.1",
"@roarr/browser-log-writer": "^1.3.0",
"@xyflow/react": "^12.4.2",
"async-mutex": "^0.5.0",
"chakra-react-select": "^4.9.2",
"cmdk": "^1.0.0",
@@ -96,9 +97,9 @@
"react-icons": "^5.3.0",
"react-redux": "9.1.2",
"react-resizable-panels": "^2.1.4",
"react-textarea-autosize": "^8.5.7",
"react-use": "^17.5.1",
"react-virtuoso": "^4.10.4",
"reactflow": "^11.11.4",
"redux-dynamic-middlewares": "^2.2.0",
"redux-remember": "^5.1.0",
"redux-undo": "^1.1.0",
@@ -126,7 +127,7 @@
"@storybook/addon-storysource": "^8.3.4",
"@storybook/manager-api": "^8.3.4",
"@storybook/react": "^8.3.4",
"@storybook/react-vite": "^8.3.4",
"@storybook/react-vite": "^8.5.5",
"@storybook/theming": "^8.3.4",
"@types/dateformat": "^5.0.2",
"@types/lodash-es": "^4.17.12",
@@ -134,9 +135,9 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react-swc": "^3.7.1",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.5",
"concurrently": "^8.2.2",
"csstype": "^3.1.3",
"dpdm": "^3.14.0",
@@ -152,11 +153,11 @@
"tsafe": "^1.7.5",
"type-fest": "^4.26.1",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite": "^6.1.0",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vite-plugin-dts": "^3.9.1",
"vite-plugin-dts": "^4.5.0",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.3.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
},
"engines": {

File diff suppressed because it is too large Load Diff

View File

@@ -187,7 +187,10 @@
"values": "Values",
"resetToDefaults": "Reset to Defaults",
"seed": "Seed",
"combinatorial": "Combinatorial"
"combinatorial": "Combinatorial",
"layout": "Layout",
"row": "Row",
"column": "Column"
},
"hrf": {
"hrf": "High Resolution Fix",
@@ -225,6 +228,8 @@
"cancelTooltip": "Cancel Current Item",
"cancelSucceeded": "Item Canceled",
"cancelFailed": "Problem Canceling Item",
"retrySucceeded": "Item Retried",
"retryFailed": "Problem Retrying Item",
"confirm": "Confirm",
"prune": "Prune",
"pruneTooltip": "Prune {{item_count}} Completed Items",
@@ -236,6 +241,7 @@
"clearFailed": "Problem Clearing Queue",
"cancelBatch": "Cancel Batch",
"cancelItem": "Cancel Item",
"retryItem": "Retry Item",
"cancelBatchSucceeded": "Batch Canceled",
"cancelBatchFailed": "Problem Canceling Batch",
"clearQueueAlertDialog": "Clearing the queue immediately cancels any processing items and clears the queue entirely. Pending filters will be canceled.",
@@ -931,6 +937,7 @@
"noWorkflows": "No Workflows",
"noMatchingWorkflows": "No Matching Workflows",
"noWorkflow": "No Workflow",
"unableToUpdateNode": "Node update failed: node {{node}} of type {{type}} (may require deleting and recreating)",
"mismatchedVersion": "Invalid node: node {{node}} of type {{type}} has mismatched version (try updating?)",
"missingTemplate": "Invalid node: node {{node}} of type {{type}} missing template (not installed?)",
"sourceNodeDoesNotExist": "Invalid edge: source/output node {{node}} does not exist",
@@ -938,6 +945,7 @@
"sourceNodeFieldDoesNotExist": "Invalid edge: source/output field {{node}}.{{field}} does not exist",
"targetNodeFieldDoesNotExist": "Invalid edge: target/input field {{node}}.{{field}} does not exist",
"deletedInvalidEdge": "Deleted invalid edge {{source}} -> {{target}}",
"deletedMissingNodeFieldFormElement": "Deleted missing form field: node {{nodeId}} field {{fieldName}}",
"noConnectionInProgress": "No connection in progress",
"node": "Node",
"nodeOutputs": "Node Outputs",
@@ -952,6 +960,7 @@
"nodeVersion": "Node Version",
"noOutputRecorded": "No outputs recorded",
"notes": "Notes",
"description": "Description",
"notesDescription": "Add notes about your workflow",
"problemSettingTitle": "Problem Setting Title",
"resetToDefaultValue": "Reset to default value",
@@ -1694,7 +1703,26 @@
"download": "Download",
"copyShareLink": "Copy Share Link",
"copyShareLinkForWorkflow": "Copy Share Link for Workflow",
"delete": "Delete"
"delete": "Delete",
"builder": {
"builder": "Builder",
"layout": "Layout",
"row": "Row",
"column": "Column",
"label": "Label",
"description": "Description",
"component": "Component",
"numberInput": "Number Input",
"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."
}
},
"controlLayers": {
"regional": "Regional",

View File

@@ -301,7 +301,9 @@
"hfTokenHelperText": "Un token HF est requis pour utiliser certains modèles. Cliquez ici pour créer ou obtenir votre token.",
"hfTokenInvalid": "Token HF invalide ou manquant",
"hfForbidden": "Vous n'avez pas accès à ce modèle HF.",
"hfTokenInvalidErrorMessage2": "Mettre à jour dans le "
"hfTokenInvalidErrorMessage2": "Mettre à jour dans le ",
"controlLora": "Controle LoRA",
"urlUnauthorizedErrorMessage2": "Découvrir comment ici."
},
"parameters": {
"images": "Images",
@@ -332,7 +334,7 @@
"showOptionsPanel": "Afficher le panneau latéral (O ou T)",
"invoke": {
"noPrompts": "Aucun prompts généré",
"missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} entrée manquante",
"missingInputForField": "entrée manquante",
"missingFieldTemplate": "Modèle de champ manquant",
"invoke": "Invoke",
"addingImagesTo": "Ajouter des images à",
@@ -353,7 +355,9 @@
"canvasIsCompositing": "La toile est en train de composer",
"collectionTooFewItems": "{{nodeLabel}} -> {{fieldLabel}} : trop peu d'éléments, minimum {{minItems}}",
"collectionTooManyItems": "{{nodeLabel}} -> {{fieldLabel}} : trop d'éléments, maximum {{maxItems}}",
"canvasIsSelectingObject": "La toile est occupée (sélection d'objet)"
"canvasIsSelectingObject": "La toile est occupée (sélection d'objet)",
"emptyBatches": "lots vides",
"batchNodeNotConnected": "Noeud de lots non connecté : {{label}}"
},
"negativePromptPlaceholder": "Prompt Négatif",
"positivePromptPlaceholder": "Prompt Positif",
@@ -1630,7 +1634,26 @@
"boardAccessError": "Impossible de trouver la planche {{board_id}}, réinitialisation à la valeur par défaut",
"workflowHelpText": "Besoin d'aide? Consultez notre guide sur <LinkComponent>Comment commencer avec les Workflows</LinkComponent>.",
"noWorkflows": "Aucun Workflows",
"noMatchingWorkflows": "Aucun Workflows correspondant"
"noMatchingWorkflows": "Aucun Workflows correspondant",
"arithmeticSequence": "Séquence Arithmétique",
"uniformRandomDistribution": "Distribution Aléatoire Uniforme",
"noBatchGroup": "aucun groupe",
"generatorLoading": "chargement",
"generatorLoadFromFile": "Charger depuis un Fichier",
"dynamicPromptsRandom": "Prompts Dynamiques (Aléatoire)",
"integerRangeGenerator": "Générateur d'interval d'entiers",
"generateValues": "Générer Valeurs",
"linearDistribution": "Distribution Linéaire",
"floatRangeGenerator": "Générateur d'interval de nombres décimaux",
"generatorNRandomValues_one": "{{count}} valeur aléatoire",
"generatorNRandomValues_many": "{{count}} valeurs aléatoires",
"generatorNRandomValues_other": "{{count}} valeurs aléatoires",
"dynamicPromptsCombinatorial": "Prompts Dynamiques (Combinatoire)",
"parseString": "Analyser la chaine de charactères",
"internalDesc": "Cette invocation est utilisée internalement par Invoke. En fonction des mises à jours il est possible que des changements y soit effectués ou qu'elle soit supprimé sans prévention.",
"splitOn": "Diviser sur",
"generatorNoValues": "vide",
"addItem": "Ajouter un élément"
},
"models": {
"noMatchingModels": "Aucun modèle correspondant",

View File

@@ -172,7 +172,8 @@
"imagesTab": "Immagini create e salvate in Invoke.",
"assetsTab": "File che hai caricato per usarli nei tuoi progetti.",
"boardsSettings": "Impostazioni Bacheche",
"imagesSettings": "Impostazioni Immagini Galleria"
"imagesSettings": "Impostazioni Immagini Galleria",
"assets": "Risorse"
},
"hotkeys": {
"searchHotkeys": "Cerca tasti di scelta rapida",
@@ -832,7 +833,12 @@
"uploadFailedInvalidUploadDesc_withCount_one": "Devi caricare al massimo 1 immagine PNG o JPEG.",
"uploadFailedInvalidUploadDesc_withCount_many": "Devi caricare al massimo {{count}} immagini PNG o JPEG.",
"uploadFailedInvalidUploadDesc_withCount_other": "Devi caricare al massimo {{count}} immagini PNG o JPEG.",
"outOfMemoryErrorDescLocal": "Segui la nostra <LinkComponent>guida per bassa VRAM</LinkComponent> per ridurre gli OOM."
"outOfMemoryErrorDescLocal": "Segui la nostra <LinkComponent>guida per bassa VRAM</LinkComponent> per ridurre gli OOM.",
"pasteFailed": "Incolla non riuscita",
"pasteSuccess": "Incollato su {{destination}}",
"unableToCopy": "Impossibile copiare",
"unableToCopyDesc": "Il tuo browser non supporta l'accesso agli appunti. Gli utenti di Firefox potrebbero risolvere il problema seguendo ",
"unableToCopyDesc_theseSteps": "questi passaggi"
},
"accessibility": {
"invokeProgressBar": "Barra di avanzamento generazione",
@@ -2092,7 +2098,10 @@
"saveCanvasToGallery": "Salva la Tela nella Galleria",
"saveToGalleryGroup": "Salva nella Galleria",
"newInpaintMask": "Nuova maschera Inpaint",
"newRegionalGuidance": "Nuova Guida Regionale"
"newRegionalGuidance": "Nuova Guida Regionale",
"copyToClipboard": "Copia negli appunti",
"copyCanvasToClipboard": "Copia la tela negli appunti",
"copyBboxToClipboard": "Copia il riquadro di delimitazione negli appunti"
},
"newImg2ImgCanvasFromImage": "Nuova Immagine da immagine",
"copyRasterLayerTo": "Copia $t(controlLayers.rasterLayer) in",
@@ -2155,7 +2164,17 @@
"ipAdapterIncompatibleBaseModel": "modello base dell'immagine di riferimento incompatibile",
"ipAdapterNoImageSelected": "nessuna immagine di riferimento selezionata",
"rgAutoNegativeNotSupported": "Auto-Negativo non supportato per il modello base selezionato"
}
},
"pasteTo": "Incolla su",
"pasteToBboxDesc": "Nuovo livello (nel riquadro di delimitazione)",
"pasteToAssets": "Risorse",
"copyRegionError": "Errore durante la copia di {{region}}",
"pasteToAssetsDesc": "Incolla in Risorse",
"pasteToBbox": "Riquadro di delimitazione",
"pasteToCanvas": "Tela",
"pasteToCanvasDesc": "Nuovo livello (nella Tela)",
"pastedTo": "Incollato su {{destination}}",
"regionCopiedToClipboard": "{{region}} Copiato negli appunti"
},
"ui": {
"tabs": {
@@ -2254,11 +2273,12 @@
"watchRecentReleaseVideos": "Guarda i video su questa versione",
"watchUiUpdatesOverview": "Guarda le novità dell'interfaccia",
"items": [
"Modalità Bassa-VRAM",
"Gestione dinamica della memoria",
"Tempi di caricamento del modello più rapidi",
"Meno errori di memoria",
"Funzionalità lotto del flusso di lavoro ampliate"
"Impostazioni predefinite VRAM migliorate",
"Cancellazione della cache del modello su richiesta",
"Compatibilità estesa FLUX LoRA",
"Filtro Regola Immagine su Tela",
"Annulla tutto tranne l'elemento della coda corrente",
"Copia da e incolla sulla Tela"
]
},
"system": {

View File

@@ -117,7 +117,8 @@
"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 Thư Viện Ảnh",
"assets": "Tài Nguyên"
},
"common": {
"ipAdapter": "IP Adapter",
@@ -303,7 +304,11 @@
"completedIn": "Hoàn tất trong",
"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ô"
"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.",
"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 Ý"
},
"hotkeys": {
"canvas": {
@@ -1797,7 +1802,10 @@
"newControlLayer": "Layer Điều Khiển Được Mới",
"newRasterLayer": "Layer Dạng Raster Mới",
"bboxGroup": "Được Tạo Từ Hộp Giới Hạn",
"canvasGroup": "Canvas"
"canvasGroup": "Canvas",
"copyCanvasToClipboard": "Sao Chép Canvas Vào Clipboard",
"copyToClipboard": "Sao Chép Vào Clipboard",
"copyBboxToClipboard": "Sao Chép Hộp Giới Hạn Vào Clipboard"
},
"stagingArea": {
"saveToGallery": "Lưu Vào Thư Viện",
@@ -1914,6 +1922,30 @@
"gaussian_type": "Gaussian",
"noise_color": "Màu Nhiễu",
"size": "Cỡ Nhiễu"
},
"adjust_image": {
"channel": "Kênh Màu",
"cyan": "Lục Lam (Cmyk)",
"value_setting": "Giá Trị",
"scale_values": "Giá Trị Theo Tỉ Lệ",
"red": "Đỏ (Rgba)",
"green": "Lục (rGba)",
"blue": "Lam (rgBa)",
"alpha": "Độ Trong Suốt (rgbA)",
"luminosity": "Độ Sáng (Lab)",
"magenta": "Hồng Đỏ (cMyk)",
"yellow": "Vàng (cmYk)",
"description": "Điều chỉnh kênh màu được chọn của ảnh.",
"black": "Đen (cmyK)",
"cr": "Cr (ycC)",
"label": "Điều Chỉnh Ảnh",
"value": "Độ Sáng (hsV)",
"saturation": "Độ Bão Hoà (hSv)",
"hue": "Vùng Màu (Hsv)",
"a": "A (lAb)",
"b": "B (laB)",
"y": "Y (Ycc)",
"cb": "Cb (yCc)"
}
},
"transform": {
@@ -1992,6 +2024,20 @@
"rgReferenceImagesNotSupported": "Ảnh Mẫu Khu Vực không được hỗ trợ cho model cơ sở được chọn",
"rgAutoNegativeNotSupported": "Tự Động Đảo Chiều không được hỗ trợ cho model cơ sở được chọn",
"rgNoRegion": "không có khu vực được vẽ"
},
"pasteTo": "Dán Vào",
"pasteToAssets": "Tài Nguyên",
"pasteToAssetsDesc": "Dán Vào Tài Nguyên",
"pasteToBbox": "Hộp Giới Hạn",
"pasteToBboxDesc": "Layer Mới (Trong Hộp Giới Hạn)",
"pasteToCanvas": "Canvas",
"pasteToCanvasDesc": "Layer Mới (Trong Canvas)",
"pastedTo": "Dán Vào {{destination}}",
"regionCopiedToClipboard": "Sao Chép {{region}} Vào Clipboard",
"copyRegionError": "Lỗi khi sao chép {{region}}",
"errors": {
"unableToLoadImage": "Không Thể Tải Hình Ảnh",
"unableToFindImage": "Không Thể Tìm Hình Ảnh"
}
},
"stylePresets": {
@@ -2123,7 +2169,12 @@
"problemDownloadingImage": "Không Thể Tải Xuống Ảnh",
"problemCopyingLayer": "Không Thể Sao Chép Layer",
"problemSavingLayer": "Không Thể Lưu Layer",
"outOfMemoryErrorDescLocal": "Làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi để hạn chế OOM (Tràn bộ nhớ)."
"outOfMemoryErrorDescLocal": "Làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi để hạn chế OOM (Tràn bộ nhớ).",
"unableToCopy": "Không Thể Sao Chép",
"unableToCopyDesc_theseSteps": "các bước sau",
"unableToCopyDesc": "Trình duyệt của bạn không hỗ trợ tính năng clipboard. Người dùng Firefox có thể khắc phục theo ",
"pasteSuccess": "Dán Vào {{destination}}",
"pasteFailed": "Dán Thất Bại"
},
"ui": {
"tabs": {
@@ -2218,11 +2269,12 @@
"watchRecentReleaseVideos": "Xem Video Phát Hành Mới Nhất",
"watchUiUpdatesOverview": "Xem Tổng Quan Về Những Cập Nhật Cho Giao Diện Người Dùng",
"items": [
"Chế độ VRAM thấp",
"Trình quản lý bộ nhớ động",
"Tải model nhanh hơn",
"Ít lỗi bộ nhớ hơn",
"Mở rộng khả năng xử lý hàng loạt workflow"
"Cải thiện các thiết lập mặc định của VRAM",
"Xoá bộ nhớ đệm của model theo yêu cầu",
"Mở rộng khả năng tương thích LoRA trên FLUX",
"Bộ lọc điều chỉnh ảnh trên Canvas",
"Huỷ tất cả trừ mục đang xếp hàng hiện tại",
"Sao chép và dán trên Canvas"
]
},
"upsell": {

View File

@@ -27,6 +27,7 @@ import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterM
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal';
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { useReadinessWatcher } from 'features/queue/store/readiness';
import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog';
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
@@ -53,49 +54,20 @@ interface Props {
}
const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
const language = useAppSelector(selectLanguage);
const logger = useLogger('system');
const dispatch = useAppDispatch();
const clearStorage = useClearStorage();
// singleton!
useSocketIO();
useGlobalModifiersInit();
useGlobalHotkeys();
useGetOpenAPISchemaQuery();
useSyncLoggingConfig();
const handleReset = useCallback(() => {
clearStorage();
location.reload();
return false;
}, [clearStorage]);
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
useEffect(() => {
if (size(config)) {
logger.info({ config }, 'Received config');
dispatch(configChanged(config));
}
}, [dispatch, config, logger]);
useEffect(() => {
dispatch(appStarted());
}, [dispatch]);
useStudioInitAction(studioInitAction);
useStarterModelsToast();
useSyncQueueStatus();
useFocusRegionWatcher();
return (
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
<AppContent />
</Box>
<HookIsolator config={config} studioInitAction={studioInitAction} />
<DeleteImageModal />
<ChangeBoardModal />
<DynamicPromptsModal />
@@ -122,3 +94,43 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
};
export default memo(App);
// Running these hooks in a separate component ensures we do not inadvertently rerender the entire app when they change.
const HookIsolator = memo(
({ config, studioInitAction }: { config: PartialAppConfig; studioInitAction?: StudioInitAction }) => {
const language = useAppSelector(selectLanguage);
const logger = useLogger('system');
const dispatch = useAppDispatch();
// singleton!
useReadinessWatcher();
useSocketIO();
useGlobalModifiersInit();
useGlobalHotkeys();
useGetOpenAPISchemaQuery();
useSyncLoggingConfig();
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
useEffect(() => {
if (size(config)) {
logger.info({ config }, 'Received config');
dispatch(configChanged(config));
}
}, [dispatch, config, logger]);
useEffect(() => {
dispatch(appStarted());
}, [dispatch]);
useStudioInitAction(studioInitAction);
useStarterModelsToast();
useSyncQueueStatus();
useFocusRegionWatcher();
return null;
}
);
HookIsolator.displayName = 'HookIsolator';

View File

@@ -1,5 +1,6 @@
import '@fontsource-variable/inter';
import 'overlayscrollbars/overlayscrollbars.css';
import '@xyflow/react/dist/base.css';
import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
import type { ReactNode } from 'react';

View File

@@ -1,6 +1,4 @@
import { createDraftSafeSelectorCreator, createSelectorCreator, lruMemoize } from '@reduxjs/toolkit';
import type { GetSelectorsOptions } from '@reduxjs/toolkit/dist/entities/state_selectors';
import type { RootState } from 'app/store/store';
import { isEqual } from 'lodash-es';
/**
@@ -14,11 +12,9 @@ export const createMemoizedSelector = createSelectorCreator({
argsMemoize: lruMemoize,
});
export const getSelectorsOptions: GetSelectorsOptions = {
export const getSelectorsOptions = {
createSelector: createDraftSafeSelectorCreator({
memoize: lruMemoize,
argsMemoize: lruMemoize,
}),
};
export const createMemoizedAppSelector = createMemoizedSelector.withTypes<RootState>();

View File

@@ -8,12 +8,13 @@ import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field';
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { forEach, intersectionBy } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
const log = logger('gallery');
@@ -21,6 +22,7 @@ const log = logger('gallery');
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const actions: Param0<typeof dispatch>[] = [];
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
return;
@@ -28,16 +30,28 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
forEach(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
dispatch(
actions.push(
fieldImageValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: undefined,
})
);
return;
}
if (isImageFieldCollectionInputInstance(input)) {
actions.push(
fieldImageCollectionValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
})
);
}
});
});
actions.forEach(dispatch);
};
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {

View File

@@ -1,6 +1,6 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState';
import { $nodeExecutionStates } from 'features/nodes/hooks/useNodeExecutionState';
import { workflowLoaded, workflowLoadRequested } from 'features/nodes/store/actions';
import { $templates } from 'features/nodes/store/nodesSlice';
import { $needsFit } from 'features/nodes/store/reactFlowInstance';

View File

@@ -26,7 +26,8 @@ export type AppFeature =
| 'modelCache'
| 'bulkDownload'
| 'starterModels'
| 'hfToken';
| 'hfToken'
| 'retryQueueItem';
/**
* A disable-able Stable Diffusion feature
*/

View File

@@ -1,5 +1,6 @@
import type { As, ChakraProps, FlexProps } from '@invoke-ai/ui-library';
import type { ChakraProps, FlexProps } from '@invoke-ai/ui-library';
import { Flex, Icon, Skeleton, Spinner, Text } from '@invoke-ai/ui-library';
import type { ElementType } from 'react';
import { memo, useMemo } from 'react';
import { PiImageBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
@@ -28,7 +29,7 @@ IAILoadingImageFallback.displayName = 'IAILoadingImageFallback';
type IAINoImageFallbackProps = FlexProps & {
label?: string;
icon?: As | null;
icon?: ElementType | null;
boxSize?: ChakraProps['boxSize'];
};

View File

@@ -1,39 +0,0 @@
import { Box } from '@invoke-ai/ui-library';
import { memo, useMemo } from 'react';
type Props = {
isSelected: boolean;
isHovered: boolean;
};
const SelectionOverlay = ({ isSelected, isHovered }: Props) => {
const shadow = useMemo(() => {
if (isSelected && isHovered) {
return 'nodeHoveredSelected';
}
if (isSelected) {
return 'nodeSelected';
}
if (isHovered) {
return 'nodeHovered';
}
return undefined;
}, [isHovered, isSelected]);
return (
<Box
className="selection-box"
position="absolute"
top={0}
insetInlineEnd={0}
bottom={0}
insetInlineStart={0}
borderRadius="base"
opacity={isSelected || isHovered ? 1 : 0.5}
transitionProperty="common"
transitionDuration="0.1s"
pointerEvents="none"
shadow={shadow}
/>
);
};
export default memo(SelectionOverlay);

View File

@@ -1,238 +0,0 @@
import { useToken } from '@invoke-ai/ui-library';
export const useChakraThemeTokens = () => {
const [
base50,
base100,
base150,
base200,
base250,
base300,
base350,
base400,
base450,
base500,
base550,
base600,
base650,
base700,
base750,
base800,
base850,
base900,
base950,
accent50,
accent100,
accent150,
accent200,
accent250,
accent300,
accent350,
accent400,
accent450,
accent500,
accent550,
accent600,
accent650,
accent700,
accent750,
accent800,
accent850,
accent900,
accent950,
baseAlpha50,
baseAlpha100,
baseAlpha150,
baseAlpha200,
baseAlpha250,
baseAlpha300,
baseAlpha350,
baseAlpha400,
baseAlpha450,
baseAlpha500,
baseAlpha550,
baseAlpha600,
baseAlpha650,
baseAlpha700,
baseAlpha750,
baseAlpha800,
baseAlpha850,
baseAlpha900,
baseAlpha950,
accentAlpha50,
accentAlpha100,
accentAlpha150,
accentAlpha200,
accentAlpha250,
accentAlpha300,
accentAlpha350,
accentAlpha400,
accentAlpha450,
accentAlpha500,
accentAlpha550,
accentAlpha600,
accentAlpha650,
accentAlpha700,
accentAlpha750,
accentAlpha800,
accentAlpha850,
accentAlpha900,
accentAlpha950,
] = useToken('colors', [
'base.50',
'base.100',
'base.150',
'base.200',
'base.250',
'base.300',
'base.350',
'base.400',
'base.450',
'base.500',
'base.550',
'base.600',
'base.650',
'base.700',
'base.750',
'base.800',
'base.850',
'base.900',
'base.950',
'accent.50',
'accent.100',
'accent.150',
'accent.200',
'accent.250',
'accent.300',
'accent.350',
'accent.400',
'accent.450',
'accent.500',
'accent.550',
'accent.600',
'accent.650',
'accent.700',
'accent.750',
'accent.800',
'accent.850',
'accent.900',
'accent.950',
'baseAlpha.50',
'baseAlpha.100',
'baseAlpha.150',
'baseAlpha.200',
'baseAlpha.250',
'baseAlpha.300',
'baseAlpha.350',
'baseAlpha.400',
'baseAlpha.450',
'baseAlpha.500',
'baseAlpha.550',
'baseAlpha.600',
'baseAlpha.650',
'baseAlpha.700',
'baseAlpha.750',
'baseAlpha.800',
'baseAlpha.850',
'baseAlpha.900',
'baseAlpha.950',
'accentAlpha.50',
'accentAlpha.100',
'accentAlpha.150',
'accentAlpha.200',
'accentAlpha.250',
'accentAlpha.300',
'accentAlpha.350',
'accentAlpha.400',
'accentAlpha.450',
'accentAlpha.500',
'accentAlpha.550',
'accentAlpha.600',
'accentAlpha.650',
'accentAlpha.700',
'accentAlpha.750',
'accentAlpha.800',
'accentAlpha.850',
'accentAlpha.900',
'accentAlpha.950',
]);
return {
base50,
base100,
base150,
base200,
base250,
base300,
base350,
base400,
base450,
base500,
base550,
base600,
base650,
base700,
base750,
base800,
base850,
base900,
base950,
accent50,
accent100,
accent150,
accent200,
accent250,
accent300,
accent350,
accent400,
accent450,
accent500,
accent550,
accent600,
accent650,
accent700,
accent750,
accent800,
accent850,
accent900,
accent950,
baseAlpha50,
baseAlpha100,
baseAlpha150,
baseAlpha200,
baseAlpha250,
baseAlpha300,
baseAlpha350,
baseAlpha400,
baseAlpha450,
baseAlpha500,
baseAlpha550,
baseAlpha600,
baseAlpha650,
baseAlpha700,
baseAlpha750,
baseAlpha800,
baseAlpha850,
baseAlpha900,
baseAlpha950,
accentAlpha50,
accentAlpha100,
accentAlpha150,
accentAlpha200,
accentAlpha250,
accentAlpha300,
accentAlpha350,
accentAlpha400,
accentAlpha450,
accentAlpha500,
accentAlpha550,
accentAlpha600,
accentAlpha650,
accentAlpha700,
accentAlpha750,
accentAlpha800,
accentAlpha850,
accentAlpha900,
accentAlpha950,
};
};

View File

@@ -0,0 +1,72 @@
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
import { useCallback, useEffect, useState } from 'react';
type UseEditableArg = {
value: string;
defaultValue: string;
onChange: (value: string) => void;
onStartEditing?: () => void;
inputRef?: RefObject<HTMLInputElement | HTMLTextAreaElement>;
};
export const useEditable = ({ value, defaultValue, onChange: _onChange, onStartEditing, inputRef }: UseEditableArg) => {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
const onBlur = useCallback(() => {
const trimmedValue = localValue.trim();
const newValue = trimmedValue || defaultValue;
setLocalValue(newValue);
if (newValue !== value) {
_onChange(newValue);
}
setIsEditing(false);
inputRef?.current?.setSelectionRange(0, 0);
}, [localValue, defaultValue, value, inputRef, _onChange]);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setLocalValue(e.target.value);
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
onBlur();
} else if (e.key === 'Escape') {
setLocalValue(value);
_onChange(value);
setIsEditing(false);
}
},
[_onChange, onBlur, value]
);
const startEditing = useCallback(() => {
setIsEditing(true);
onStartEditing?.();
}, [onStartEditing]);
useEffect(() => {
// Another component may change the title; sync local title with global state
setLocalValue(value);
}, [value]);
useEffect(() => {
if (isEditing) {
inputRef?.current?.focus();
inputRef?.current?.select();
}
}, [inputRef, isEditing]);
return {
isEditing,
startEditing,
value: localValue,
inputProps: {
value: localValue,
onChange,
onKeyDown,
onBlur,
},
};
};

View File

@@ -1,5 +1,5 @@
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { createMemoizedAppSelector } from 'app/store/createMemoizedSelector';
import { createSelector } from '@reduxjs/toolkit';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
@@ -34,7 +34,7 @@ import type {
} from 'services/api/types';
const buildSelectControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) =>
createMemoizedAppSelector(selectCanvasSlice, (canvas) => {
createSelector(selectCanvasSlice, (canvas) => {
const layer = selectEntityOrThrow(canvas, entityIdentifier, 'ControlLayerControlAdapter');
return layer.controlAdapter;
});

View File

@@ -1,67 +1,43 @@
import { Input } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { useEditable } from 'common/hooks/useEditable';
import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityTitle } from 'features/controlLayers/hooks/useEntityTitle';
import { useEntityName, useEntityTypeName } from 'features/controlLayers/hooks/useEntityTitle';
import { entityNameChanged } from 'features/controlLayers/store/canvasSlice';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useRef } from 'react';
export const CanvasEntityEditableTitle = memo(() => {
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const title = useEntityTitle(entityIdentifier);
const isEditing = useBoolean(false);
const [localTitle, setLocalTitle] = useState(title);
const ref = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const name = useEntityName(entityIdentifier);
const typeName = useEntityTypeName(entityIdentifier.type);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setLocalTitle(e.target.value);
}, []);
const onBlur = useCallback(() => {
const trimmedTitle = localTitle.trim();
if (trimmedTitle.length === 0) {
dispatch(entityNameChanged({ entityIdentifier, name: null }));
} else if (trimmedTitle !== title) {
dispatch(entityNameChanged({ entityIdentifier, name: trimmedTitle }));
}
isEditing.setFalse();
}, [dispatch, entityIdentifier, isEditing, localTitle, title]);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onBlur();
} else if (e.key === 'Escape') {
setLocalTitle(title);
isEditing.setFalse();
}
const onChange = useCallback(
(name: string) => {
dispatch(entityNameChanged({ entityIdentifier, name }));
},
[isEditing, onBlur, title]
[dispatch, entityIdentifier]
);
useEffect(() => {
if (isEditing.isTrue) {
ref.current?.focus();
ref.current?.select();
}
}, [isEditing.isTrue]);
const editable = useEditable({
value: name || typeName,
defaultValue: typeName,
onChange,
inputRef,
});
if (!isEditing.isTrue) {
return <CanvasEntityTitle cursor="text" onDoubleClick={isEditing.setTrue} />;
if (!editable.isEditing) {
return <CanvasEntityTitle cursor="text" onDoubleClick={editable.startEditing} />;
}
return (
<Input
ref={ref}
value={localTitle}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
ref={inputRef}
{...editable.inputProps}
variant="outline"
_focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }}
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
/>
);
});

View File

@@ -15,17 +15,17 @@ const createSelectName = (entityIdentifier: CanvasEntityIdentifier) =>
return entity.name;
});
export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
const { t } = useTranslation();
export const useEntityName = (entityIdentifier: CanvasEntityIdentifier) => {
const selectName = useMemo(() => createSelectName(entityIdentifier), [entityIdentifier]);
const name = useAppSelector(selectName);
return name;
};
const title = useMemo(() => {
if (name) {
return name;
}
export const useEntityTypeName = (type: CanvasEntityIdentifier['type']) => {
const { t } = useTranslation();
switch (entityIdentifier.type) {
const typeName = useMemo(() => {
switch (type) {
case 'inpaint_mask':
return t('controlLayers.inpaintMask');
case 'control_layer':
@@ -39,7 +39,15 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
default:
assert(false, 'Unexpected entity type');
}
}, [entityIdentifier.type, name, t]);
}, [type, t]);
return typeName;
};
export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
const name = useEntityName(entityIdentifier);
const typeName = useEntityTypeName(entityIdentifier.type);
const title = useMemo(() => name || typeName, [name, typeName]);
return title;
};

View File

@@ -59,11 +59,11 @@ export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase<
this.syncOpacity();
}
if (!prevState || this.state.fill !== prevState.fill) {
// On first render, we must force the update
this.renderer.updateCompositingRectFill(!prevState);
// On first render, or when the fill changes, we must force the update
this.renderer.updateCompositingRectFill(true);
}
if (!prevState) {
// On first render, we must force the updates
if (!prevState || this.state.objects !== prevState.objects) {
// On first render, or when the objects change, we must force the update
this.renderer.updateCompositingRectSize(true);
this.renderer.updateCompositingRectPosition(true);
}

View File

@@ -59,11 +59,11 @@ export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase
this.syncOpacity();
}
if (!prevState || this.state.fill !== prevState.fill) {
// On first render, we must force the update
this.renderer.updateCompositingRectFill(!prevState);
// On first render, or when the fill changes, we must force the update
this.renderer.updateCompositingRectFill(true);
}
if (!prevState) {
// On first render, we must force the updates
if (!prevState || this.state.objects !== prevState.objects) {
// On first render, or when the objects change, we must force the update
this.renderer.updateCompositingRectSize(true);
this.renderer.updateCompositingRectPosition(true);
}

View File

@@ -284,8 +284,8 @@ export class CanvasEntityFilterer extends CanvasModuleBase {
this.log.error({ error: serializeError(filterResult.error) }, 'Error filtering');
this.$isProcessing.set(false);
// Clean up the abort controller as needed
if (!this.abortController.signal.aborted) {
this.abortController.abort();
if (!controller.signal.aborted) {
controller.abort();
}
this.abortController = null;
return;
@@ -324,8 +324,8 @@ export class CanvasEntityFilterer extends CanvasModuleBase {
this.$isProcessing.set(false);
// Clean up the abort controller as needed
if (!this.abortController.signal.aborted) {
this.abortController.abort();
if (!controller.signal.aborted) {
controller.abort();
}
this.abortController = null;

View File

@@ -277,8 +277,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
let points: number[];
let isShiftDraw = false;
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
isShiftDraw = true;
points = [
lastLinePoint.x,
lastLinePoint.y,
@@ -298,15 +301,18 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
points,
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.parent.getClip(selectedEntity.state),
// When shift is held, the line may extend beyond the clip region. No clip for these lines.
clip: isShiftDraw ? null : this.parent.getClip(selectedEntity.state),
});
} else {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
let points: number[];
let isShiftDraw = false;
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
isShiftDraw = true;
points = [lastLinePoint.x, lastLinePoint.y, alignedPoint.x, alignedPoint.y];
} else {
// Create a new line with the current point
@@ -319,7 +325,8 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
points,
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.parent.getClip(selectedEntity.state),
// When shift is held, the line may extend beyond the clip region. No clip for these lines.
clip: isShiftDraw ? null : this.parent.getClip(selectedEntity.state),
});
}
};

View File

@@ -3,7 +3,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
import { getColorAtCoordinate, getPrefixedId } from 'features/controlLayers/konva/util';
import type { RgbColor } from 'features/controlLayers/store/types';
import type { RgbaColor } from 'features/controlLayers/store/types';
import { RGBA_BLACK } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
@@ -52,6 +52,39 @@ type CanvasColorPickerToolModuleConfig = {
* The color of the crosshair line borders.
*/
CROSSHAIR_BORDER_COLOR: string;
/**
* The color of the RGBA value text.
*/
TEXT_COLOR: string;
/**
* The padding of the RGBA value text within the background rect.
*/
TEXT_PADDING: number;
/**
* The font size of the RGBA value text.
*/
TEXT_FONT_SIZE: number;
/**
* The color of the RGBA value text background rect.
*/
TEXT_BG_COLOR: string;
/**
* The width of the RGBA value text background rect.
*/
TEXT_BG_WIDTH: number;
/**
* The height of the RGBA value text background rect.
*/
TEXT_BG_HEIGHT: number;
/**
* The corner radius of the RGBA value text background rect.
*/
TEXT_BG_CORNER_RADIUS: number;
/**
* The x offset of the RGBA value text background rect from the color picker ring.
*/
TEXT_BG_X_OFFSET: number;
};
const DEFAULT_CONFIG: CanvasColorPickerToolModuleConfig = {
@@ -65,6 +98,14 @@ const DEFAULT_CONFIG: CanvasColorPickerToolModuleConfig = {
CROSSHAIR_LINE_LENGTH: 10,
CROSSHAIR_LINE_COLOR: 'rgba(0,0,0,1)',
CROSSHAIR_BORDER_COLOR: 'rgba(255,255,255,0.8)',
TEXT_COLOR: 'rgba(255,255,255,1)',
TEXT_BG_COLOR: 'rgba(0,0,0,0.8)',
TEXT_BG_HEIGHT: 62,
TEXT_BG_WIDTH: 62,
TEXT_BG_CORNER_RADIUS: 7,
TEXT_PADDING: 8,
TEXT_FONT_SIZE: 12,
TEXT_BG_X_OFFSET: 7,
};
/**
@@ -83,7 +124,7 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
/**
* The color currently under the cursor. Only has a value when the color picker tool is active.
*/
$colorUnderCursor = atom<RgbColor>(RGBA_BLACK);
$colorUnderCursor = atom<RgbaColor>(RGBA_BLACK);
/**
* The Konva objects that make up the color picker tool preview:
@@ -105,6 +146,9 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
crosshairSouthOuter: Konva.Line;
crosshairWestInner: Konva.Line;
crosshairWestOuter: Konva.Line;
rgbaTextGroup: Konva.Group;
rgbaText: Konva.Text;
rgbaTextBackground: Konva.Rect;
};
constructor(parent: CanvasToolModule) {
@@ -202,8 +246,28 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
stroke: this.config.CROSSHAIR_BORDER_COLOR,
perfectDrawEnabled: false,
}),
rgbaTextGroup: new Konva.Group({
listening: false,
name: `${this.type}:color_picker_text_group`,
}),
rgbaText: new Konva.Text({
listening: false,
name: `${this.type}:color_picker_text`,
fill: this.config.TEXT_COLOR,
fontFamily: 'monospace',
align: 'left',
fontStyle: 'bold',
verticalAlign: 'middle',
}),
rgbaTextBackground: new Konva.Rect({
listening: false,
name: `${this.type}:color_picker_text_background`,
fill: this.config.TEXT_BG_COLOR,
}),
};
this.konva.rgbaTextGroup.add(this.konva.rgbaTextBackground, this.konva.rgbaText);
this.konva.group.add(
this.konva.ringCandidateColor,
this.konva.ringCurrentColor,
@@ -216,7 +280,8 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
this.konva.crosshairSouthOuter,
this.konva.crosshairSouthInner,
this.konva.crosshairWestOuter,
this.konva.crosshairWestInner
this.konva.crosshairWestInner,
this.konva.rgbaTextGroup
);
}
@@ -233,11 +298,6 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
return;
}
if (!this.parent.getCanDraw()) {
this.setVisibility(false);
return;
}
const cursorPos = this.parent.$cursorPos.get();
if (!cursorPos) {
@@ -283,6 +343,24 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
outerRadius: colorPickerOuterRadius + twoPixels,
});
const textBgWidth = this.manager.stage.unscale(this.config.TEXT_BG_WIDTH);
const textBgHeight = this.manager.stage.unscale(this.config.TEXT_BG_HEIGHT);
this.konva.rgbaTextBackground.setAttrs({
width: textBgWidth,
height: textBgHeight,
cornerRadius: this.manager.stage.unscale(this.config.TEXT_BG_CORNER_RADIUS),
});
this.konva.rgbaText.setAttrs({
padding: this.manager.stage.unscale(this.config.TEXT_PADDING),
fontSize: this.manager.stage.unscale(this.config.TEXT_FONT_SIZE),
text: `R: ${colorUnderCursor.r}\nG: ${colorUnderCursor.g}\nB: ${colorUnderCursor.b}\nA: ${colorUnderCursor.a}`,
});
this.konva.rgbaTextGroup.setAttrs({
x: x + this.manager.stage.unscale(this.config.RING_OUTER_RADIUS + this.config.TEXT_BG_X_OFFSET),
y: y - textBgHeight / 2,
});
const size = this.manager.stage.unscale(this.config.CROSSHAIR_LINE_LENGTH);
const space = this.manager.stage.unscale(this.config.CROSSHAIR_INNER_RADIUS);
const innerThickness = this.manager.stage.unscale(this.config.CROSSHAIR_LINE_THICKNESS);
@@ -329,11 +407,8 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
onStagePointerUp = (_e: KonvaEventObject<PointerEvent>) => {
const color = this.$colorUnderCursor.get();
if (color) {
const settings = this.manager.stateApi.getSettings();
// This will update the color but not the alpha value
this.manager.stateApi.setColor({ ...settings.color, ...color });
}
const settings = this.manager.stateApi.getSettings();
this.manager.stateApi.setColor({ ...settings.color, ...color });
};
onStagePointerMove = (_e: KonvaEventObject<PointerEvent>) => {
@@ -346,7 +421,11 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
return;
}
// Hide the background layer so we can get the color under the cursor without the grid interfering
this.manager.background.konva.layer.visible(false);
const color = getColorAtCoordinate(this.manager.stage.konva.stage, cursorPos.absolute);
this.manager.background.konva.layer.visible(true);
if (color) {
this.$colorUnderCursor.set(color);
}

View File

@@ -22,6 +22,7 @@ import type {
Coordinate,
Tool,
} from 'features/controlLayers/store/types';
import { isRenderableEntityType } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { atom } from 'nanostores';
@@ -177,24 +178,26 @@ export class CanvasToolModule extends CanvasModuleBase {
stage.setCursor('not-allowed');
} else if (tool === 'bbox') {
this.tools.bbox.syncCursorStyle();
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
stage.setCursor('not-allowed');
} else if (selectedEntityAdapter?.$isDisabled.get()) {
stage.setCursor('not-allowed');
} else if (selectedEntityAdapter?.$isEntityTypeHidden.get()) {
stage.setCursor('not-allowed');
} else if (selectedEntityAdapter?.$isLocked.get()) {
stage.setCursor('not-allowed');
} else if (tool === 'brush') {
this.tools.brush.syncCursorStyle();
} else if (tool === 'eraser') {
this.tools.eraser.syncCursorStyle();
} else if (tool === 'colorPicker') {
this.tools.colorPicker.syncCursorStyle();
} else if (tool === 'move') {
this.tools.move.syncCursorStyle();
} else if (tool === 'rect') {
this.tools.rect.syncCursorStyle();
} else if (selectedEntityAdapter && isRenderableEntityType(selectedEntityAdapter.entityIdentifier.type)) {
if (selectedEntityAdapter.$isDisabled.get()) {
stage.setCursor('not-allowed');
} else if (selectedEntityAdapter.$isEntityTypeHidden.get()) {
stage.setCursor('not-allowed');
} else if (selectedEntityAdapter.$isLocked.get()) {
stage.setCursor('not-allowed');
} else if (tool === 'brush') {
this.tools.brush.syncCursorStyle();
} else if (tool === 'eraser') {
this.tools.eraser.syncCursorStyle();
} else if (tool === 'move') {
this.tools.move.syncCursorStyle();
} else if (tool === 'rect') {
this.tools.rect.syncCursorStyle();
}
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
stage.setCursor('not-allowed');
} else {
stage.setCursor('not-allowed');
}
@@ -387,15 +390,17 @@ export class CanvasToolModule extends CanvasModuleBase {
try {
this.$lastPointerType.set(e.evt.pointerType);
if (!this.getCanDraw()) {
return;
}
const tool = this.$tool.get();
if (tool === 'colorPicker') {
this.tools.colorPicker.onStagePointerUp(e);
} else if (tool === 'brush') {
}
if (!this.getCanDraw()) {
return;
}
if (tool === 'brush') {
this.tools.brush.onStagePointerUp(e);
} else if (tool === 'eraser') {
this.tools.eraser.onStagePointerUp(e);
@@ -416,15 +421,17 @@ export class CanvasToolModule extends CanvasModuleBase {
this.$lastPointerType.set(e.evt.pointerType);
this.syncCursorPositions();
if (!this.getCanDraw()) {
return;
}
const tool = this.$tool.get();
if (tool === 'colorPicker') {
this.tools.colorPicker.onStagePointerMove(e);
} else if (tool === 'brush') {
}
if (!this.getCanDraw()) {
return;
}
if (tool === 'brush') {
await this.tools.brush.onStagePointerMove(e);
} else if (tool === 'eraser') {
await this.tools.eraser.onStagePointerMove(e);

View File

@@ -7,6 +7,7 @@ import type {
Coordinate,
CoordinateWithPressure,
Rect,
RgbaColor,
} from 'features/controlLayers/store/types';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
@@ -15,7 +16,6 @@ import { clamp } from 'lodash-es';
import { customAlphabet } from 'nanoid';
import type { StrokeOptions } from 'perfect-freehand';
import getStroke from 'perfect-freehand';
import type { RgbColor } from 'react-colorful';
import { assert } from 'tsafe';
/**
@@ -484,9 +484,10 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
export const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
export function getPrefixedId(
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>)
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>),
separator = ':'
): string {
return `${prefix}:${nanoid()}`;
return `${prefix}${separator}${nanoid()}`;
}
export const getEmptyRect = (): Rect => {
@@ -723,7 +724,7 @@ export const getPointerType = (e: KonvaEventObject<PointerEvent>): 'mouse' | 'pe
* @param coord The coordinate to get the color at. This must be the _absolute_ coordinate on the stage.
* @returns The color under the coordinate, or null if there was a problem getting the color.
*/
export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): RgbColor | null => {
export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): RgbaColor | null => {
const ctx = stage
.toCanvas({ x: coord.x, y: coord.y, width: 1, height: 1, imageSmoothingEnabled: false })
.getContext('2d');
@@ -732,13 +733,13 @@ export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): Rgb
return null;
}
const [r, g, b, _a] = ctx.getImageData(0, 0, 1, 1).data;
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
if (r === undefined || g === undefined || b === undefined) {
if (r === undefined || g === undefined || b === undefined || a === undefined) {
return null;
}
return { r, g, b };
return { r, g, b, a };
};
export const roundRect = (rect: Rect): Rect => {

View File

@@ -49,6 +49,8 @@ export type ParamsState = {
optimizedDenoisingEnabled: boolean;
iterations: number;
scheduler: ParameterScheduler;
upscaleScheduler: ParameterScheduler;
upscaleCfgScale: ParameterCFGScale;
seed: ParameterSeed;
shouldRandomizeSeed: boolean;
steps: ParameterSteps;
@@ -96,6 +98,8 @@ const initialState: ParamsState = {
optimizedDenoisingEnabled: true,
iterations: 1,
scheduler: 'dpmpp_3m_k',
upscaleScheduler: 'kdpm_2',
upscaleCfgScale: 2,
seed: 0,
shouldRandomizeSeed: true,
steps: 30,
@@ -139,6 +143,9 @@ export const paramsSlice = createSlice({
setCfgScale: (state, action: PayloadAction<ParameterCFGScale>) => {
state.cfgScale = action.payload;
},
setUpscaleCfgScale: (state, action: PayloadAction<ParameterCFGScale>) => {
state.upscaleCfgScale = action.payload;
},
setGuidance: (state, action: PayloadAction<ParameterGuidance>) => {
state.guidance = action.payload;
},
@@ -148,6 +155,10 @@ export const paramsSlice = createSlice({
setScheduler: (state, action: PayloadAction<ParameterScheduler>) => {
state.scheduler = action.payload;
},
setUpscaleScheduler: (state, action: PayloadAction<ParameterScheduler>) => {
state.upscaleScheduler = action.payload;
},
setSeed: (state, action: PayloadAction<number>) => {
state.seed = action.payload;
state.shouldRandomizeSeed = false;
@@ -315,6 +326,8 @@ export const {
setCfgRescaleMultiplier,
setGuidance,
setScheduler,
setUpscaleScheduler,
setUpscaleCfgScale,
setSeed,
setImg2imgStrength,
setOptimizedDenoisingEnabled,
@@ -409,6 +422,9 @@ export const selectVAEPrecision = createParamsSelector((params) => params.vaePre
export const selectIterations = createParamsSelector((params) => params.iterations);
export const selectShouldUseCPUNoise = createParamsSelector((params) => params.shouldUseCpuNoise);
export const selectUpscaleScheduler = createParamsSelector((params) => params.upscaleScheduler);
export const selectUpscaleCfgScale = createParamsSelector((params) => params.upscaleCfgScale);
export const selectRefinerCFGScale = createParamsSelector((params) => params.refinerCFGScale);
export const selectRefinerModel = createParamsSelector((params) => params.refinerModel);
export const selectIsRefinerModelSelected = createParamsSelector((params) => Boolean(params.refinerModel));

View File

@@ -9,7 +9,8 @@ import type { DndListTargetState } from 'features/dnd/types';
*/
const line = {
thickness: 2,
backgroundColor: 'base.500',
backgroundColor: 'red',
// backgroundColor: 'base.500',
};
type DropIndicatorProps = {
@@ -104,7 +105,7 @@ function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) {
);
}
export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetState }) => {
export const DndListDropIndicator = ({ dndState, gap }: { dndState: DndListTargetState; gap?: string }) => {
if (dndState.type !== 'is-dragging-over') {
return null;
}
@@ -117,7 +118,7 @@ export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetStat
<DndDropIndicatorInternal
edge={dndState.closestEdge}
// This is the gap between items in the list, used to calculate the position of the drop indicator
gap="var(--invoke-space-2)"
gap={gap || 'var(--invoke-space-2)'}
/>
);
};

View File

@@ -108,18 +108,6 @@ export const singleCanvasEntityDndSource: DndSource<SingleCanvasEntityDndSourceD
getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type),
};
const _singleWorkflowField = buildTypeAndKey('single-workflow-field');
type SingleWorkflowFieldDndSourceData = DndData<
typeof _singleWorkflowField.type,
typeof _singleWorkflowField.key,
{ fieldIdentifier: FieldIdentifier }
>;
export const singleWorkflowFieldDndSource: DndSource<SingleWorkflowFieldDndSourceData> = {
..._singleWorkflowField,
typeGuard: buildTypeGuard(_singleWorkflowField.key),
getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type),
};
type DndTarget<TargetData extends DndData, SourceData extends DndData> = {
key: symbol;
type: TargetData['type'];

View File

@@ -1,9 +1,9 @@
import { Flex, IconButton, Input, Text } from '@invoke-ai/ui-library';
import { useBoolean } from 'common/hooks/useBoolean';
import { useEditable } from 'common/hooks/useEditable';
import { withResultAsync } from 'common/util/result';
import { toast } from 'features/toast/toast';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPencilBold } from 'react-icons/pi';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
@@ -16,85 +16,54 @@ type Props = {
export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
const { t } = useTranslation();
const isEditing = useBoolean(false);
const [isHovering, setIsHovering] = useState(false);
const [localTitle, setLocalTitle] = useState(board.board_name);
const ref = useRef<HTMLInputElement>(null);
const isHovering = useBoolean(false);
const inputRef = useRef<HTMLInputElement>(null);
const [updateBoard, updateBoardResult] = useUpdateBoardMutation();
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setLocalTitle(e.target.value);
}, []);
const onEdit = useCallback(() => {
isEditing.setTrue();
setIsHovering(false);
}, [isEditing]);
const onBlur = useCallback(async () => {
const trimmedTitle = localTitle.trim();
isEditing.setFalse();
if (trimmedTitle.length === 0) {
setLocalTitle(board.board_name);
} else if (trimmedTitle !== board.board_name) {
setLocalTitle(trimmedTitle);
const onChange = useCallback(
async (board_name: string) => {
const result = await withResultAsync(() =>
updateBoard({ board_id: board.board_id, changes: { board_name: trimmedTitle } }).unwrap()
updateBoard({ board_id: board.board_id, changes: { board_name } }).unwrap()
);
if (result.isErr()) {
setLocalTitle(board.board_name);
toast({
status: 'error',
title: t('boards.updateBoardError'),
});
} else {
setLocalTitle(result.value.board_name);
}
}
}, [board.board_id, board.board_name, isEditing, localTitle, updateBoard, t]);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onBlur();
} else if (e.key === 'Escape') {
setLocalTitle(board.board_name);
isEditing.setFalse();
}
},
[board.board_name, isEditing, onBlur]
[board.board_id, t, updateBoard]
);
const handleMouseOver = useCallback(() => {
setIsHovering(true);
}, []);
const editable = useEditable({
value: board.board_name,
defaultValue: board.board_name,
onChange,
inputRef,
onStartEditing: isHovering.setTrue,
});
const handleMouseOut = useCallback(() => {
setIsHovering(false);
}, []);
useEffect(() => {
if (isEditing.isTrue) {
ref.current?.focus();
ref.current?.select();
}
}, [isEditing.isTrue]);
if (!isEditing.isTrue) {
if (!editable.isEditing) {
return (
<Flex alignItems="center" gap={3} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut}>
<Flex alignItems="center" gap={3} onMouseOver={isHovering.setTrue} onMouseOut={isHovering.setFalse}>
<Text
size="sm"
fontWeight="semibold"
userSelect="none"
color={isSelected ? 'base.100' : 'base.300'}
onDoubleClick={onEdit}
onDoubleClick={editable.startEditing}
cursor="text"
>
{localTitle}
{editable.value}
</Text>
{isHovering && (
<IconButton aria-label="edit name" icon={<PiPencilBold />} size="sm" variant="ghost" onClick={onEdit} />
{isHovering.isTrue && (
<IconButton
aria-label="edit name"
icon={<PiPencilBold />}
size="sm"
variant="ghost"
onClick={editable.startEditing}
/>
)}
</Flex>
);
@@ -102,11 +71,8 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
return (
<Input
ref={ref}
value={localTitle}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
ref={inputRef}
{...editable.inputProps}
variant="outline"
isDisabled={updateBoardResult.isLoading}
_focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }}

View File

@@ -6,7 +6,6 @@ import { createSelector } from '@reduxjs/toolkit';
import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage';
import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage';
@@ -19,6 +18,7 @@ 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,7 +178,20 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
);
}, [imageDTO, element, store, dndId]);
const isHovered = useBoolean(false);
// 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 onMouseOver = useCallback(() => {
$isHovered.set(true);
}, [$isHovered]);
const onMouseOut = useCallback(() => {
$isHovered.set(false);
}, [$isHovered]);
const onClick = useCallback<MouseEventHandler<HTMLDivElement>>(
(e) => {
@@ -217,8 +230,8 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
<Flex
role="button"
className="gallery-image"
onMouseOver={isHovered.setTrue}
onMouseOut={isHovered.setFalse}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={onClick}
onDoubleClick={onDoubleClick}
data-selected={isSelected}
@@ -234,7 +247,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
maxH="full"
borderRadius="base"
/>
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered.isTrue} />
<GalleryImageHoverIcons imageDTO={imageDTO} $isHovered={$isHovered} />
</Flex>
</Box>
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}

View File

@@ -1,19 +1,22 @@
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: boolean;
$isHovered: Atom<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

@@ -17,79 +17,19 @@ import { useTranslation } from 'react-i18next';
export const JumpTo = memo(() => {
const { t } = useTranslation();
const { goToPage, currentPage, pages } = useGalleryPagination();
const [newPage, setNewPage] = useState(currentPage);
const { isOpen, onToggle, onClose } = useDisclosure();
const ref = useRef<HTMLInputElement>(null);
const onOpen = useCallback(() => {
setNewPage(currentPage);
setTimeout(() => {
const input = ref.current?.querySelector('input');
input?.focus();
input?.select();
}, 0);
}, [currentPage]);
const onChangeJumpTo = useCallback((v: number) => {
setNewPage(v - 1);
}, []);
const onClickGo = useCallback(() => {
goToPage(newPage);
onClose();
}, [newPage, goToPage, onClose]);
useHotkeys(
'enter',
() => {
onClickGo();
},
{ enabled: isOpen, enableOnFormTags: ['input'] },
[isOpen, onClickGo]
);
useHotkeys(
'esc',
() => {
setNewPage(currentPage);
onClose();
},
{ enabled: isOpen, enableOnFormTags: ['input'] },
[isOpen, onClose]
);
useEffect(() => {
setNewPage(currentPage);
}, [currentPage]);
const disclosure = useDisclosure();
return (
<Popover isOpen={isOpen} onClose={onClose} onOpen={onOpen} isLazy lazyBehavior="unmount">
<Popover isOpen={disclosure.isOpen} onClose={disclosure.onClose} isLazy lazyBehavior="unmount">
<PopoverTrigger>
<Button aria-label={t('gallery.jump')} size="sm" onClick={onToggle} variant="outline">
<Button aria-label={t('gallery.jump')} size="sm" onClick={disclosure.onToggle} variant="outline">
{t('gallery.jump')}
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<Flex gap={2} alignItems="center">
<FormControl>
<CompositeNumberInput
ref={ref}
size="sm"
maxW="60px"
value={newPage + 1}
min={1}
max={pages}
step={1}
onChange={onChangeJumpTo}
/>
</FormControl>
<Button h="full" size="sm" onClick={onClickGo}>
{t('gallery.go')}
</Button>
</Flex>
<JumpToContent disclosure={disclosure} />
</PopoverBody>
</PopoverContent>
</Popover>
@@ -97,3 +37,68 @@ export const JumpTo = memo(() => {
});
JumpTo.displayName = 'JumpTo';
const JumpToContent = memo(({ disclosure }: { disclosure: ReturnType<typeof useDisclosure> }) => {
const { t } = useTranslation();
const { goToPage, currentPage, pages } = useGalleryPagination();
const [newPage, setNewPage] = useState(currentPage);
const ref = useRef<HTMLInputElement>(null);
const onChangeJumpTo = useCallback((v: number) => {
setNewPage(v - 1);
}, []);
const onClickGo = useCallback(() => {
goToPage(newPage);
disclosure.onClose();
}, [goToPage, newPage, disclosure]);
useHotkeys(
'enter',
() => {
onClickGo();
},
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
[disclosure.isOpen, onClickGo]
);
useHotkeys(
'esc',
() => {
setNewPage(currentPage);
disclosure.onClose();
},
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
[disclosure.isOpen, disclosure.onClose]
);
useEffect(() => {
setTimeout(() => {
const input = ref.current?.querySelector('input');
input?.focus();
input?.select();
}, 0);
setNewPage(currentPage);
}, [currentPage]);
return (
<Flex gap={2} alignItems="center">
<FormControl>
<CompositeNumberInput
ref={ref}
size="sm"
maxW="60px"
value={newPage + 1}
min={1}
max={pages}
step={1}
onChange={onChangeJumpTo}
/>
</FormControl>
<Button h="full" size="sm" onClick={onClickGo}>
{t('gallery.go')}
</Button>
</Flex>
);
});
JumpToContent.displayName = 'JumpToContent';

View File

@@ -21,7 +21,7 @@ import type { ImageDTO } from 'services/api/types';
*
* We can hack around this, thanks to the fact that the image viewer is always opened on the first app startup. By the
* time the user closes it, the resizable panels library has already done its one extra resize and the DOM layout has
* stablized. So we can track the first time the image viewer is closed and fit the layers to the stage at that time,
* stabilized. So we can track the first time the image viewer is closed and fit the layers to the stage at that time,
* ensuring that the bbox is centered in the canvas stage on that first app startup.
*
* TODO(psyche): Figure out a better way to do handle this...

View File

@@ -152,7 +152,7 @@ type UseGalleryNavigationReturn = {
/**
* Provides access to the gallery navigation via arrow keys.
* Also provides information about the current image's position in the gallery,
* useful for determining whether to load more images or display navigatin
* useful for determining whether to load more images or display navigation
* buttons.
*/
export const useGalleryNavigation = (): UseGalleryNavigationReturn => {

View File

@@ -115,19 +115,13 @@ export const useGalleryPagination = () => {
},
[throttledOnOffsetChanged, limit]
);
const goToFirst = useCallback(() => {
throttledOnOffsetChanged({ offset: 0 });
}, [throttledOnOffsetChanged]);
const goToLast = useCallback(() => {
throttledOnOffsetChanged({ offset: (pages - 1) * (limit || 0) });
}, [throttledOnOffsetChanged, pages, limit]);
// handle when total/pages decrease and user is on high page number (ie bulk removing or deleting)
useEffect(() => {
if (pages && currentPage + 1 > pages) {
goToLast();
throttledOnOffsetChanged({ offset: (pages - 1) * (limit || 0) });
}
}, [currentPage, pages, goToLast]);
}, [currentPage, pages, throttledOnOffsetChanged, limit]);
const pageButtons = useMemo(() => {
if (pages > 7) {
@@ -135,35 +129,16 @@ export const useGalleryPagination = () => {
}
return range(1, pages);
}, [currentPage, pages]);
const isFirstEnabled = useMemo(() => currentPage > 0, [currentPage]);
const isLastEnabled = useMemo(() => currentPage < pages - 1, [currentPage, pages]);
const rangeDisplay = useMemo(() => {
const startItem = currentPage * (limit || 0) + 1;
const endItem = Math.min((currentPage + 1) * (limit || 0), total);
return `${startItem}-${endItem} of ${total}`;
}, [total, currentPage, limit]);
const numberOnPage = useMemo(() => {
return Math.min((currentPage + 1) * (limit || 0), total);
}, [currentPage, limit, total]);
return {
count,
total,
currentPage,
pages,
isNextEnabled,
isPrevEnabled,
goNext,
goPrev,
goToPage,
goToFirst,
goToLast,
goNext,
isPrevEnabled,
isNextEnabled,
pageButtons,
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
goToPage,
currentPage,
total,
pages,
};
};

View File

@@ -1,5 +1,3 @@
import 'reactflow/dist/style.css';
import { Flex } from '@invoke-ai/ui-library';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { useFocusRegion } from 'common/hooks/focus';

View File

@@ -12,25 +12,27 @@ import {
Text,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import type { EdgeChange, NodeChange } from '@xyflow/react';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { CommandEmpty, CommandItem, CommandList, CommandRoot } from 'cmdk';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { useBuildNode } from 'features/nodes/hooks/useBuildNode';
import {
$addNodeCmdk,
$cursorPos,
$edgePendingUpdate,
$pendingConnection,
$templates,
edgesChanged,
nodesChanged,
useAddNodeCmdk,
} from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition';
import { getFirstValidConnection } from 'features/nodes/store/util/getFirstValidConnection';
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { toast } from 'features/toast/toast';
@@ -38,34 +40,12 @@ import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memoize } from 'lodash-es';
import { computed } from 'nanostores';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCircuitryBold, PiFlaskBold, PiHammerBold, PiLightningFill } from 'react-icons/pi';
import type { EdgeChange, NodeChange } from 'reactflow';
import type { S } from 'services/api/types';
const useThrottle = <T,>(value: T, limit: number) => {
const [throttledValue, setThrottledValue] = useState(value);
const lastRan = useRef(Date.now());
useEffect(() => {
const handler = setTimeout(
function () {
if (Date.now() - lastRan.current >= limit) {
setThrottledValue(value);
lastRan.current = Date.now();
}
},
limit - (Date.now() - lastRan.current)
);
return () => {
clearTimeout(handler);
};
}, [value, limit]);
return throttledValue;
};
import { objectEntries } from 'tsafe';
import { useDebounce } from 'use-debounce';
const useAddNode = () => {
const { t } = useTranslation();
@@ -95,8 +75,8 @@ const useAddNode = () => {
node.selected = true;
// Deselect all other nodes and edges
const nodeChanges: NodeChange[] = [{ type: 'add', item: node }];
const edgeChanges: EdgeChange[] = [];
const nodeChanges: NodeChange<AnyNode>[] = [{ type: 'add', item: node }];
const edgeChanges: EdgeChange<AnyEdge>[] = [];
nodes.forEach(({ id, selected }) => {
if (selected) {
nodeChanges.push({ type: 'select', id, selected: false });
@@ -162,19 +142,26 @@ const cmdkRootSx: SystemStyleObject = {
export const AddNodeCmdk = memo(() => {
const { t } = useTranslation();
const addNodeCmdk = useAddNodeCmdk();
const inputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const addNode = useAddNode();
const tab = useAppSelector(selectActiveTab);
const throttledSearchTerm = useThrottle(searchTerm, 100);
// Filtering the list is expensive - debounce the search term to avoid stutters
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const isOpen = useStore($addNodeCmdk);
const open = useCallback(() => {
$addNodeCmdk.set(true);
}, []);
const close = useCallback(() => {
$addNodeCmdk.set(false);
}, []);
useRegisteredHotkeys({
id: 'addNode',
category: 'workflows',
callback: addNodeCmdk.setTrue,
callback: open,
options: { enabled: tab === 'workflows', preventDefault: true },
dependencies: [addNodeCmdk.setTrue, tab],
dependencies: [open, tab],
});
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
@@ -182,10 +169,10 @@ export const AddNodeCmdk = memo(() => {
}, []);
const onClose = useCallback(() => {
addNodeCmdk.setFalse();
close();
setSearchTerm('');
$pendingConnection.set(null);
}, [addNodeCmdk]);
}, [close]);
const onSelect = useCallback(
(value: string) => {
@@ -196,14 +183,7 @@ export const AddNodeCmdk = memo(() => {
);
return (
<Modal
isOpen={addNodeCmdk.isTrue}
onClose={onClose}
useInert={false}
initialFocusRef={inputRef}
size="xl"
isCentered
>
<Modal isOpen={isOpen} onClose={onClose} useInert={false} initialFocusRef={inputRef} size="xl" isCentered>
<ModalOverlay />
<ModalContent h="512" maxH="70%">
<ModalBody p={2} h="full" sx={cmdkRootSx}>
@@ -224,7 +204,7 @@ export const AddNodeCmdk = memo(() => {
/>
</CommandEmpty>
<CommandList>
<NodeCommandList searchTerm={throttledSearchTerm} onSelect={onSelect} />
<NodeCommandList searchTerm={debouncedSearchTerm} onSelect={onSelect} />
</CommandList>
</ScrollableContent>
</Box>
@@ -381,11 +361,11 @@ const NodeCommandList = memo(({ searchTerm, onSelect }: { searchTerm: string; on
if (filter(template, searchTerm)) {
const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs;
for (const field of Object.values(candidateFields)) {
for (const [_fieldName, fieldTemplate] of objectEntries(candidateFields)) {
const sourceType =
pendingConnection.handleType === 'source' ? field.type : pendingConnection.fieldTemplate.type;
pendingConnection.handleType === 'source' ? pendingConnection.fieldTemplate.type : fieldTemplate.type;
const targetType =
pendingConnection.handleType === 'target' ? field.type : pendingConnection.fieldTemplate.type;
pendingConnection.handleType === 'target' ? pendingConnection.fieldTemplate.type : fieldTemplate.type;
if (validateConnectionTypes(sourceType, targetType)) {
_items.push({

View File

@@ -1,11 +1,25 @@
import { useGlobalMenuClose, useToken } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import type {
EdgeChange,
HandleType,
NodeChange,
OnEdgesChange,
OnInit,
OnMoveEnd,
OnNodesChange,
OnReconnect,
ProOptions,
ReactFlowProps,
ReactFlowState,
} from '@xyflow/react';
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from '@xyflow/react';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
import { useConnection } from 'features/nodes/hooks/useConnection';
import { useCopyPaste } from 'features/nodes/hooks/useCopyPaste';
import { useSyncExecutionState } from 'features/nodes/hooks/useExecutionState';
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher';
import {
$addNodeCmdk,
@@ -30,23 +44,11 @@ import {
} from 'features/nodes/store/selectors';
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import type { CSSProperties, MouseEvent } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import type {
EdgeChange,
NodeChange,
OnEdgesChange,
OnEdgeUpdateFunc,
OnInit,
OnMoveEnd,
OnNodesChange,
ProOptions,
ReactFlowProps,
ReactFlowState,
} from 'reactflow';
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from 'reactflow';
import CustomConnectionLine from './connectionLines/CustomConnectionLine';
import InvocationCollapsedEdge from './edges/InvocationCollapsedEdge';
@@ -58,13 +60,13 @@ import NotesNode from './nodes/Notes/NotesNode';
const edgeTypes = {
collapsed: InvocationCollapsedEdge,
default: InvocationDefaultEdge,
};
} as const;
const nodeTypes = {
invocation: InvocationNodeWrapper,
current_image: CurrentImageNode,
notes: NotesNode,
};
} as const;
// TODO: can we support reactflow? if not, we could style the attribution so it matches the app
const proOptions: ProOptions = { hideAttribution: true };
@@ -97,7 +99,7 @@ export const Flow = memo(() => {
const [borderRadius] = useToken('radii', ['base']);
const flowStyles = useMemo<CSSProperties>(() => ({ borderRadius }), [borderRadius]);
const onNodesChange: OnNodesChange = useCallback(
const onNodesChange: OnNodesChange<AnyNode> = useCallback(
(nodeChanges) => {
dispatch(nodesChanged(nodeChanges));
const flow = $flow.get();
@@ -112,7 +114,7 @@ export const Flow = memo(() => {
[dispatch, needsFit]
);
const onEdgesChange: OnEdgesChange = useCallback(
const onEdgesChange: OnEdgesChange<AnyEdge> = useCallback(
(changes) => {
if (changes.length > 0) {
dispatch(edgesChanged(changes));
@@ -130,7 +132,7 @@ export const Flow = memo(() => {
onCloseGlobal();
}, [onCloseGlobal]);
const onInit: OnInit = useCallback((flow) => {
const onInit: OnInit<AnyNode, AnyEdge> = useCallback((flow) => {
$flow.set(flow);
flow.fitView();
}, []);
@@ -158,13 +160,13 @@ export const Flow = memo(() => {
* where the edge is deleted if you click it accidentally).
*/
const onEdgeUpdateStart: NonNullable<ReactFlowProps['onEdgeUpdateStart']> = useCallback((e, edge, _handleType) => {
const onReconnectStart = useCallback((event: MouseEvent, edge: AnyEdge, _handleType: HandleType) => {
$edgePendingUpdate.set(edge);
$didUpdateEdge.set(false);
$lastEdgeUpdateMouseEvent.set(e);
$lastEdgeUpdateMouseEvent.set(event);
}, []);
const onEdgeUpdate: OnEdgeUpdateFunc = useCallback(
const onReconnect: OnReconnect = useCallback(
(oldEdge, newConnection) => {
// This event is fired when an edge update is successful
$didUpdateEdge.set(true);
@@ -183,7 +185,7 @@ export const Flow = memo(() => {
[dispatch, updateNodeInternals]
);
const onEdgeUpdateEnd: NonNullable<ReactFlowProps['onEdgeUpdateEnd']> = useCallback(
const onReconnectEnd: NonNullable<ReactFlowProps['onReconnectEnd']> = useCallback(
(e, edge, _handleType) => {
const didUpdateEdge = $didUpdateEdge.get();
// Fall back to a reasonable default event
@@ -208,7 +210,7 @@ export const Flow = memo(() => {
// #endregion
const { copySelection, pasteSelection, pasteSelectionWithEdges } = useCopyPaste();
const { copySelection, pasteSelection, pasteSelectionWithEdges } = useNodeCopyPaste();
useRegisteredHotkeys({
id: 'copySelection',
@@ -220,8 +222,8 @@ export const Flow = memo(() => {
const selectAll = useCallback(() => {
const { nodes, edges } = selectNodesSlice(store.getState());
const nodeChanges: NodeChange[] = [];
const edgeChanges: EdgeChange[] = [];
const nodeChanges: NodeChange<AnyNode>[] = [];
const edgeChanges: EdgeChange<AnyEdge>[] = [];
nodes.forEach(({ id, selected }) => {
if (!selected) {
nodeChanges.push({ type: 'select', id, selected: true });
@@ -294,8 +296,8 @@ export const Flow = memo(() => {
const deleteSelection = useCallback(() => {
const { nodes, edges } = selectNodesSlice(store.getState());
const nodeChanges: NodeChange[] = [];
const edgeChanges: EdgeChange[] = [];
const nodeChanges: NodeChange<AnyNode>[] = [];
const edgeChanges: EdgeChange<AnyEdge>[] = [];
nodes
.filter((n) => n.selected)
.forEach(({ id }) => {
@@ -322,7 +324,7 @@ export const Flow = memo(() => {
});
return (
<ReactFlow
<ReactFlow<AnyNode, AnyEdge>
id="workflow-editor"
ref={flowWrapper}
defaultViewport={viewport}
@@ -334,9 +336,9 @@ export const Flow = memo(() => {
onMouseMove={onMouseMove}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgeUpdate={onEdgeUpdate}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnectStart={onConnectStart}
onConnect={onConnect}
onConnectEnd={onConnectEnd}

View File

@@ -1,4 +1,6 @@
import { useStore } from '@nanostores/react';
import type { ConnectionLineComponentProps } from '@xyflow/react';
import { getBezierPath } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
@@ -6,8 +8,6 @@ import { $pendingConnection } from 'features/nodes/store/nodesSlice';
import { selectShouldAnimateEdges, selectShouldColorEdges } from 'features/nodes/store/workflowSettingsSlice';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import type { ConnectionLineComponentProps } from 'reactflow';
import { getBezierPath } from 'reactflow';
const pathStyles: CSSProperties = { opacity: 0.8 };

View File

@@ -1,13 +1,39 @@
import { Badge, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Badge, Box, chakra } from '@invoke-ai/ui-library';
import type { EdgeProps } from '@xyflow/react';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useChakraThemeTokens } from 'common/hooks/useChakraThemeTokens';
import { getEdgeStyles } from 'features/nodes/components/flow/edges/util/getEdgeColor';
import { makeEdgeSelector } from 'features/nodes/components/flow/edges/util/makeEdgeSelector';
import { $templates } from 'features/nodes/store/nodesSlice';
import { buildSelectAreConnectedNodesSelected } from 'features/nodes/components/flow/edges/util/buildEdgeSelectors';
import { selectShouldAnimateEdges } from 'features/nodes/store/workflowSettingsSlice';
import type { CollapsedInvocationNodeEdge } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
import type { EdgeProps } from 'reactflow';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from 'reactflow';
const ChakraBaseEdge = chakra(BaseEdge);
const baseEdgeSx: SystemStyleObject = {
strokeWidth: '3px !important',
stroke: 'base.500 !important',
opacity: '0.5 !important',
strokeDasharray: 'none',
'&[data-selected="true"]': {
opacity: '1 !important',
},
'&[data-selected="true"], &[data-are-connected-nodes-selected="true"]': {
strokeDasharray: '5 !important',
},
'&[data-should-animate-edges="true"]': {
animation: 'dashdraw 0.5s linear infinite !important',
},
};
const badgeSx: SystemStyleObject = {
bg: 'base.500',
opacity: 0.5,
shadow: 'base',
'&[data-selected="true"]': {
opacity: 1,
},
};
const InvocationCollapsedEdge = ({
sourceX,
@@ -20,17 +46,15 @@ const InvocationCollapsedEdge = ({
data,
selected = false,
source,
sourceHandleId,
target,
targetHandleId,
}: EdgeProps<{ count: number }>) => {
const templates = useStore($templates);
const selector = useMemo(
() => makeEdgeSelector(templates, source, sourceHandleId, target, targetHandleId),
[templates, source, sourceHandleId, target, targetHandleId]
}: EdgeProps<CollapsedInvocationNodeEdge>) => {
const shouldAnimateEdges = useAppSelector(selectShouldAnimateEdges);
const selectAreConnectedNodesSelected = useMemo(
() => buildSelectAreConnectedNodesSelected(source, target),
[source, target]
);
const { shouldAnimateEdges, areConnectedNodesSelected } = useAppSelector(selector);
const areConnectedNodesSelected = useAppSelector(selectAreConnectedNodesSelected);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
@@ -41,31 +65,29 @@ const InvocationCollapsedEdge = ({
targetPosition,
});
const { base500 } = useChakraThemeTokens();
const edgeStyles = useMemo(
() => getEdgeStyles(base500, selected, shouldAnimateEdges, areConnectedNodesSelected),
[areConnectedNodesSelected, base500, selected, shouldAnimateEdges]
);
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={edgeStyles} />
{data?.count && data.count > 1 && (
<ChakraBaseEdge
path={edgePath}
markerEnd={markerEnd}
sx={baseEdgeSx}
data-selected={selected}
data-are-connected-nodes-selected={areConnectedNodesSelected}
data-should-animate-edges={shouldAnimateEdges}
/>
{data?.count !== undefined && (
<EdgeLabelRenderer>
<Flex
data-testid="asdfasdfasdf"
<Box
position="absolute"
transform={`translate(-50%, -50%) translate(${labelX}px,${labelY}px)`}
className="nodrag nopan"
// Unfortunately edge labels do not get the same zIndex treatment as edges do, so we need to manage this ourselves
className="edge-label-renderer__custom-edge nodrag nopan" // Unfortunately edge labels do not get the same zIndex treatment as edges do, so we need to manage this ourselves
// See: https://github.com/xyflow/xyflow/issues/3658
zIndex={1001}
>
<Badge variant="solid" bg="base.500" opacity={selected ? 0.8 : 0.5} boxShadow="base">
<Badge variant="solid" sx={badgeSx} data-selected={selected}>
{data.count}
</Badge>
</Flex>
</Box>
</EdgeLabelRenderer>
)}
</>

View File

@@ -1,14 +1,61 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { chakra, Flex, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import type { EdgeProps } from '@xyflow/react';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import { getEdgeStyles } from 'features/nodes/components/flow/edges/util/getEdgeColor';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectShouldShowEdgeLabels } from 'features/nodes/store/workflowSettingsSlice';
import { selectShouldAnimateEdges, selectShouldShowEdgeLabels } from 'features/nodes/store/workflowSettingsSlice';
import type { DefaultInvocationNodeEdge } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
import type { EdgeProps } from 'reactflow';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from 'reactflow';
import { makeEdgeSelector } from './util/makeEdgeSelector';
import {
buildSelectAreConnectedNodesSelected,
buildSelectEdgeColor,
buildSelectEdgeLabel,
} from './util/buildEdgeSelectors';
const ChakraBaseEdge = chakra(BaseEdge);
const baseEdgeSx: SystemStyleObject = {
strokeWidth: '3px !important',
opacity: '0.5 !important',
strokeDasharray: 'none',
'&[data-selected="true"]': {
opacity: '1 !important',
},
'&[data-should-animate-edges="true"]': {
animation: 'dashdraw 0.5s linear infinite !important',
'&[data-selected="true"], &[data-are-connected-nodes-selected="true"]': {
strokeDasharray: '5 !important',
},
},
};
const edgeLabelWrapperSx: SystemStyleObject = {
pointerEvents: 'all',
position: 'absolute',
bg: 'base.800',
borderRadius: 'base',
borderWidth: 1,
opacity: 0.5,
borderColor: 'transparent',
py: 1,
px: 3,
shadow: 'md',
'&[data-selected="true"]': {
opacity: 1,
borderColor: undefined,
},
};
const edgeLabelTextSx: SystemStyleObject = {
fontWeight: 'semibold',
color: 'base.300',
'&[data-selected="true"]': {
color: 'base.100',
},
};
const InvocationDefaultEdge = ({
sourceX,
@@ -23,15 +70,26 @@ const InvocationDefaultEdge = ({
target,
sourceHandleId,
targetHandleId,
}: EdgeProps) => {
}: EdgeProps<DefaultInvocationNodeEdge>) => {
const templates = useStore($templates);
const selector = useMemo(
() => makeEdgeSelector(templates, source, sourceHandleId, target, targetHandleId),
const shouldAnimateEdges = useAppSelector(selectShouldAnimateEdges);
const shouldShowEdgeLabels = useAppSelector(selectShouldShowEdgeLabels);
const selectAreConnectedNodesSelected = useMemo(
() => buildSelectAreConnectedNodesSelected(source, target),
[source, target]
);
const selectStrokeColor = useMemo(
() => buildSelectEdgeColor(templates, source, sourceHandleId, target, targetHandleId),
[templates, source, sourceHandleId, target, targetHandleId]
);
const { shouldAnimateEdges, areConnectedNodesSelected, stroke, label } = useAppSelector(selector);
const shouldShowEdgeLabels = useAppSelector(selectShouldShowEdgeLabels);
const selectEdgeLabel = useMemo(
() => buildSelectEdgeLabel(templates, source, sourceHandleId, target, targetHandleId),
[templates, source, sourceHandleId, target, targetHandleId]
);
const areConnectedNodesSelected = useAppSelector(selectAreConnectedNodesSelected);
const stroke = useAppSelector(selectStrokeColor);
const label = useAppSelector(selectEdgeLabel);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
@@ -42,31 +100,26 @@ const InvocationDefaultEdge = ({
targetPosition,
});
const edgeStyles = useMemo(
() => getEdgeStyles(stroke, selected, shouldAnimateEdges, areConnectedNodesSelected),
[areConnectedNodesSelected, stroke, selected, shouldAnimateEdges]
);
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={edgeStyles} />
<ChakraBaseEdge
path={edgePath}
markerEnd={markerEnd}
sx={baseEdgeSx}
stroke={`${stroke} !important`}
data-selected={selected}
data-are-connected-nodes-selected={areConnectedNodesSelected}
data-should-animate-edges={shouldAnimateEdges}
/>
{label && shouldShowEdgeLabels && (
<EdgeLabelRenderer>
<Flex
className="nodrag nopan"
pointerEvents="all"
position="absolute"
transform={`translate(-50%, -50%) translate(${labelX}px,${labelY}px)`}
bg="base.800"
borderRadius="base"
borderWidth={1}
borderColor={selected ? 'undefined' : 'transparent'}
opacity={selected ? 1 : 0.5}
py={1}
px={3}
shadow="md"
data-selected={selected}
sx={edgeLabelWrapperSx}
>
<Text size="sm" fontWeight="semibold" color={selected ? 'base.100' : 'base.300'}>
<Text size="sm" sx={edgeLabelTextSx} data-selected={selected}>
{label}
</Text>
</Flex>

View File

@@ -0,0 +1,62 @@
import { createSelector } from '@reduxjs/toolkit';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { Templates } from 'features/nodes/store/types';
import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { getFieldColor } from './getEdgeColor';
export const buildSelectAreConnectedNodesSelected = (source: string, target: string) =>
createSelector(selectNodesSlice, (nodes): boolean => {
const sourceNode = nodes.nodes.find((node) => node.id === source);
const targetNode = nodes.nodes.find((node) => node.id === target);
return Boolean(sourceNode?.selected || targetNode?.selected);
});
export const buildSelectEdgeColor = (
templates: Templates,
source: string,
sourceHandleId: string | null | undefined,
target: string,
targetHandleId: string | null | undefined
) =>
createSelector(selectNodesSlice, selectWorkflowSettingsSlice, (nodes, workflowSettings): string => {
const { shouldColorEdges } = workflowSettings;
const sourceNode = nodes.nodes.find((node) => node.id === source);
const targetNode = nodes.nodes.find((node) => node.id === target);
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
return colorTokenToCssVar('base.500');
}
const sourceNodeTemplate = templates[sourceNode.data.type];
const isInvocationToInvocationEdge = isInvocationNode(sourceNode) && isInvocationNode(targetNode);
const outputFieldTemplate = sourceNodeTemplate?.outputs[sourceHandleId];
const sourceType = isInvocationToInvocationEdge ? outputFieldTemplate?.type : undefined;
return sourceType && shouldColorEdges ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
});
export const buildSelectEdgeLabel = (
templates: Templates,
source: string,
sourceHandleId: string | null | undefined,
target: string,
targetHandleId: string | null | undefined
) =>
createSelector(selectNodesSlice, (nodes): string | null => {
const sourceNode = nodes.nodes.find((node) => node.id === source);
const targetNode = nodes.nodes.find((node) => node.id === target);
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
return null;
}
const sourceNodeTemplate = templates[sourceNode.data.type];
const targetNodeTemplate = templates[targetNode.data.type];
return `${sourceNodeTemplate?.title || sourceNode.data?.label} -> ${targetNodeTemplate?.title || targetNode.data?.label}`;
});

View File

@@ -1,7 +1,6 @@
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { FIELD_COLORS } from 'features/nodes/types/constants';
import type { FieldType } from 'features/nodes/types/field';
import type { CSSProperties } from 'react';
export const getFieldColor = (fieldType: FieldType | null): string => {
if (!fieldType) {
@@ -11,16 +10,3 @@ export const getFieldColor = (fieldType: FieldType | null): string => {
return color ? colorTokenToCssVar(color) : colorTokenToCssVar('base.500');
};
export const getEdgeStyles = (
stroke: string,
selected: boolean,
shouldAnimateEdges: boolean,
areConnectedNodesSelected: boolean
): CSSProperties => ({
strokeWidth: 3,
stroke,
opacity: selected ? 1 : 0.5,
animation: shouldAnimateEdges ? 'dashdraw 0.5s linear infinite' : undefined,
strokeDasharray: selected || areConnectedNodesSelected ? 5 : 'none',
});

View File

@@ -1,58 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { deepClone } from 'common/util/deepClone';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { Templates } from 'features/nodes/store/types';
import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { getFieldColor } from './getEdgeColor';
const defaultReturnValue = {
areConnectedNodesSelected: false,
shouldAnimateEdges: false,
stroke: colorTokenToCssVar('base.500'),
label: '',
};
export const makeEdgeSelector = (
templates: Templates,
source: string,
sourceHandleId: string | null | undefined,
target: string,
targetHandleId: string | null | undefined
) =>
createMemoizedSelector(
selectNodesSlice,
selectWorkflowSettingsSlice,
(
nodes,
workflowSettings
): { areConnectedNodesSelected: boolean; shouldAnimateEdges: boolean; stroke: string; label: string } => {
const { shouldAnimateEdges, shouldColorEdges } = workflowSettings;
const sourceNode = nodes.nodes.find((node) => node.id === source);
const targetNode = nodes.nodes.find((node) => node.id === target);
const returnValue = deepClone(defaultReturnValue);
returnValue.shouldAnimateEdges = shouldAnimateEdges;
const isInvocationToInvocationEdge = isInvocationNode(sourceNode) && isInvocationNode(targetNode);
returnValue.areConnectedNodesSelected = Boolean(sourceNode?.selected || targetNode?.selected);
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
return returnValue;
}
const sourceNodeTemplate = templates[sourceNode.data.type];
const targetNodeTemplate = templates[targetNode.data.type];
const outputFieldTemplate = sourceNodeTemplate?.outputs[sourceHandleId];
const sourceType = isInvocationToInvocationEdge ? outputFieldTemplate?.type : undefined;
returnValue.stroke = sourceType && shouldColorEdges ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
returnValue.label = `${sourceNodeTemplate?.title || sourceNode.data?.label} -> ${targetNodeTemplate?.title || targetNode.data?.label}`;
return returnValue;
}
);

View File

@@ -1,5 +1,6 @@
import { Flex, Image, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import type { NodeProps } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { DndImage } from 'features/dnd/DndImage';
@@ -12,7 +13,6 @@ import { motion } from 'framer-motion';
import type { CSSProperties, PropsWithChildren } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { NodeProps } from 'reactflow';
import { $lastProgressEvent } from 'services/events/stores';
const CurrentImageNode = (props: NodeProps) => {

View File

@@ -1,83 +1,121 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, Grid, GridItem } from '@invoke-ai/ui-library';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { useFieldNames } from 'features/nodes/hooks/useFieldNames';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate';
import { OutputFieldNodesEditorView } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView';
import {
useInputFieldNamesAnyOrDirect,
useInputFieldNamesConnection,
useInputFieldNamesMissing,
} from 'features/nodes/hooks/useInputFieldNamesByStatus';
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { memo } from 'react';
import InputField from './fields/InputField';
import OutputField from './fields/OutputField';
import { InputFieldEditModeNodes } from './fields/InputFieldEditModeNodes';
import InvocationNodeFooter from './InvocationNodeFooter';
import InvocationNodeHeader from './InvocationNodeHeader';
type Props = {
nodeId: string;
isOpen: boolean;
label: string;
type: string;
selected: boolean;
};
const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
const fieldNames = useFieldNames(nodeId);
const sx: SystemStyleObject = {
flexDirection: 'column',
w: 'full',
h: 'full',
py: 2,
gap: 1,
borderBottomRadius: 'base',
'&[data-with-footer="true"]': {
borderBottomRadius: 0,
},
};
const InvocationNode = ({ nodeId, isOpen }: Props) => {
const withFooter = useWithFooter(nodeId);
const outputFieldNames = useOutputFieldNames(nodeId);
return (
<NodeWrapper nodeId={nodeId} selected={selected}>
<InvocationNodeHeader nodeId={nodeId} isOpen={isOpen} label={label} selected={selected} type={type} />
<>
<InvocationNodeHeader nodeId={nodeId} isOpen={isOpen} />
{isOpen && (
<>
<Flex
layerStyle="nodeBody"
flexDirection="column"
w="full"
h="full"
py={2}
gap={1}
borderBottomRadius={withFooter ? 0 : 'base'}
>
<Flex layerStyle="nodeBody" sx={sx} data-with-footer={withFooter}>
<Flex flexDir="column" px={2} w="full" h="full">
<Grid gridTemplateColumns="1fr auto" gridAutoRows="1fr">
{fieldNames.connectionFields.map((fieldName, i) => (
<GridItem gridColumnStart={1} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.input-field`}>
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
</GridItem>
))}
{outputFieldNames.map((fieldName, i) => (
<GridItem gridColumnStart={2} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.output-field`}>
<OutputField nodeId={nodeId} fieldName={fieldName} />
</GridItem>
))}
<ConnectionFields nodeId={nodeId} />
<OutputFields nodeId={nodeId} />
</Grid>
{fieldNames.anyOrDirectFields.map((fieldName) => (
<InvocationInputFieldCheck
key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId}
fieldName={fieldName}
>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
))}
{fieldNames.missingFields.map((fieldName) => (
<InvocationInputFieldCheck
key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId}
fieldName={fieldName}
>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
))}
<AnyOrDirectFields nodeId={nodeId} />
<MissingFields nodeId={nodeId} />
</Flex>
</Flex>
{withFooter && <InvocationNodeFooter nodeId={nodeId} />}
</>
)}
</NodeWrapper>
</>
);
};
export default memo(InvocationNode);
const ConnectionFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useInputFieldNamesConnection(nodeId);
return (
<>
{fieldNames.map((fieldName, i) => (
<GridItem gridColumnStart={1} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.input-field`}>
<InputFieldGate nodeId={nodeId} fieldName={fieldName}>
<InputFieldEditModeNodes nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
</GridItem>
))}
</>
);
});
ConnectionFields.displayName = 'ConnectionFields';
const AnyOrDirectFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useInputFieldNamesAnyOrDirect(nodeId);
return (
<>
{fieldNames.map((fieldName) => (
<InputFieldGate key={`${nodeId}.${fieldName}.input-field`} nodeId={nodeId} fieldName={fieldName}>
<InputFieldEditModeNodes nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
))}
</>
);
});
AnyOrDirectFields.displayName = 'AnyOrDirectFields';
const MissingFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useInputFieldNamesMissing(nodeId);
return (
<>
{fieldNames.map((fieldName) => (
<InputFieldGate key={`${nodeId}.${fieldName}.input-field`} nodeId={nodeId} fieldName={fieldName}>
<InputFieldEditModeNodes nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
))}
</>
);
});
MissingFields.displayName = 'MissingFields';
const OutputFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useOutputFieldNames(nodeId);
return (
<>
{fieldNames.map((fieldName, i) => (
<GridItem gridColumnStart={2} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.output-field`}>
<OutputFieldGate nodeId={nodeId} fieldName={fieldName}>
<OutputFieldNodesEditorView nodeId={nodeId} fieldName={fieldName} />
</OutputFieldGate>
</GridItem>
))}
</>
);
});
OutputFields.displayName = 'OutputFields';

View File

@@ -1,40 +1,25 @@
import { useChakraThemeTokens } from 'common/hooks/useChakraThemeTokens';
import { Handle, Position } from '@xyflow/react';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { map } from 'lodash-es';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { memo } from 'react';
interface Props {
nodeId: string;
}
const hiddenHandleStyles: CSSProperties = { visibility: 'hidden' };
const collapsedHandleStyles: CSSProperties = {
borderWidth: 0,
borderRadius: '3px',
width: '1rem',
height: '1rem',
backgroundColor: 'var(--invoke-colors-base-600)',
zIndex: -1,
};
const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
const template = useNodeTemplate(nodeId);
const { base600 } = useChakraThemeTokens();
const dummyHandleStyles: CSSProperties = useMemo(
() => ({
borderWidth: 0,
borderRadius: '3px',
width: '1rem',
height: '1rem',
backgroundColor: base600,
zIndex: -1,
}),
[base600]
);
const collapsedTargetStyles: CSSProperties = useMemo(
() => ({ ...dummyHandleStyles, left: '-0.5rem' }),
[dummyHandleStyles]
);
const collapsedSourceStyles: CSSProperties = useMemo(
() => ({ ...dummyHandleStyles, right: '-0.5rem' }),
[dummyHandleStyles]
);
if (!template) {
return null;
@@ -47,7 +32,7 @@ const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
id={`${nodeId}-collapsed-target`}
isConnectable={false}
position={Position.Left}
style={collapsedTargetStyles}
style={collapsedHandleStyles}
/>
{map(template.inputs, (input) => (
<Handle
@@ -64,7 +49,7 @@ const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
id={`${nodeId}-collapsed-source`}
isConnectable={false}
position={Position.Right}
style={collapsedSourceStyles}
style={collapsedHandleStyles}
/>
{map(template.outputs, (output) => (
<Handle

View File

@@ -1,6 +1,6 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Flex, FormControlGroup } from '@invoke-ai/ui-library';
import { useHasImageOutput } from 'features/nodes/hooks/useHasImageOutput';
import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
@@ -15,7 +15,7 @@ type Props = {
const props: ChakraProps = { w: 'unset' };
const InvocationNodeFooter = ({ nodeId }: Props) => {
const hasImageOutput = useHasImageOutput(nodeId);
const hasImageOutput = useNodeHasImageOutput(nodeId);
const isCacheEnabled = useFeatureStatus('invocationCache');
return (
<Flex

View File

@@ -1,3 +1,4 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle';
@@ -5,29 +6,30 @@ import InvocationNodeClassificationIcon from 'features/nodes/components/flow/nod
import { memo } from 'react';
import InvocationNodeCollapsedHandles from './InvocationNodeCollapsedHandles';
import InvocationNodeInfoIcon from './InvocationNodeInfoIcon';
import { InvocationNodeInfoIcon } from './InvocationNodeInfoIcon';
import InvocationNodeStatusIndicator from './InvocationNodeStatusIndicator';
type Props = {
nodeId: string;
isOpen: boolean;
label: string;
type: string;
selected: boolean;
};
const sx: SystemStyleObject = {
borderTopRadius: 'base',
alignItems: 'center',
justifyContent: 'space-between',
h: 8,
textAlign: 'center',
color: 'base.200',
borderBottomRadius: 'base',
'&[data-is-open="true"]': {
borderBottomRadius: 0,
},
};
const InvocationNodeHeader = ({ nodeId, isOpen }: Props) => {
return (
<Flex
layerStyle="nodeHeader"
borderTopRadius="base"
borderBottomRadius={isOpen ? 0 : 'base'}
alignItems="center"
justifyContent="space-between"
h={8}
textAlign="center"
color="base.200"
>
<Flex layerStyle="nodeHeader" sx={sx} data-is-open={isOpen}>
<NodeCollapseButton nodeId={nodeId} isOpen={isOpen} />
<InvocationNodeClassificationIcon nodeId={nodeId} />
<NodeTitle nodeId={nodeId} />

View File

@@ -1,9 +1,10 @@
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { compare } from 'compare-versions';
import { useNode } from 'features/nodes/hooks/useNode';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiInfoBold } from 'react-icons/pi';
@@ -12,7 +13,7 @@ interface Props {
nodeId: string;
}
const InvocationNodeInfoIcon = ({ nodeId }: Props) => {
export const InvocationNodeInfoIcon = memo(({ nodeId }: Props) => {
const needsUpdate = useNodeNeedsUpdate(nodeId);
return (
@@ -20,96 +21,66 @@ const InvocationNodeInfoIcon = ({ nodeId }: Props) => {
<Icon as={PiInfoBold} display="block" boxSize={4} w={8} color={needsUpdate ? 'error.400' : 'base.400'} />
</Tooltip>
);
};
});
export default memo(InvocationNodeInfoIcon);
InvocationNodeInfoIcon.displayName = 'InvocationNodeInfoIcon';
const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
const node = useNode(nodeId);
const notes = useInvocationNodeNotes(nodeId);
const label = useNodeLabel(nodeId);
const version = useNodeVersion(nodeId);
const nodeTemplate = useNodeTemplate(nodeId);
const { t } = useTranslation();
const title = useMemo(() => {
if (node.data?.label && nodeTemplate?.title) {
return `${node.data.label} (${nodeTemplate.title})`;
if (label) {
return `${label} (${nodeTemplate.title})`;
}
if (node.data?.label && !nodeTemplate) {
return node.data.label;
}
if (!node.data?.label && nodeTemplate) {
return nodeTemplate.title;
}
return t('nodes.unknownNode');
}, [node.data.label, nodeTemplate, t]);
const versionComponent = useMemo(() => {
if (!isInvocationNode(node) || !nodeTemplate) {
return null;
}
if (!node.data.version) {
return (
<Text as="span" color="error.500">
{t('nodes.versionUnknown')}
</Text>
);
}
if (!nodeTemplate.version) {
return (
<Text as="span" color="error.500">
{t('nodes.version')} {node.data.version} ({t('nodes.unknownTemplate')})
</Text>
);
}
if (compare(node.data.version, nodeTemplate.version, '<')) {
return (
<Text as="span" color="error.500">
{t('nodes.version')} {node.data.version} ({t('nodes.updateNode')})
</Text>
);
}
if (compare(node.data.version, nodeTemplate.version, '>')) {
return (
<Text as="span" color="error.500">
{t('nodes.version')} {node.data.version} ({t('nodes.updateApp')})
</Text>
);
}
return (
<Text as="span">
{t('nodes.version')} {node.data.version}
</Text>
);
}, [node, nodeTemplate, t]);
if (!isInvocationNode(node)) {
return <Text fontWeight="semibold">{t('nodes.unknownNode')}</Text>;
}
return nodeTemplate.title;
}, [label, nodeTemplate.title]);
return (
<Flex flexDir="column">
<Text as="span" fontWeight="semibold">
{title}
</Text>
{nodeTemplate?.nodePack && (
<Text opacity={0.7}>
{t('nodes.nodePack')}: {nodeTemplate.nodePack}
</Text>
)}
<Text opacity={0.7} fontStyle="oblique 5deg">
{nodeTemplate?.description}
<Text opacity={0.7}>
{t('nodes.nodePack')}: {nodeTemplate.nodePack}
</Text>
{versionComponent}
{node.data?.notes && <Text>{node.data.notes}</Text>}
<Text opacity={0.7} fontStyle="oblique 5deg">
{nodeTemplate.description}
</Text>
<Version nodeVersion={version} templateVersion={nodeTemplate.version} />
{notes && <Text>{notes}</Text>}
</Flex>
);
});
TooltipContent.displayName = 'TooltipContent';
const Version = ({ nodeVersion, templateVersion }: { nodeVersion: string; templateVersion: string }) => {
const { t } = useTranslation();
if (compare(nodeVersion, templateVersion, '<')) {
return (
<Text as="span" color="error.500">
{t('nodes.version')} {nodeVersion} ({t('nodes.updateNode')})
</Text>
);
}
if (compare(nodeVersion, templateVersion, '>')) {
return (
<Text as="span" color="error.500">
{t('nodes.version')} {nodeVersion} ({t('nodes.updateApp')})
</Text>
);
}
return (
<Text as="span">
{t('nodes.version')} {nodeVersion}
</Text>
);
};

View File

@@ -1,31 +1,31 @@
import { FormControl, FormLabel, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useNode } from 'features/nodes/hooks/useNode';
import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes';
import { nodeNotesChanged } from 'features/nodes/store/nodesSlice';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const NotesTextarea = ({ nodeId }: { nodeId: string }) => {
type Props = {
nodeId: string;
};
export const InvocationNodeNotesTextarea = memo(({ nodeId }: Props) => {
const dispatch = useAppDispatch();
const node = useNode(nodeId);
const { t } = useTranslation();
const notes = useInvocationNodeNotes(nodeId);
const handleNotesChanged = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
dispatch(nodeNotesChanged({ nodeId, notes: e.target.value }));
},
[dispatch, nodeId]
);
if (!isInvocationNode(node)) {
return null;
}
return (
<FormControl orientation="vertical" h="full">
<FormLabel>{t('nodes.notes')}</FormLabel>
<Textarea value={node.data?.notes} onChange={handleNotesChanged} rows={10} resize="none" />
<Textarea value={notes} onChange={handleNotesChanged} rows={10} resize="none" />
</FormControl>
);
};
});
export default memo(NotesTextarea);
InvocationNodeNotesTextarea.displayName = 'InvocationNodeNotesTextarea';

View File

@@ -1,6 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Badge, CircularProgress, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library';
import { useExecutionState } from 'features/nodes/hooks/useExecutionState';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import type { NodeExecutionState } from 'features/nodes/types/invocation';
import { zNodeStatus } from 'features/nodes/types/invocation';
@@ -22,7 +22,7 @@ const circleStyles: SystemStyleObject = {
};
const InvocationNodeStatusIndicator = ({ nodeId }: Props) => {
const nodeExecutionState = useExecutionState(nodeId);
const nodeExecutionState = useNodeExecutionState(nodeId);
if (!nodeExecutionState) {
return null;

View File

@@ -1,6 +1,5 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import { useNodePack } from 'features/nodes/hooks/useNodePack';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import { memo } from 'react';
@@ -11,14 +10,13 @@ type Props = {
isOpen: boolean;
label: string;
type: string;
selected: boolean;
};
const InvocationNodeUnknownFallback = ({ nodeId, isOpen, label, type, selected }: Props) => {
const InvocationNodeUnknownFallback = ({ nodeId, isOpen, label, type }: Props) => {
const { t } = useTranslation();
const nodePack = useNodePack(nodeId);
return (
<NodeWrapper nodeId={nodeId} selected={selected}>
<>
<Flex
className={DRAG_HANDLE_CLASSNAME}
layerStyle="nodeHeader"
@@ -64,7 +62,7 @@ const InvocationNodeUnknownFallback = ({ nodeId, isOpen, label, type, selected }
</Flex>
</Flex>
)}
</NodeWrapper>
</>
);
};

View File

@@ -1,16 +1,17 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import type { Node, NodeProps } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import InvocationNode from 'features/nodes/components/flow/nodes/Invocation/InvocationNode';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import type { InvocationNodeData } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
import type { NodeProps } from 'reactflow';
import InvocationNodeUnknownFallback from './InvocationNodeUnknownFallback';
const InvocationNodeWrapper = (props: NodeProps<InvocationNodeData>) => {
const InvocationNodeWrapper = (props: NodeProps<Node<InvocationNodeData>>) => {
const { data, selected } = props;
const { id: nodeId, type, isOpen, label } = data;
const templates = useStore($templates);
@@ -27,11 +28,17 @@ const InvocationNodeWrapper = (props: NodeProps<InvocationNodeData>) => {
if (!hasTemplate) {
return (
<InvocationNodeUnknownFallback nodeId={nodeId} isOpen={isOpen} label={label} type={type} selected={selected} />
<NodeWrapper nodeId={nodeId} selected={selected}>
<InvocationNodeUnknownFallback nodeId={nodeId} isOpen={isOpen} label={label} type={type} />
</NodeWrapper>
);
}
return <InvocationNode nodeId={nodeId} isOpen={isOpen} label={label} type={type} selected={selected} />;
return (
<NodeWrapper nodeId={nodeId} selected={selected}>
<InvocationNode nodeId={nodeId} isOpen={isOpen} />
</NodeWrapper>
);
};
export default memo(InvocationNodeWrapper);

View File

@@ -1,7 +1,7 @@
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useHasImageOutput } from 'features/nodes/hooks/useHasImageOutput';
import { useIsIntermediate } from 'features/nodes/hooks/useIsIntermediate';
import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
import { useNodeIsIntermediate } from 'features/nodes/hooks/useNodeIsIntermediate';
import { nodeIsIntermediateChanged } from 'features/nodes/store/nodesSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
@@ -10,8 +10,8 @@ import { useTranslation } from 'react-i18next';
const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const hasImageOutput = useHasImageOutput(nodeId);
const isIntermediate = useIsIntermediate(nodeId);
const hasImageOutput = useNodeHasImageOutput(nodeId);
const isIntermediate = useNodeIsIntermediate(nodeId);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(
@@ -30,7 +30,7 @@ const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
return (
<FormControl className="nopan">
<FormLabel>{t('nodes.saveToGallery')} </FormLabel>
<FormLabel m={0}>{t('nodes.saveToGallery')} </FormLabel>
<Checkbox onChange={handleChange} isChecked={!isIntermediate} />
</FormControl>
);

View File

@@ -23,7 +23,7 @@ const UseCacheCheckbox = ({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
return (
<FormControl>
<FormLabel>{t('invocationCache.useCache')}</FormLabel>
<FormLabel m={0}>{t('invocationCache.useCache')}</FormLabel>
<Checkbox className="nopan" onChange={handleChange} isChecked={useCache} />
</FormControl>
);

View File

@@ -1,143 +0,0 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import {
Editable,
EditableInput,
EditablePreview,
Flex,
forwardRef,
Tooltip,
useEditableControls,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel';
import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { MouseEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FieldTooltipContent from './FieldTooltipContent';
interface Props {
nodeId: string;
fieldName: string;
kind: 'inputs' | 'outputs';
isInvalid?: boolean;
withTooltip?: boolean;
shouldDim?: boolean;
}
const EditableFieldTitle = forwardRef((props: Props, ref) => {
const { nodeId, fieldName, kind, isInvalid = false, withTooltip = false, shouldDim = false } = props;
const label = useFieldLabel(nodeId, fieldName);
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [localTitle, setLocalTitle] = useState(label || fieldTemplateTitle || t('nodes.unknownField'));
const handleSubmit = useCallback(
(newTitleRaw: string) => {
const newTitle = newTitleRaw.trim();
const finalTitle = newTitle || fieldTemplateTitle || t('nodes.unknownField');
setLocalTitle(finalTitle);
dispatch(fieldLabelChanged({ nodeId, fieldName, label: finalTitle }));
},
[fieldTemplateTitle, dispatch, nodeId, fieldName, t]
);
const handleChange = useCallback((newTitle: string) => {
setLocalTitle(newTitle);
}, []);
useEffect(() => {
// Another component may change the title; sync local title with global state
setLocalTitle(label || fieldTemplateTitle || t('nodes.unknownField'));
}, [label, fieldTemplateTitle, t]);
return (
<Editable
value={localTitle}
onChange={handleChange}
onSubmit={handleSubmit}
as={Flex}
ref={ref}
position="relative"
overflow="hidden"
alignItems="center"
justifyContent="flex-start"
gap={1}
w="full"
>
<Tooltip
label={withTooltip ? <FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="inputs" /> : undefined}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
>
<EditablePreview
fontWeight="semibold"
sx={editablePreviewStyles}
noOfLines={1}
color={isInvalid ? 'error.300' : 'base.300'}
opacity={shouldDim ? 0.5 : 1}
/>
</Tooltip>
<EditableInput className="nodrag" sx={editableInputStyles} />
<EditableControls />
</Editable>
);
});
const editableInputStyles: SystemStyleObject = {
p: 0,
w: 'full',
fontWeight: 'semibold',
color: 'base.100',
_focusVisible: {
p: 0,
textAlign: 'left',
boxShadow: 'none',
},
};
const editablePreviewStyles: SystemStyleObject = {
p: 0,
textAlign: 'left',
_hover: {
fontWeight: 'semibold !important',
},
};
export default memo(EditableFieldTitle);
const EditableControls = memo(() => {
const { isEditing, getEditButtonProps } = useEditableControls();
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
const { onClick } = getEditButtonProps();
if (!onClick) {
return;
}
onClick(e);
e.preventDefault();
},
[getEditButtonProps]
);
if (isEditing) {
return null;
}
return (
<Flex
onClick={handleClick}
position="absolute"
w="min-content"
h="full"
top={0}
insetInlineStart={0}
cursor="text"
/>
);
});
EditableControls.displayName = 'EditableControls';

View File

@@ -1,94 +0,0 @@
import { Tooltip } from '@invoke-ai/ui-library';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import type { ValidationResult } from 'features/nodes/store/util/validateConnection';
import { HANDLE_TOOLTIP_OPEN_DELAY, MODEL_TYPES } from 'features/nodes/types/constants';
import { type FieldInputTemplate, type FieldOutputTemplate, isSingle } from 'features/nodes/types/field';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { HandleType } from 'reactflow';
import { Handle, Position } from 'reactflow';
type FieldHandleProps = {
fieldTemplate: FieldInputTemplate | FieldOutputTemplate;
handleType: HandleType;
isConnectionInProgress: boolean;
isConnectionStartField: boolean;
validationResult: ValidationResult;
};
const FieldHandle = (props: FieldHandleProps) => {
const { fieldTemplate, handleType, isConnectionInProgress, isConnectionStartField, validationResult } = props;
const { t } = useTranslation();
const { name } = fieldTemplate;
const type = fieldTemplate.type;
const fieldTypeName = useFieldTypeName(type);
const styles: CSSProperties = useMemo(() => {
const isModelType = MODEL_TYPES.some((t) => t === type.name);
const color = getFieldColor(type);
const s: CSSProperties = {
backgroundColor: !isSingle(type) ? colorTokenToCssVar('base.900') : color,
position: 'absolute',
width: '1rem',
height: '1rem',
borderWidth: !isSingle(type) ? 4 : 0,
borderStyle: 'solid',
borderColor: color,
borderRadius: isModelType || type.batch ? 4 : '100%',
zIndex: 1,
transformOrigin: 'center',
};
if (type.batch) {
s.transform = 'rotate(45deg) translateX(-0.3rem) translateY(-0.3rem)';
}
if (handleType === 'target') {
s.insetInlineStart = '-1rem';
} else {
s.insetInlineEnd = '-1rem';
}
if (isConnectionInProgress && !isConnectionStartField && !validationResult.isValid) {
s.filter = 'opacity(0.4) grayscale(0.7)';
}
if (isConnectionInProgress && !validationResult.isValid) {
if (isConnectionStartField) {
s.cursor = 'grab';
} else {
s.cursor = 'not-allowed';
}
} else {
s.cursor = 'crosshair';
}
return s;
}, [handleType, isConnectionInProgress, isConnectionStartField, type, validationResult.isValid]);
const tooltip = useMemo(() => {
if (isConnectionInProgress && validationResult.messageTKey) {
return t(validationResult.messageTKey);
}
return fieldTypeName;
}, [fieldTypeName, isConnectionInProgress, t, validationResult.messageTKey]);
return (
<Tooltip
label={tooltip}
placement={handleType === 'target' ? 'start' : 'end'}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
>
<Handle
type={handleType}
id={name}
position={handleType === 'target' ? Position.Left : Position.Right}
style={styles}
/>
</Tooltip>
);
};
export default memo(FieldHandle);

View File

@@ -1,68 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
import {
selectWorkflowSlice,
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
} from 'features/nodes/store/workflowSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiMinusBold, PiPlusBold } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const value = useFieldValue(nodeId, fieldName);
const selectIsExposed = useMemo(
() =>
createSelector(selectWorkflowSlice, (workflow) => {
return Boolean(workflow.exposedFields.find((f) => f.nodeId === nodeId && f.fieldName === fieldName));
}),
[fieldName, nodeId]
);
const isExposed = useAppSelector(selectIsExposed);
const handleExposeField = useCallback(() => {
dispatch(workflowExposedFieldAdded({ nodeId, fieldName, value }));
}, [dispatch, fieldName, nodeId, value]);
const handleUnexposeField = useCallback(() => {
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
if (!isExposed) {
return (
<IconButton
variant="ghost"
tooltip={t('nodes.addLinearView')}
aria-label={t('nodes.addLinearView')}
icon={<PiPlusBold />}
onClick={handleExposeField}
pointerEvents="auto"
size="xs"
/>
);
} else {
return (
<IconButton
variant="ghost"
tooltip={t('nodes.removeLinearView')}
aria-label={t('nodes.removeLinearView')}
icon={<PiMinusBold />}
onClick={handleUnexposeField}
pointerEvents="auto"
size="xs"
/>
);
}
};
export default memo(FieldLinearViewToggle);

View File

@@ -1,42 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { isEqual } from 'lodash-es';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
const FieldResetToDefaultValueButton = ({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const value = useFieldValue(nodeId, fieldName);
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
const isDisabled = useMemo(() => {
return isEqual(value, fieldTemplate.default);
}, [value, fieldTemplate.default]);
const onClick = useCallback(() => {
dispatch(fieldValueReset({ nodeId, fieldName, value: fieldTemplate.default }));
}, [dispatch, fieldName, fieldTemplate.default, nodeId]);
return (
<IconButton
variant="ghost"
tooltip={t('nodes.resetToDefaultValue')}
aria-label={t('nodes.resetToDefaultValue')}
icon={<PiArrowCounterClockwiseBold />}
onClick={onClick}
isDisabled={isDisabled}
pointerEvents="auto"
size="xs"
/>
);
};
export default memo(FieldResetToDefaultValueButton);

View File

@@ -1,63 +0,0 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useFieldInputInstance } from 'features/nodes/hooks/useFieldInputInstance';
import { useFieldTemplate } from 'features/nodes/hooks/useFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { isFieldInputInstance, isFieldInputTemplate } from 'features/nodes/types/field';
import { startCase } from 'lodash-es';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
nodeId: string;
fieldName: string;
kind: 'inputs' | 'outputs';
}
const FieldTooltipContent = ({ nodeId, fieldName, kind }: Props) => {
const field = useFieldInputInstance(nodeId, fieldName);
const fieldTemplate = useFieldTemplate(nodeId, fieldName, kind);
const isInputTemplate = isFieldInputTemplate(fieldTemplate);
const fieldTypeName = useFieldTypeName(fieldTemplate?.type);
const { t } = useTranslation();
const fieldTitle = useMemo(() => {
if (isFieldInputInstance(field)) {
if (field.label && fieldTemplate?.title) {
return `${field.label} (${fieldTemplate.title})`;
}
if (field.label && !fieldTemplate) {
return field.label;
}
if (!field.label && fieldTemplate) {
return fieldTemplate.title;
}
return t('nodes.unknownField');
} else {
return fieldTemplate?.title || t('nodes.unknownField');
}
}, [field, fieldTemplate, t]);
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{fieldTitle}</Text>
{fieldTemplate && (
<Text opacity={0.7} fontStyle="oblique 5deg">
{fieldTemplate.description}
</Text>
)}
{fieldTypeName && (
<Text>
{t('parameters.type')}: {fieldTypeName}
</Text>
)}
{isInputTemplate && (
<Text>
{t('common.input')}: {startCase(fieldTemplate.input)}
</Text>
)}
</Flex>
);
};
export default memo(FieldTooltipContent);

View File

@@ -0,0 +1,25 @@
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 type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
export const FloatFieldInput = memo((props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
return (
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
flex="1 1 0"
/>
);
});
FloatFieldInput.displayName = 'FloatFieldInput ';

View File

@@ -0,0 +1,42 @@
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 type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
export const FloatFieldInputAndSlider = memo(
(props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
return (
<>
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
marks
withThumbTooltip
flex="1 1 0"
/>
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
flex="1 1 0"
/>
</>
);
}
);
FloatFieldInputAndSlider.displayName = 'FloatFieldInputAndSlider ';

View File

@@ -0,0 +1,27 @@
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 type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
export const FloatFieldSlider = memo((props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
return (
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
marks
withThumbTooltip
flex="1 1 0"
/>
);
});
FloatFieldSlider.displayName = 'FloatFieldSlider ';

View File

@@ -0,0 +1,65 @@
import { NUMPY_RAND_MAX } from 'app/constants';
import { useAppDispatch } from 'app/store/storeHooks';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { isNil } from 'lodash-es';
import { useCallback, useMemo } from 'react';
export const useFloatField = (props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { nodeId, field, fieldTemplate } = props;
const dispatch = useAppDispatch();
const onChange = useCallback(
(value: number) => {
dispatch(fieldFloatValueChanged({ nodeId, fieldName: field.name, value }));
},
[dispatch, field.name, nodeId]
);
const min = useMemo(() => {
let min = -NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.minimum)) {
min = fieldTemplate.minimum;
}
if (!isNil(fieldTemplate.exclusiveMinimum)) {
min = fieldTemplate.exclusiveMinimum + 0.01;
}
return min;
}, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum]);
const max = useMemo(() => {
let max = NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.maximum)) {
max = fieldTemplate.maximum;
}
if (!isNil(fieldTemplate.exclusiveMaximum)) {
max = fieldTemplate.exclusiveMaximum - 0.01;
}
return max;
}, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum]);
const step = useMemo(() => {
if (isNil(fieldTemplate.multipleOf)) {
return 0.1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
const fineStep = useMemo(() => {
if (isNil(fieldTemplate.multipleOf)) {
return 0.01;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
return {
defaultValue: fieldTemplate.default,
onChange,
value: field.value,
min,
max,
step,
fineStep,
};
};

View File

@@ -1,94 +0,0 @@
import { Flex, FormControl } from '@invoke-ai/ui-library';
import FieldResetToDefaultValueButton from 'features/nodes/components/flow/nodes/Invocation/fields/FieldResetToDefaultValueButton';
import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
import { useFieldIsInvalid } from 'features/nodes/hooks/useFieldIsInvalid';
import { memo, useCallback, useState } from 'react';
import EditableFieldTitle from './EditableFieldTitle';
import FieldHandle from './FieldHandle';
import FieldLinearViewToggle from './FieldLinearViewToggle';
import InputFieldRenderer from './InputFieldRenderer';
import { InputFieldWrapper } from './InputFieldWrapper';
interface Props {
nodeId: string;
fieldName: string;
}
const InputField = ({ nodeId, fieldName }: Props) => {
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
const [isHovered, setIsHovered] = useState(false);
const isInvalid = useFieldIsInvalid(nodeId, fieldName);
const { isConnected, isConnectionInProgress, isConnectionStartField, validationResult, shouldDim } =
useConnectionState({ nodeId, fieldName, kind: 'inputs' });
const onMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const onMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
if (fieldTemplate.input === 'connection' || isConnected) {
return (
<InputFieldWrapper shouldDim={shouldDim}>
<FormControl isInvalid={isInvalid} isDisabled={isConnected} px={2}>
<EditableFieldTitle
nodeId={nodeId}
fieldName={fieldName}
kind="inputs"
isInvalid={isInvalid}
withTooltip
shouldDim
/>
</FormControl>
<FieldHandle
fieldTemplate={fieldTemplate}
handleType="target"
isConnectionInProgress={isConnectionInProgress}
isConnectionStartField={isConnectionStartField}
validationResult={validationResult}
/>
</InputFieldWrapper>
);
}
return (
<InputFieldWrapper shouldDim={shouldDim}>
<FormControl
isInvalid={isInvalid}
isDisabled={isConnected}
// Without pointerEvents prop, disabled inputs don't trigger reactflow events. For example, when making a
// connection, the mouse up to end the connection won't fire, leaving the connection in-progress.
pointerEvents={isConnected ? 'none' : 'auto'}
orientation="vertical"
px={2}
>
<Flex flexDir="column" w="full" gap={1} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Flex gap={1}>
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="inputs" isInvalid={isInvalid} withTooltip />
{isHovered && <FieldResetToDefaultValueButton nodeId={nodeId} fieldName={fieldName} />}
{isHovered && <FieldLinearViewToggle nodeId={nodeId} fieldName={fieldName} />}
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
</FormControl>
{fieldTemplate.input !== 'direct' && (
<FieldHandle
fieldTemplate={fieldTemplate}
handleType="target"
isConnectionInProgress={isConnectionInProgress}
isConnectionStartField={isConnectionStartField}
validationResult={validationResult}
/>
)}
</InputFieldWrapper>
);
};
export default memo(InputField);

View File

@@ -0,0 +1,73 @@
import {
FormControl,
FormLabel,
IconButton,
Popover,
PopoverContent,
PopoverTrigger,
Textarea,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiNoteBold } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
export const InputFieldDescriptionPopover = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
return (
<Popover isLazy lazyBehavior="unmount">
<PopoverTrigger>
<IconButton
variant="ghost"
tooltip={t('nodes.description')}
aria-label={t('nodes.description')}
icon={<PiNoteBold />}
pointerEvents="auto"
size="xs"
/>
</PopoverTrigger>
<PopoverContent p={2} w={256}>
<Content nodeId={nodeId} fieldName={fieldName} />
</PopoverContent>
</Popover>
);
});
InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover';
const Content = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const description = useInputFieldDescription(nodeId, fieldName);
const onChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value }));
},
[dispatch, fieldName, nodeId]
);
return (
<FormControl orientation="vertical">
<FormLabel>{t('nodes.description')}</FormLabel>
<Textarea
className="nodrag nopan nowheel"
fontSize="sm"
value={description ?? ''}
onChange={onChange}
p={2}
resize="none"
rows={5}
/>
</FormControl>
);
});
Content.displayName = 'Content';

View File

@@ -0,0 +1,130 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, FormControl, Spacer } from '@invoke-ai/ui-library';
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 { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useRef, useState } from 'react';
import { InputFieldRenderer } from './InputFieldRenderer';
import { InputFieldTitle } from './InputFieldTitle';
import { InputFieldWrapper } from './InputFieldWrapper';
interface Props {
nodeId: string;
fieldName: string;
}
export const InputFieldEditModeNodes = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
const isInvalid = useInputFieldIsInvalid(nodeId, fieldName);
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
if (fieldTemplate.input === 'connection' || isConnected) {
return (
<ConnectedOrConnectionField
nodeId={nodeId}
fieldName={fieldName}
isInvalid={isInvalid}
isConnected={isConnected}
fieldTemplate={fieldTemplate}
/>
);
}
return (
<DirectField
nodeId={nodeId}
fieldName={fieldName}
isInvalid={isInvalid}
isConnected={isConnected}
fieldTemplate={fieldTemplate}
/>
);
});
InputFieldEditModeNodes.displayName = 'InputFieldEditModeNodes';
type CommonProps = {
nodeId: string;
fieldName: string;
isInvalid: boolean;
isConnected: boolean;
fieldTemplate: FieldInputTemplate;
};
const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid, isConnected }: CommonProps) => {
return (
<InputFieldWrapper>
<FormControl isInvalid={isInvalid} isDisabled={isConnected} px={2}>
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} />
</FormControl>
<InputFieldHandle nodeId={nodeId} fieldName={fieldName} />
</InputFieldWrapper>
);
});
ConnectedOrConnectionField.displayName = 'ConnectedOrConnectionField';
const directFieldSx: SystemStyleObject = {
orientation: 'vertical',
px: 2,
'&[data-is-dragging="true"]': {
opacity: 0.3,
},
// Without pointerEvents prop, disabled inputs don't trigger reactflow events. For example, when making a
// connection, the mouse up to end the connection won't fire, leaving the connection in-progress.
pointerEvents: 'auto',
'&[data-is-connected="true"]': {
pointerEvents: 'none',
},
};
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>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
</FormControl>
{fieldTemplate.input !== 'direct' && <InputFieldHandle nodeId={nodeId} fieldName={fieldName} />}
</InputFieldWrapper>
);
});
DirectField.displayName = 'DirectField';

View File

@@ -0,0 +1,23 @@
import { InputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldUnknownPlaceholder';
import { useInputFieldInstanceExists } from 'features/nodes/hooks/useInputFieldInstanceExists';
import { useInputFieldTemplateExists } from 'features/nodes/hooks/useInputFieldTemplateExists';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const InputFieldGate = memo(({ nodeId, fieldName, children }: Props) => {
const hasInstance = useInputFieldInstanceExists(nodeId, fieldName);
const hasTemplate = useInputFieldTemplateExists(nodeId, fieldName);
if (!hasTemplate || !hasInstance) {
return <InputFieldUnknownPlaceholder nodeId={nodeId} fieldName={fieldName} />;
}
return children;
});
InputFieldGate.displayName = 'InputFieldGate';

View File

@@ -0,0 +1,159 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Tooltip } from '@invoke-ai/ui-library';
import { Handle, Position } from '@xyflow/react';
import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
import {
useConnectionErrorTKey,
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY, MODEL_TYPES } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
nodeId: string;
fieldName: string;
};
const sx = {
position: 'relative',
width: 'full',
height: 'full',
borderStyle: 'solid',
borderWidth: 4,
pointerEvents: 'none',
'&[data-cardinality="SINGLE"]': {
borderWidth: 0,
},
borderRadius: '100%',
'&[data-is-model-field="true"], &[data-is-batch-field="true"]': {
borderRadius: 4,
},
'&[data-is-batch-field="true"]': {
transform: 'rotate(45deg)',
},
'&[data-is-connection-in-progress="true"][data-is-connection-start-field="false"][data-is-connection-valid="false"]':
{
filter: 'opacity(0.4) grayscale(0.7)',
cursor: 'not-allowed',
},
'&[data-is-connection-in-progress="true"][data-is-connection-start-field="true"][data-is-connection-valid="false"]': {
cursor: 'grab',
},
'&[data-is-connection-in-progress="false"] &[data-is-connection-valid="true"]': {
cursor: 'crosshair',
},
} satisfies SystemStyleObject;
const handleStyles = {
position: 'absolute',
width: '1rem',
height: '1rem',
zIndex: 1,
background: 'none',
border: 'none',
insetInlineStart: '-0.5rem',
} satisfies CSSProperties;
export const InputFieldHandle = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
const isModelField = useMemo(() => MODEL_TYPES.some((t) => t === fieldTemplate.type.name), [fieldTemplate.type]);
const isConnectionInProgress = useIsConnectionInProgress();
if (isConnectionInProgress) {
return (
<ConnectionInProgressHandle
nodeId={nodeId}
fieldName={fieldName}
fieldTemplate={fieldTemplate}
fieldTypeName={fieldTypeName}
fieldColor={fieldColor}
isModelField={isModelField}
/>
);
}
return (
<IdleHandle
nodeId={nodeId}
fieldName={fieldName}
fieldTemplate={fieldTemplate}
fieldTypeName={fieldTypeName}
fieldColor={fieldColor}
isModelField={isModelField}
/>
);
});
InputFieldHandle.displayName = 'InputFieldHandle';
type HandleCommonProps = {
nodeId: string;
fieldName: string;
fieldTemplate: FieldInputTemplate;
fieldTypeName: string;
fieldColor: string;
isModelField: boolean;
};
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
return (
<Tooltip label={fieldTypeName} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}
data-is-batch-field={fieldTemplate.type.batch}
data-is-model-field={isModelField}
data-is-connection-in-progress={false}
data-is-connection-start-field={false}
data-is-connection-valid={false}
backgroundColor={fieldTemplate.type.cardinality === 'SINGLE' ? fieldColor : 'base.900'}
borderColor={fieldColor}
/>
</Handle>
</Tooltip>
);
});
IdleHandle.displayName = 'IdleHandle';
const ConnectionInProgressHandle = memo(
({ nodeId, fieldName, fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
const { t } = useTranslation();
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');
const tooltip = useMemo(() => {
if (connectionError !== null) {
return t(connectionError);
}
return fieldTypeName;
}, [fieldTypeName, t, connectionError]);
return (
<Tooltip label={tooltip} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}
data-is-batch-field={fieldTemplate.type.batch}
data-is-model-field={isModelField}
data-is-connection-in-progress={true}
data-is-connection-start-field={isConnectionStartField}
data-is-connection-valid={connectionError === null}
backgroundColor={fieldTemplate.type.cardinality === 'SINGLE' ? fieldColor : 'base.900'}
borderColor={fieldColor}
/>
</Handle>
</Tooltip>
);
}
);
ConnectionInProgressHandle.displayName = 'ConnectionInProgressHandle';

View File

@@ -1,12 +1,21 @@
import { FloatFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput';
import { FloatFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider';
import { FloatFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldSlider';
import { FloatFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatFieldCollectionInputComponent';
import { FloatGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorFieldComponent';
import { ImageFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent';
import { IntegerFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerFieldCollectionInputComponent';
import { IntegerGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerGeneratorFieldComponent';
import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent';
import { NumberFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/NumberFieldCollectionInputComponent';
import { StringFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringFieldCollectionInputComponent';
import { StringGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringGeneratorFieldComponent';
import { useFieldInputInstance } from 'features/nodes/hooks/useFieldInputInstance';
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
import { IntegerFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput';
import { IntegerFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider';
import { IntegerFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider';
import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput';
import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import {
isBoardFieldInputInstance,
isBoardFieldInputTemplate,
@@ -77,6 +86,7 @@ import {
isVAEModelFieldInputInstance,
isVAEModelFieldInputTemplate,
} from 'features/nodes/types/field';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
import BoardFieldInputComponent from './inputs/BoardFieldInputComponent';
@@ -94,174 +104,295 @@ import ImageFieldInputComponent from './inputs/ImageFieldInputComponent';
import IPAdapterModelFieldInputComponent from './inputs/IPAdapterModelFieldInputComponent';
import LoRAModelFieldInputComponent from './inputs/LoRAModelFieldInputComponent';
import MainModelFieldInputComponent from './inputs/MainModelFieldInputComponent';
import NumberFieldInputComponent from './inputs/NumberFieldInputComponent';
import RefinerModelFieldInputComponent from './inputs/RefinerModelFieldInputComponent';
import SchedulerFieldInputComponent from './inputs/SchedulerFieldInputComponent';
import SD3MainModelFieldInputComponent from './inputs/SD3MainModelFieldInputComponent';
import SDXLMainModelFieldInputComponent from './inputs/SDXLMainModelFieldInputComponent';
import SpandrelImageToImageModelFieldInputComponent from './inputs/SpandrelImageToImageModelFieldInputComponent';
import StringFieldInputComponent from './inputs/StringFieldInputComponent';
import T2IAdapterModelFieldInputComponent from './inputs/T2IAdapterModelFieldInputComponent';
import T5EncoderModelFieldInputComponent from './inputs/T5EncoderModelFieldInputComponent';
import VAEModelFieldInputComponent from './inputs/VAEModelFieldInputComponent';
type InputFieldProps = {
type Props = {
nodeId: string;
fieldName: string;
settings?: NodeFieldElement['data']['settings'];
};
const InputFieldRenderer = ({ nodeId, fieldName }: InputFieldProps) => {
const fieldInstance = useFieldInputInstance(nodeId, fieldName);
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) => {
const field = useInputFieldInstance(nodeId, fieldName);
const template = useInputFieldTemplate(nodeId, fieldName);
if (isStringFieldCollectionInputInstance(fieldInstance) && isStringFieldCollectionInputTemplate(fieldTemplate)) {
return <StringFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
// When deciding which component to render, first we check the type of the template, which is more efficient than the
// instance type check. The instance type check uses zod and is slower.
if (isStringFieldCollectionInputTemplate(template)) {
if (!isStringFieldCollectionInputInstance(field)) {
return null;
}
return <StringFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isStringFieldInputInstance(fieldInstance) && isStringFieldInputTemplate(fieldTemplate)) {
return <StringFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isStringFieldInputTemplate(template)) {
if (!isStringFieldInputInstance(field)) {
return null;
}
if (settings?.type !== 'string-field-config') {
if (template.ui_component === 'textarea') {
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
} else {
return <StringFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
}
}
if (settings.component === 'input') {
return <StringFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'textarea') {
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
}
}
if (isBooleanFieldInputInstance(fieldInstance) && isBooleanFieldInputTemplate(fieldTemplate)) {
return <BooleanFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isBooleanFieldInputTemplate(template)) {
if (!isBooleanFieldInputInstance(field)) {
return null;
}
return <BooleanFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isIntegerFieldInputInstance(fieldInstance) && isIntegerFieldInputTemplate(fieldTemplate)) {
return <NumberFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isIntegerFieldInputTemplate(template)) {
if (!isIntegerFieldInputInstance(field)) {
return null;
}
if (settings?.type !== 'integer-field-config') {
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (settings.component === 'number-input') {
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'slider') {
return <IntegerFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'number-input-and-slider') {
return <IntegerFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
}
}
if (isFloatFieldInputInstance(fieldInstance) && isFloatFieldInputTemplate(fieldTemplate)) {
return <NumberFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isFloatFieldInputTemplate(template)) {
if (!isFloatFieldInputInstance(field)) {
return null;
}
if (settings?.type !== 'float-field-config') {
return <FloatFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (settings.component === 'number-input') {
return <FloatFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'slider') {
return <FloatFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'number-input-and-slider') {
return <FloatFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
}
}
if (isIntegerFieldCollectionInputInstance(fieldInstance) && isIntegerFieldCollectionInputTemplate(fieldTemplate)) {
return <NumberFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isIntegerFieldCollectionInputTemplate(template)) {
if (!isIntegerFieldCollectionInputInstance(field)) {
return null;
}
return <IntegerFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isFloatFieldCollectionInputInstance(fieldInstance) && isFloatFieldCollectionInputTemplate(fieldTemplate)) {
return <NumberFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isFloatFieldCollectionInputTemplate(template)) {
if (!isFloatFieldCollectionInputInstance(field)) {
return null;
}
return <FloatFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isEnumFieldInputInstance(fieldInstance) && isEnumFieldInputTemplate(fieldTemplate)) {
return <EnumFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isEnumFieldInputTemplate(template)) {
if (!isEnumFieldInputInstance(field)) {
return null;
}
return <EnumFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isImageFieldCollectionInputInstance(fieldInstance) && isImageFieldCollectionInputTemplate(fieldTemplate)) {
return <ImageFieldCollectionInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isImageFieldCollectionInputTemplate(template)) {
if (!isImageFieldCollectionInputInstance(field)) {
return null;
}
return <ImageFieldCollectionInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isImageFieldInputInstance(fieldInstance) && isImageFieldInputTemplate(fieldTemplate)) {
return <ImageFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isImageFieldInputTemplate(template)) {
if (!isImageFieldInputInstance(field)) {
return null;
}
return <ImageFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isBoardFieldInputInstance(fieldInstance) && isBoardFieldInputTemplate(fieldTemplate)) {
return <BoardFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isBoardFieldInputTemplate(template)) {
if (!isBoardFieldInputInstance(field)) {
return null;
}
return <BoardFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isMainModelFieldInputInstance(fieldInstance) && isMainModelFieldInputTemplate(fieldTemplate)) {
return <MainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isMainModelFieldInputTemplate(template)) {
if (!isMainModelFieldInputInstance(field)) {
return null;
}
return <MainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isModelIdentifierFieldInputInstance(fieldInstance) && isModelIdentifierFieldInputTemplate(fieldTemplate)) {
return <ModelIdentifierFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isModelIdentifierFieldInputTemplate(template)) {
if (!isModelIdentifierFieldInputInstance(field)) {
return null;
}
return <ModelIdentifierFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isSDXLRefinerModelFieldInputInstance(fieldInstance) && isSDXLRefinerModelFieldInputTemplate(fieldTemplate)) {
return <RefinerModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isSDXLRefinerModelFieldInputTemplate(template)) {
if (!isSDXLRefinerModelFieldInputInstance(field)) {
return null;
}
return <RefinerModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isVAEModelFieldInputInstance(fieldInstance) && isVAEModelFieldInputTemplate(fieldTemplate)) {
return <VAEModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isVAEModelFieldInputTemplate(template)) {
if (!isVAEModelFieldInputInstance(field)) {
return null;
}
return <VAEModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isT5EncoderModelFieldInputInstance(fieldInstance) && isT5EncoderModelFieldInputTemplate(fieldTemplate)) {
return <T5EncoderModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isT5EncoderModelFieldInputTemplate(template)) {
if (!isT5EncoderModelFieldInputInstance(field)) {
return null;
}
return <T5EncoderModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isCLIPEmbedModelFieldInputInstance(fieldInstance) && isCLIPEmbedModelFieldInputTemplate(fieldTemplate)) {
return <CLIPEmbedModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isCLIPEmbedModelFieldInputTemplate(template)) {
if (!isCLIPEmbedModelFieldInputInstance(field)) {
return null;
}
return <CLIPEmbedModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isCLIPLEmbedModelFieldInputInstance(fieldInstance) && isCLIPLEmbedModelFieldInputTemplate(fieldTemplate)) {
return <CLIPLEmbedModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isCLIPLEmbedModelFieldInputTemplate(template)) {
if (!isCLIPLEmbedModelFieldInputInstance(field)) {
return null;
}
return <CLIPLEmbedModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isCLIPGEmbedModelFieldInputInstance(fieldInstance) && isCLIPGEmbedModelFieldInputTemplate(fieldTemplate)) {
return <CLIPGEmbedModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isCLIPGEmbedModelFieldInputTemplate(template)) {
if (!isCLIPGEmbedModelFieldInputInstance(field)) {
return null;
}
return <CLIPGEmbedModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isControlLoRAModelFieldInputInstance(fieldInstance) && isControlLoRAModelFieldInputTemplate(fieldTemplate)) {
return <ControlLoRAModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isControlLoRAModelFieldInputTemplate(template)) {
if (!isControlLoRAModelFieldInputInstance(field)) {
return null;
}
return <ControlLoRAModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isFluxVAEModelFieldInputInstance(fieldInstance) && isFluxVAEModelFieldInputTemplate(fieldTemplate)) {
return <FluxVAEModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isFluxVAEModelFieldInputTemplate(template)) {
if (!isFluxVAEModelFieldInputInstance(field)) {
return null;
}
return <FluxVAEModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isLoRAModelFieldInputInstance(fieldInstance) && isLoRAModelFieldInputTemplate(fieldTemplate)) {
return <LoRAModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isLoRAModelFieldInputTemplate(template)) {
if (!isLoRAModelFieldInputInstance(field)) {
return null;
}
return <LoRAModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isControlNetModelFieldInputInstance(fieldInstance) && isControlNetModelFieldInputTemplate(fieldTemplate)) {
return <ControlNetModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isControlNetModelFieldInputTemplate(template)) {
if (!isControlNetModelFieldInputInstance(field)) {
return null;
}
return <ControlNetModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isIPAdapterModelFieldInputInstance(fieldInstance) && isIPAdapterModelFieldInputTemplate(fieldTemplate)) {
return <IPAdapterModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isIPAdapterModelFieldInputTemplate(template)) {
if (!isIPAdapterModelFieldInputInstance(field)) {
return null;
}
return <IPAdapterModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isT2IAdapterModelFieldInputInstance(fieldInstance) && isT2IAdapterModelFieldInputTemplate(fieldTemplate)) {
return <T2IAdapterModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isT2IAdapterModelFieldInputTemplate(template)) {
if (!isT2IAdapterModelFieldInputInstance(field)) {
return null;
}
return <T2IAdapterModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (
isSpandrelImageToImageModelFieldInputInstance(fieldInstance) &&
isSpandrelImageToImageModelFieldInputTemplate(fieldTemplate)
) {
return (
<SpandrelImageToImageModelFieldInputComponent
nodeId={nodeId}
field={fieldInstance}
fieldTemplate={fieldTemplate}
/>
);
if (isSpandrelImageToImageModelFieldInputTemplate(template)) {
if (!isSpandrelImageToImageModelFieldInputInstance(field)) {
return null;
}
return <SpandrelImageToImageModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isColorFieldInputInstance(fieldInstance) && isColorFieldInputTemplate(fieldTemplate)) {
return <ColorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isColorFieldInputTemplate(template)) {
if (!isColorFieldInputInstance(field)) {
return null;
}
return <ColorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isFluxMainModelFieldInputInstance(fieldInstance) && isFluxMainModelFieldInputTemplate(fieldTemplate)) {
return <FluxMainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isFluxMainModelFieldInputTemplate(template)) {
if (!isFluxMainModelFieldInputInstance(field)) {
return null;
}
return <FluxMainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isSD3MainModelFieldInputInstance(fieldInstance) && isSD3MainModelFieldInputTemplate(fieldTemplate)) {
return <SD3MainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isSD3MainModelFieldInputTemplate(template)) {
if (!isSD3MainModelFieldInputInstance(field)) {
return null;
}
return <SD3MainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isSDXLMainModelFieldInputInstance(fieldInstance) && isSDXLMainModelFieldInputTemplate(fieldTemplate)) {
return <SDXLMainModelFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isSDXLMainModelFieldInputTemplate(template)) {
if (!isSDXLMainModelFieldInputInstance(field)) {
return null;
}
return <SDXLMainModelFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isSchedulerFieldInputInstance(fieldInstance) && isSchedulerFieldInputTemplate(fieldTemplate)) {
return <SchedulerFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isSchedulerFieldInputTemplate(template)) {
if (!isSchedulerFieldInputInstance(field)) {
return null;
}
return <SchedulerFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isFloatGeneratorFieldInputInstance(fieldInstance) && isFloatGeneratorFieldInputTemplate(fieldTemplate)) {
return <FloatGeneratorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isFloatGeneratorFieldInputTemplate(template)) {
if (!isFloatGeneratorFieldInputInstance(field)) {
return null;
}
return <FloatGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isIntegerGeneratorFieldInputInstance(fieldInstance) && isIntegerGeneratorFieldInputTemplate(fieldTemplate)) {
return <IntegerGeneratorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isIntegerGeneratorFieldInputTemplate(template)) {
if (!isIntegerGeneratorFieldInputInstance(field)) {
return null;
}
return <IntegerGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (isStringGeneratorFieldInputInstance(fieldInstance) && isStringGeneratorFieldInputTemplate(fieldTemplate)) {
return <StringGeneratorFieldInputComponent nodeId={nodeId} field={fieldInstance} fieldTemplate={fieldTemplate} />;
if (isStringGeneratorFieldInputTemplate(template)) {
if (!isStringGeneratorFieldInputInstance(field)) {
return null;
}
return <StringGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (fieldTemplate) {
// Fallback for when there is no component for the type
return null;
}
};
return null;
});
export default memo(InputFieldRenderer);
InputFieldRenderer.displayName = 'InputFieldRenderer';

View File

@@ -0,0 +1,30 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useInputFieldDefaultValue } from 'features/nodes/hooks/useInputFieldDefaultValue';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
export const InputFieldResetToDefaultValueIconButton = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const { isValueChanged, resetToDefaultValue } = useInputFieldDefaultValue(nodeId, fieldName);
return (
<IconButton
variant="ghost"
tooltip={t('nodes.resetToDefaultValue')}
aria-label={t('nodes.resetToDefaultValue')}
icon={<PiArrowCounterClockwiseBold />}
pointerEvents="auto"
size="xs"
onClick={resetToDefaultValue}
isDisabled={!isValueChanged}
/>
);
});
InputFieldResetToDefaultValueIconButton.displayName = 'InputFieldResetToDefaultValueIconButton';

View File

@@ -0,0 +1,92 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Input, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { InputFieldTooltipContent } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent';
import {
useConnectionErrorTKey,
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
const labelSx: SystemStyleObject = {
p: 0,
fontWeight: 'semibold',
textAlign: 'left',
color: 'base.300',
_hover: {
fontWeight: 'semibold !important',
},
'&[data-is-invalid="true"]': {
color: 'error.300',
},
'&[data-is-disabled="true"]': {
opacity: 0.5,
},
};
interface Props {
nodeId: string;
fieldName: string;
isInvalid?: boolean;
}
export const InputFieldTitle = memo((props: Props) => {
const { nodeId, fieldName, isInvalid } = props;
const inputRef = useRef<HTMLInputElement>(null);
const label = useInputFieldLabel(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName);
const { t } = useTranslation();
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
const isConnectionInProgress = useIsConnectionInProgress();
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');
const dispatch = useAppDispatch();
const defaultTitle = useMemo(() => fieldTemplateTitle || t('nodes.unknownField'), [fieldTemplateTitle, t]);
const onChange = useCallback(
(label: string) => {
dispatch(fieldLabelChanged({ nodeId, fieldName, label }));
},
[dispatch, nodeId, fieldName]
);
const editable = useEditable({
value: label || defaultTitle,
defaultValue: defaultTitle,
onChange,
inputRef,
});
if (!editable.isEditing) {
return (
<Tooltip
label={<InputFieldTooltipContent nodeId={nodeId} fieldName={fieldName} />}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
>
<Text
sx={labelSx}
noOfLines={1}
data-is-invalid={isInvalid}
data-is-disabled={
(isConnectionInProgress && connectionError !== null && !isConnectionStartField) || isConnected
}
onDoubleClick={editable.startEditing}
>
{editable.value}
</Text>
</Tooltip>
);
}
return <Input ref={inputRef} variant="outline" {...editable.inputProps} />;
});
InputFieldTitle.displayName = 'InputFieldTitle';

View File

@@ -0,0 +1,49 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { startCase } from 'lodash-es';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
nodeId: string;
fieldName: string;
}
export const InputFieldTooltipContent = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const fieldInstance = useInputFieldInstance(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldTitle = useMemo(() => {
if (fieldInstance.label && fieldTemplate.title) {
return `${fieldInstance.label} (${fieldTemplate.title})`;
}
if (fieldInstance.label && !fieldTemplate.title) {
return fieldInstance.label;
}
return fieldTemplate.title;
}, [fieldInstance, fieldTemplate]);
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{fieldTitle}</Text>
<Text opacity={0.7} fontStyle="oblique 5deg">
{fieldTemplate.description}
</Text>
<Text>
{t('parameters.type')}: {fieldTypeName}
</Text>
<Text>
{t('common.input')}: {startCase(fieldTemplate.input)}
</Text>
</Flex>
);
});
InputFieldTooltipContent.displayName = 'InputFieldTooltipContent';

View File

@@ -0,0 +1,27 @@
import { FormControl, FormLabel } from '@invoke-ai/ui-library';
import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper';
import { useInputFieldName } from 'features/nodes/hooks/useInputFieldName';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
nodeId: string;
fieldName: string;
};
export const InputFieldUnknownPlaceholder = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const name = useInputFieldName(nodeId, fieldName);
return (
<InputFieldWrapper>
<FormControl isInvalid={true} alignItems="stretch" justifyContent="center" gap={2} h="full" w="full">
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
{t('nodes.unknownInput', { name })}
</FormLabel>
</FormControl>
</InputFieldWrapper>
);
});
InputFieldUnknownPlaceholder.displayName = 'InputFieldUnknownPlaceholder';

View File

@@ -1,27 +1,19 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type InputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean;
}>;
const sx = {
position: 'relative',
minH: 8,
py: 0.5,
alignItems: 'center',
w: 'full',
h: 'full',
} satisfies SystemStyleObject;
export const InputFieldWrapper = memo(({ shouldDim, children }: InputFieldWrapperProps) => {
return (
<Flex
position="relative"
minH={8}
py={0.5}
alignItems="center"
opacity={shouldDim ? 0.5 : 1}
transitionProperty="opacity"
transitionDuration="0.1s"
w="full"
h="full"
>
{children}
</Flex>
);
export const InputFieldWrapper = memo(({ children }: PropsWithChildren) => {
return <Flex sx={sx}>{children}</Flex>;
});
InputFieldWrapper.displayName = 'InputFieldWrapper';

View File

@@ -0,0 +1,27 @@
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 type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
export const IntegerFieldInput = memo(
(props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props);
return (
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
flex="1 1 0"
/>
);
}
);
IntegerFieldInput.displayName = 'IntegerFieldInput';

View File

@@ -0,0 +1,42 @@
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 type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
export const IntegerFieldInputAndSlider = memo(
(props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props);
return (
<>
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
marks
withThumbTooltip
flex="1 1 0"
/>
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
flex="1 1 0"
/>
</>
);
}
);
IntegerFieldInputAndSlider.displayName = 'IntegerFieldInputAndSlider';

View File

@@ -0,0 +1,29 @@
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 type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { memo } from 'react';
export const IntegerFieldSlider = memo(
(props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props);
return (
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className="nodrag"
marks
withThumbTooltip
flex="1 1 0"
/>
);
}
);
IntegerFieldSlider.displayName = 'IntegerFieldSlider';

View File

@@ -0,0 +1,65 @@
import { NUMPY_RAND_MAX } from 'app/constants';
import { useAppDispatch } from 'app/store/storeHooks';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { fieldIntegerValueChanged } from 'features/nodes/store/nodesSlice';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { isNil } from 'lodash-es';
import { useCallback, useMemo } from 'react';
export const useIntegerField = (props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { nodeId, field, fieldTemplate } = props;
const dispatch = useAppDispatch();
const onChange = useCallback(
(value: number) => {
dispatch(fieldIntegerValueChanged({ nodeId, fieldName: field.name, value: Math.floor(Number(value)) }));
},
[dispatch, field.name, nodeId]
);
const min = useMemo(() => {
let min = -NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.minimum)) {
min = fieldTemplate.minimum;
}
if (!isNil(fieldTemplate.exclusiveMinimum)) {
min = fieldTemplate.exclusiveMinimum + 1;
}
return min;
}, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum]);
const max = useMemo(() => {
let max = NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.maximum)) {
max = fieldTemplate.maximum;
}
if (!isNil(fieldTemplate.exclusiveMaximum)) {
max = fieldTemplate.exclusiveMaximum - 1;
}
return max;
}, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum]);
const step = useMemo(() => {
if (isNil(fieldTemplate.multipleOf)) {
return 1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
const fineStep = useMemo(() => {
if (isNil(fieldTemplate.multipleOf)) {
return 1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
return {
defaultValue: fieldTemplate.default,
onChange,
value: field.value,
min,
max,
step,
fineStep,
};
};

View File

@@ -1,59 +0,0 @@
import { Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const InvocationInputFieldCheck = memo(({ nodeId, fieldName, children }: Props) => {
const { t } = useTranslation();
const templates = useStore($templates);
const selector = useMemo(
() =>
createMemoizedSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const instance = node.data.inputs[fieldName];
const template = templates[node.data.type];
const fieldTemplate = template?.inputs[fieldName];
return {
name: instance?.label || fieldTemplate?.title || fieldName,
hasInstance: Boolean(instance),
hasTemplate: Boolean(fieldTemplate),
};
}),
[fieldName, nodeId, templates]
);
const { hasInstance, hasTemplate, name } = useAppSelector(selector);
if (!hasTemplate || !hasInstance) {
return (
<Flex position="relative" minH={8} py={0.5} alignItems="center" w="full" h="full">
<FormControl
isInvalid={true}
alignItems="stretch"
justifyContent="center"
flexDir="column"
gap={2}
h="full"
w="full"
>
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
{t('nodes.unknownInput', { name })}
</FormLabel>
</FormControl>
</Flex>
);
}
return children;
});
InvocationInputFieldCheck.displayName = 'InvocationInputFieldCheck';

View File

@@ -1,116 +0,0 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Circle, Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { useLinearViewFieldDnd } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd';
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
import EditableFieldTitle from './EditableFieldTitle';
import FieldTooltipContent from './FieldTooltipContent';
import InputFieldRenderer from './InputFieldRenderer';
type Props = {
fieldIdentifier: FieldIdentifier;
};
const sx = {
layerStyle: 'second',
alignItems: 'center',
position: 'relative',
borderRadius: 'base',
w: 'full',
p: 2,
'&[data-is-dragging=true]': {
opacity: 0.3,
},
transitionProperty: 'common',
} satisfies SystemStyleObject;
const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => {
const dispatch = useAppDispatch();
const { isValueChanged, onReset } = useFieldOriginalValue(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(fieldIdentifier.nodeId);
const { t } = useTranslation();
const handleRemoveField = useCallback(() => {
dispatch(workflowExposedFieldRemoved(fieldIdentifier));
}, [dispatch, fieldIdentifier]);
const ref = useRef<HTMLDivElement>(null);
const [dndListState, isDragging] = useLinearViewFieldDnd(ref, fieldIdentifier);
return (
<Box position="relative" w="full">
<Flex
ref={ref}
// This is used to trigger the post-move flash animation
data-field-name={`${fieldIdentifier.nodeId}-${fieldIdentifier.fieldName}`}
data-is-dragging={isDragging}
onMouseEnter={handleMouseOver}
onMouseLeave={handleMouseOut}
sx={sx}
>
<Flex flexDir="column" w="full">
<Flex alignItems="center" gap={2}>
<EditableFieldTitle nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} kind="inputs" />
<Spacer />
{isMouseOverNode && <Circle me={2} size={2} borderRadius="full" bg="invokeBlue.500" />}
{isValueChanged && (
<IconButton
aria-label={t('nodes.resetToDefaultValue')}
tooltip={t('nodes.resetToDefaultValue')}
variant="ghost"
size="sm"
onClick={onReset}
icon={<PiArrowCounterClockwiseBold />}
/>
)}
<Tooltip
label={
<FieldTooltipContent
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
kind="inputs"
/>
}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
>
<Flex h="full" alignItems="center">
<Icon fontSize="sm" color="base.300" as={PiInfoBold} />
</Flex>
</Tooltip>
<IconButton
aria-label={t('nodes.removeLinearView')}
tooltip={t('nodes.removeLinearView')}
variant="ghost"
size="sm"
onClick={handleRemoveField}
icon={<PiTrashSimpleBold />}
/>
</Flex>
<InputFieldRenderer nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
</Flex>
</Flex>
<DndListDropIndicator dndState={dndListState} />
</Box>
);
};
const LinearViewField = ({ fieldIdentifier }: Props) => {
return (
<InvocationInputFieldCheck nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<LinearViewFieldInternal fieldIdentifier={fieldIdentifier} />
</InvocationInputFieldCheck>
);
};
export default memo(LinearViewField);

View File

@@ -0,0 +1,32 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useInputFieldInitialFormValue } from 'features/nodes/hooks/useInputFieldInitialFormValue';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
type Props = {
element: NodeFieldElement;
};
export const NodeFieldElementResetToInitialValueIconButton = memo(({ element }: Props) => {
const { t } = useTranslation();
const { id, data } = element;
const { nodeId, fieldName } = data.fieldIdentifier;
const { isValueChanged, resetToInitialValue } = useInputFieldInitialFormValue(id, nodeId, fieldName);
return (
<IconButton
variant="link"
size="sm"
alignSelf="stretch"
tooltip={t('nodes.resetToDefaultValue')}
aria-label={t('nodes.resetToDefaultValue')}
icon={<PiArrowCounterClockwiseBold />}
onClick={resetToInitialValue}
isDisabled={!isValueChanged}
/>
);
});
NodeFieldElementResetToInitialValueIconButton.displayName = 'NodeFieldElementResetToInitialValueIconButton';

View File

@@ -1,82 +0,0 @@
import { Flex, FormControl, FormLabel, Tooltip } from '@invoke-ai/ui-library';
import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
import { useFieldOutputTemplate } from 'features/nodes/hooks/useFieldOutputTemplate';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import FieldHandle from './FieldHandle';
import FieldTooltipContent from './FieldTooltipContent';
interface Props {
nodeId: string;
fieldName: string;
}
const OutputField = ({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const fieldTemplate = useFieldOutputTemplate(nodeId, fieldName);
const { isConnected, isConnectionInProgress, isConnectionStartField, validationResult, shouldDim } =
useConnectionState({ nodeId, fieldName, kind: 'outputs' });
if (!fieldTemplate) {
return (
<OutputFieldWrapper shouldDim={shouldDim}>
<FormControl alignItems="stretch" justifyContent="space-between" gap={2} h="full" w="full">
<FormLabel display="flex" alignItems="center" h="full" color="error.300" mb={0} px={1} gap={2}>
{t('nodes.unknownOutput', {
name: fieldName,
})}
</FormLabel>
</FormControl>
</OutputFieldWrapper>
);
}
return (
<OutputFieldWrapper shouldDim={shouldDim}>
<Tooltip
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="outputs" />}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
shouldWrapChildren
>
<FormControl isDisabled={isConnected} pe={2}>
<FormLabel mb={0}>{fieldTemplate?.title}</FormLabel>
</FormControl>
</Tooltip>
<FieldHandle
fieldTemplate={fieldTemplate}
handleType="source"
isConnectionInProgress={isConnectionInProgress}
isConnectionStartField={isConnectionStartField}
validationResult={validationResult}
/>
</OutputFieldWrapper>
);
};
export default memo(OutputField);
type OutputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean;
}>;
const OutputFieldWrapper = memo(({ shouldDim, children }: OutputFieldWrapperProps) => (
<Flex
position="relative"
minH={8}
py={0.5}
alignItems="center"
opacity={shouldDim ? 0.5 : 1}
transitionProperty="opacity"
transitionDuration="0.1s"
justifyContent="flex-end"
>
{children}
</Flex>
));
OutputFieldWrapper.displayName = 'OutputFieldWrapper';

View File

@@ -0,0 +1,21 @@
import { OutputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldUnknownPlaceholder';
import { useOutputFieldTemplateExists } from 'features/nodes/hooks/useOutputFieldTemplateExists';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const OutputFieldGate = memo(({ nodeId, fieldName, children }: Props) => {
const hasTemplate = useOutputFieldTemplateExists(nodeId, fieldName);
if (!hasTemplate) {
return <OutputFieldUnknownPlaceholder nodeId={nodeId} fieldName={fieldName} />;
}
return children;
});
OutputFieldGate.displayName = 'OutputFieldGate';

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