Compare commits

..

118 Commits

Author SHA1 Message Date
Chenlei Hu
244cd3c920 1.2.9 (#285) 2024-08-03 14:25:16 -04:00
Chenlei Hu
0cf5e647af Fix copy paste of widget value (#284)
* Fix copy paste of widget value

* Fix ui tests

* Allow undefined group font size

* Update test expectations [skip ci]

* nit

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-08-03 14:22:43 -04:00
Chenlei Hu
f0f867481d Fix canvas not init issue (#283) 2024-08-03 10:48:54 -04:00
Chenlei Hu
d02b074fa3 Manage searchbox imp setting in Vue app (#282)
* Manage searchbox setting in vue

* nit
2024-08-03 10:31:10 -04:00
Chenlei Hu
e14d84526a 1.2.8 (#279) 2024-08-01 21:19:41 -04:00
Chenlei Hu
2aa9166079 Disable flux example workflow test (#278) 2024-08-01 21:17:28 -04:00
Chenlei Hu
3baa07e0a9 Update litegraph (Font size fix / Perf improvement) (#275) 2024-07-31 11:06:04 -04:00
Chenlei Hu
c494cd211e Allow INT/FLOAT represent list of numbers (#274) 2024-07-31 10:45:46 -04:00
Chenlei Hu
c00e2fd208 Allow input spec with extra values passthrough (#273)
* Allow input spec extra values passthrough

* Refine custom input spec

* nit

* nit
2024-07-31 09:51:32 -04:00
bymyself
d77343da83 Sync #4090 (#272) 2024-07-31 08:45:44 -04:00
Chenlei Hu
c611c15d40 Update README.md (#271) 2024-07-30 19:12:44 -04:00
Chenlei Hu
269686eebb 1.2.7 (#270) 2024-07-30 19:09:22 -04:00
Chenlei Hu
0e3590d017 Update litegraph (Batch link move with shift + drag) (#268)
* Refactor based on new event data format

* nit

* Add playwright tests

* Update test expectations [skip ci]

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-30 19:06:58 -04:00
Alistor
7d2d6df57b Add spellcheck option to Multiline widget, add Interrupt Queue keybind (#267)
* Add spellcheck option to Multiline widget and set to false by default

* Add Queue Interrupt Keybind

* Update keybinds.ts

Fixed indentation
2024-07-30 17:34:54 -04:00
Chenlei Hu
4462dabc63 Truncate JSON default value in node preview (#264) 2024-07-30 10:12:47 -04:00
Chenlei Hu
53bfc0c95a Block UI interaction when loading (#263) 2024-07-30 09:56:40 -04:00
Chenlei Hu
b78682689e Update litegraph (#262) 2024-07-30 09:25:27 -04:00
Chenlei Hu
6d1dce8255 1.2.6 (#261) 2024-07-29 18:38:51 -04:00
Chenlei Hu
73f4e5143d Attach isLeaf info (#260) 2024-07-29 17:49:57 -04:00
Chenlei Hu
7d75cc99ba Add sort button in node library sidebar tab (#259)
* Add sort button on node library

* tab template
2024-07-29 12:39:54 -04:00
Chenlei Hu
0aa7d0b99a Store spinner state in workspace state store (#256) 2024-07-29 10:54:22 -04:00
Chenlei Hu
66b690e5c8 Manage canvas element in Vue (#255)
* Manage canvas element in Vue

* nit

* Fix unittest
2024-07-29 10:29:29 -04:00
Chenlei Hu
6e27b884fc Fix node preview widget overflow (#254)
* Fix node preview widget overflow

* nit
2024-07-28 21:51:14 -04:00
Chenlei Hu
561162fb3e Update litegraph (Fix drag + alt copy node) (#253)
* Update litegraph

* Update github action

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-28 13:39:45 -04:00
Chenlei Hu
7c6bd7ed71 1.2.5 (#252) 2024-07-28 09:41:53 -04:00
Chenlei Hu
fc5bdf80b3 Fix no task message display (#251) 2024-07-28 09:39:17 -04:00
Chenlei Hu
033f242e43 Float workflow selection on top of sidebar (#250) 2024-07-28 09:37:00 -04:00
Chenlei Hu
304429b967 Update README.md (#249) 2024-07-28 08:20:41 -04:00
Chenlei Hu
6dbdb9baa6 Fix clientX/Y offset calculation (#248) 2024-07-28 07:50:00 -04:00
filtered
3e3e909e36 Fix "undo" incorrectly undoing text input (#247)
Fixes an issue where under certain conditions, the ComfyUI custom undo / redo functions would not run when intended to.

When trying to undo an action like deleting several nodes, instead the native browser undo runs - e.g. a textarea gets focus and the last typed text is undone.  Clicking outside the textarea and hitting ctrl + z again just keeps doing the same thing.
2024-07-28 06:57:05 -04:00
Chenlei Hu
e128f39760 1.2.4 (#246) 2024-07-27 22:36:22 -04:00
Chenlei Hu
f22713aeb0 1.2.3 (#244) 2024-07-27 22:32:06 -04:00
Chenlei Hu
74fd1c9abe Reduce history queue max length (#243) 2024-07-27 22:23:46 -04:00
Chenlei Hu
f4f0c960a3 Fix combo input default value (#242)
* Fix combo input default value

* Supress logs and fix failure
2024-07-27 22:17:42 -04:00
Chenlei Hu
c0875d066a Node library side bar tab (#237)
* Basic tree

* Add node filter

* Fix key issue

* Add icons

* Node count

* nit

* Add node preview basics

* Node preview

* Make comfy node in node lib draggable

* Set drop target

* Add node on drop

* Drop on dynamic location

* nit

* nit

* Fix hover preview issue

* nit

* More visual diff between node and folder

* Add playwright test

* Add dep

* Get rid of screenshot test
2024-07-27 21:28:48 -04:00
Chenlei Hu
980ed0083d Fix settings getter (#240) 2024-07-27 18:22:22 -04:00
Chenlei Hu
76be537351 Add pp.addNodeOnGraph to simplify adding node on canvas (#239) 2024-07-27 17:44:17 -04:00
Alexander Brown
424a5f7a86 Expose Queue Button on the menu. (#238) 2024-07-27 17:44:06 -04:00
Chenlei Hu
542e1c1f59 Move sidebar y-axis overflow style to container (#236) 2024-07-26 16:25:12 -04:00
Chenlei Hu
972ffe73e3 Use ComfyNodeDefImpl on nodeDefStore (#235) 2024-07-26 15:53:22 -04:00
Chenlei Hu
b4d7735855 Use ComfyNodeDefImpl on nodeSearchService (#233)
* Make nodeSearchService use ComfyNodeDefImpl

* Fix test
2024-07-26 13:41:24 -04:00
Chenlei Hu
9bcc08d7ab Allow duplicated output names (#232) 2024-07-26 11:57:55 -04:00
Chenlei Hu
a1750212e5 Make node def output class (#231)
* Refactor node def output

* Adjust test
2024-07-26 11:09:43 -04:00
Chenlei Hu
4dba1d3ab0 Fix splitter overlay z-index (#230) 2024-07-26 10:47:04 -04:00
Chenlei Hu
ee6eed1c1c Fix extension register tab with API (#229)
* Get rid of extension manager impl

* nit

* Test register tab
2024-07-26 10:29:20 -04:00
Chenlei Hu
8e1d3f3baa Use ComfyNodeDefImpl on NodePreview component (#228)
* store widgets

* Use node def impl
2024-07-25 23:15:03 -04:00
Chenlei Hu
dc13ed102b Reverse init order (#227) 2024-07-25 22:20:38 -04:00
Chenlei Hu
9d56bb4e0e Set default empty record for InputSpec (#225) 2024-07-25 21:29:22 -04:00
Chenlei Hu
c97ff6fd85 Add name field to BaseInputSpec (#226) 2024-07-25 21:07:16 -04:00
Chenlei Hu
0ec15ba101 Transform ComfyNodeDef to ComfyNodeDefImpl (#224) 2024-07-25 20:27:16 -04:00
Chenlei Hu
55d5ec8c25 (update) CSS formatting (#221)
Co-authored-by: dmx <vincent.f@intp.com>
2024-07-25 16:32:57 -04:00
Chenlei Hu
c6d2767af1 Transforms ComfyInputsSpec on nodes (#220)
* Convert input spec defs

* Fix test

* Add combo test

* import metadata
2024-07-25 13:50:55 -04:00
Chenlei Hu
e179f75387 Apply new code format standard (#217) 2024-07-25 10:10:18 -04:00
Chenlei Hu
19c70d95d3 Sidebar tab API for extensions (#215)
* Add extensionManager to manage tabs

* Fix null bug

* nit
2024-07-24 21:31:59 -04:00
Chenlei Hu
ebdd7b8e40 Use store to manage nodeSearchService (#214) 2024-07-24 12:00:23 -04:00
Chenlei Hu
b73fe80761 [Major Refactor] Use pinia store to manage setting & nodeDef (#202)
* Node def store and settings tore

* Fix initial values

* Remove legacy setting listen

* Fix searchbox test
2024-07-24 11:49:09 -04:00
Chenlei Hu
84d8c5fc16 Fix ws dev server URL (#213) 2024-07-24 11:01:22 -04:00
Chenlei Hu
5b4e96f6c5 Test theme toggle feature (#212)
* WIP

* Add test on theme toggle

* Add teardown logic

* Cleanup menu setting
2024-07-23 17:56:35 -04:00
Chenlei Hu
1b7db43f8a Format everything (#211) 2024-07-23 15:40:54 -04:00
Chenlei Hu
648e52e39c 1.2.2 (#209) 2024-07-23 14:21:37 -04:00
Chenlei Hu
89b195dc13 Only install chromium in github action (#210) 2024-07-23 14:21:26 -04:00
Chenlei Hu
69d95f6e46 Update litegraph (Fix auto connect slot) (#208)
* Update litegraph

* Update version again

* Add browser test for litegraph change

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-23 14:05:02 -04:00
Chenlei Hu
609d3fe279 Add i18n for side tool bar tooltips (#207)
* Add npm dep

* Add i18n for side bar tooltips
2024-07-23 10:43:10 -04:00
Chenlei Hu
9b36c6b254 Add side bar icon tooltip (#206) 2024-07-23 09:45:47 -04:00
Chenlei Hu
d87058babf 1.2.1 (#204) 2024-07-22 23:00:13 -04:00
Chenlei Hu
a71f7671ae Fix default setting issue for first time install (#203)
* Fix default setting issue for first time install

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-22 22:54:59 -04:00
Chenlei Hu
bd68617c82 Fix theme toggle (#200)
* Use builtin event on color change

* Fix theme toggle

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-22 16:46:02 -04:00
Chenlei Hu
189662cd7c 1.2.0 (#197) 2024-07-22 10:28:48 -04:00
Chenlei Hu
fc327fe071 Fix typo (#195) 2024-07-22 10:16:54 -04:00
Chenlei Hu
65740a30c5 v1.2.0 Side Bar & Menu rework (#189)
* Basic side tool bar skeleton + Theme toggle (#164)

* Side bar skeleton

* Fix grid layout

* nit

* Add theme toggle logic

* Change primevue color theme to blue to match beta menu UI

* Add litegraph canvas splitter overlay (#177)

* Add vue wrapper

* Splitter overlay

* Move teleport to side bar comp

* Toolbar placeholder

* Move settings button from top menu to side bar (#178)

* Reverse relationship between splitter overlay and sidebar component (#180)

* Reverse relationship between splitter overlay and sidebar component

* nit

* Remove border on splitter

* Fix canvas shift (#186)

* Move queue/history display to side bar (#185)

* Side bar placeholder

* Pinia store for queue items

* Flatten task item

* Fix schema

* computed

* Switch running / pending order

* Use class-transformer

* nit

* Show display status

* Add tag severity style

* Add execution time

* nit

* Rename to execution success

* Add time display

* Sort queue desc order

* nit

* Add remove item feature

* Load workflow

* Add confirmation popup

* Add empty table placeholder

* Remove beta menu UI's queue button/list

* Add tests on litegraph widget text truncate (#191)

* Add tests on litegraph widget text truncate

* Updated screenshots

* Revert port change

* Remove screenshots

* Update test expectations [skip ci]

* Add back menu.settingsGroup for compatibility (#192)

* Close side bar on menu location set as disabled (#194)

* Remove placeholder side bar tabs (#196)

---------

Co-authored-by: bymyself <abolkonsky.rem@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2024-07-22 10:15:41 -04:00
Chenlei Hu
1521cd47c8 Move searchbox position up to 25% to top of screen (#187) 2024-07-21 10:35:04 -04:00
pythongosssss
f18740d5e4 Sync PR (#182)
#133
2024-07-21 10:52:58 +01:00
Chenlei Hu
3fbffc1eb6 1.1.9 (#173) 2024-07-19 19:10:21 -04:00
Chenlei Hu
cb9042f9f9 Sync #4061 (#172) 2024-07-19 19:08:42 -04:00
Chenlei Hu
a99a833c38 Fix searchbox immediate dismiss issue (#171) 2024-07-19 19:00:52 -04:00
Chenlei Hu
a2afdd74b2 Mount vue app after comfy app init (#167)
* Mount vue app after comfy app

* Emit event when vue app loaded

* Dispatch event to window

* Fix test timeout

* Try observe variable

* Revert

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-19 18:47:53 -04:00
Chenlei Hu
396e0c9525 Sync #4044 (#168) 2024-07-19 15:47:53 -04:00
Chenlei Hu
050fd4eb32 Sync #4043 (#169) 2024-07-19 15:47:43 -04:00
Chenlei Hu
631a060fff Enable CI for dev* branches (#165) 2024-07-19 12:23:48 -04:00
Chenlei Hu
d0030e1185 1.1.8 (#163) 2024-07-18 17:47:24 -04:00
Chenlei Hu
71ac0dcccc Allow dynamic widgets values (#162) 2024-07-18 17:45:42 -04:00
Chenlei Hu
ab7436f87c Update litegraph to 0.7.26 (#161)
* Update litegraph

* Test node order

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-18 17:34:35 -04:00
Chenlei Hu
9961be1bc7 Validate node def from /object_info endpoint (#159)
* Validate node def

* nit

* nit

* More tests
2024-07-18 12:20:47 -04:00
Chenlei Hu
2568746071 Add node search service test (#158) 2024-07-18 10:13:05 -04:00
Chenlei Hu
dea9af8650 Display frontend version in settings dialog (#157)
* Display frontend version in settings dialog

* Change execution order
2024-07-18 10:09:15 -04:00
Chenlei Hu
c2e7ef11ec 1.1.7 (#153) 2024-07-17 22:35:31 -04:00
Chenlei Hu
54246d37b0 Relands "Fix node searchbox default value setting" (#152)
* Revert "Revert "Fix node searchbox default value setting (#150)" (#151)"

This reverts commit bb02f935ff.

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-17 22:34:03 -04:00
Chenlei Hu
bb02f935ff Revert "Fix node searchbox default value setting (#150)" (#151)
This reverts commit 3dfef8a73e.
2024-07-17 22:30:36 -04:00
Chenlei Hu
3dfef8a73e Fix node searchbox default value setting (#150) 2024-07-17 22:23:49 -04:00
Chenlei Hu
24cdb6ad2d Convert legacy format node.widget_values (#149) 2024-07-17 22:05:16 -04:00
Chenlei Hu
7619e9159b Convert pos object to array on parsing (#147) 2024-07-17 20:57:14 -04:00
Chenlei Hu
05d5896c82 1.1.6 (#146) 2024-07-17 17:31:05 -04:00
Chenlei Hu
1706476dca 1.1.5 (#145) 2024-07-17 17:28:53 -04:00
Chenlei Hu
31f4ee332a Fix bypass display issue (#144)
* Fix bypass display issue

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-17 17:27:24 -04:00
Chenlei Hu
6b2acc146d Fix zod parsing for pos in workflow (#142)
* Fix zod parsing for pos in workflow

* Add test

* Fix test
2024-07-17 16:02:49 -04:00
Chenlei Hu
edb1349bb5 1.1.4 (#139) 2024-07-17 00:22:21 -04:00
Chenlei Hu
3fbaeb1fb8 Fix custom node def validation issue (#137)
* Fix nodeDef.output undefined

* fix2

* nit
2024-07-16 22:40:25 -04:00
Chenlei Hu
2ab3c2bca1 Update README on how to use (#135) 2024-07-16 14:36:08 -04:00
Chenlei Hu
13cda7de41 Annotate settings.ts (#134)
* Annotate settings.ts

* nit
2024-07-16 11:21:23 -04:00
bymyself
e216fa82c5 Fix search preview's widget text wrap (#132) 2024-07-14 19:46:25 -04:00
Chenlei Hu
900c4d9ca5 1.1.3 (#130) 2024-07-13 16:16:03 -04:00
Chenlei Hu
d49c68e7bf Auto release on release PR merge (#129)
* wip

* Add release workflow

* nit
2024-07-13 16:13:34 -04:00
Chenlei Hu
93a0c1012f Add system node to serach box db (#128) 2024-07-13 09:25:58 -04:00
Chenlei Hu
5fac7c9365 Fix vue warning on key (#127) 2024-07-13 09:04:16 -04:00
Chenlei Hu
f1acdf976a Shift node color's brightness for light mode (#123)
* Shift node color's brightness for light mode

* nit

* Fix test
2024-07-13 09:01:35 -04:00
Chenlei Hu
15900cd523 Fix filter dialog color in light mode (#126) 2024-07-13 08:58:48 -04:00
Chenlei Hu
55431d1e4f Avoid conflict with N-SideBar (#122)
* Avoid conflict with N-SideBar

* nit
2024-07-12 17:32:51 -04:00
Chenlei Hu
dc22765c8f 1.1.2 (#119) 2024-07-12 14:46:07 -04:00
Chenlei Hu
5a998cd7fb Relative scroll height (#118)
* Relative scroll height

* nit

* nit
2024-07-12 14:40:24 -04:00
Chenlei Hu
605faf0a93 Node preview on focus (#116)
* Basic preview

* Adjust position

* Fix node display

* nit

* handle combo default value

* nit

* Custom AutoComplete
2024-07-12 13:30:25 -04:00
Chenlei Hu
3ac793b931 1.1.1 (#115) 2024-07-12 09:27:21 -04:00
Chenlei Hu
dbd0e3ef68 Bind ComfyUI frontend version to window (#113) 2024-07-11 20:55:10 -04:00
Chenlei Hu
04aad417fc Prevent spinner when failed to mount (#112) 2024-07-11 20:34:51 -04:00
Chenlei Hu
86ee0767c3 Auto link node on creation (#110)
* Auto link node on creation

* Handle corner case

* Add some browser tests

* Add auto link test

* Force enable

* Confirm setting before running test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-11 12:25:46 -04:00
Chenlei Hu
ffc4f0c98e Make search box in modal (#109) 2024-07-10 22:43:48 -04:00
Chenlei Hu
9b276a6237 No searchbox on link release unless shift pressed (#108) 2024-07-10 20:37:59 -04:00
Chenlei Hu
99193e4b52 1.1.0 (#107) 2024-07-10 19:53:25 -04:00
Chenlei Hu
a28ac0c0fa New searchbox with fuzzy search (WIP) (#83)
* Disable default searchbox

Add headlessui and vue

Add vite's vue plugin

Vue app helloworld

Format vue

Add vue shim

minimal working searchbox

Add primevue dark mode

Use primevue

nit

Add fuse fuzzy search

Add tailwindcss / center searchbox

Fix style

Add node source chip

desc text wrapping

Add placeholder

inputbox filter support wip

Revert some filter designs

Add filter modal

Drop down show all nodes

Change modal font

Add filtered search

nit

Complete on focus

Auto fill filterOption

Fix dropdown

Fix z-index

Fix search bug

Properly remove chip

Adjust node source detection

Resolve merge conflict

nit

* Refactor

* Use badge to display filter type

* nit

* Trigger on canvas event

* nit

* Auto add data type filter when link released

* nit

* Auto focus when shown

* Focus on add/remvoe filter

* close dialog when escape pressed

* Add node at fixed location

* nit

* Update litegraph

* nit

* Change theme

* Increase search limit

* Add node on event location

* Clear filter when dialog closed

* Enable/Disable new search bx

* Improve app loading

* Fix copy node

* Update test expectations

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-10 19:46:35 -04:00
198 changed files with 17362 additions and 11628 deletions

View File

@@ -7,8 +7,8 @@ PLAYWRIGHT_TEST_URL=http://localhost:5173
DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# The target ComfyUI checkout directory to deploy the frontend code to.
# The dist directory will be copied to {DEPLOY_COMFY_UI_DIR}/custom_web_versions/main/dev
# Add `--front-end-root {DEPLOY_COMFY_UI_DIR}/custom_web_versions/main/dev`
# The dist directory will be copied to {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev
# Add `--front-end-root {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev`
# to ComfyUI launch script to serve the custom web version.
DEPLOY_COMFYUI_DIR=/home/ComfyUI/web

43
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Create Release Draft
on:
pull_request:
types: [closed]
branches:
- main
- master
paths:
- "package.json"
jobs:
draft_release:
runs-on: ubuntu-latest
if: >
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'Release')
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: lts/*
- name: Get current version
id: current_version
run: echo ::set-output name=version::$(node -p "require('./package.json').version")
- name: Build project
run: |
npm ci
npm run build
npm run zipdist
- name: Create release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: |
dist.zip
tag_name: v${{ steps.current_version.outputs.version }}
draft: true
prerelease: false
make_latest: "true"

View File

@@ -21,7 +21,7 @@ jobs:
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v4
with:
repository: "huchenlei/ComfyUI_frontend"
repository: "Comfy-Org/ComfyUI_frontend"
path: "ComfyUI_frontend"
ref: ${{ github.head_ref }}
- uses: actions/setup-node@v3
@@ -50,7 +50,7 @@ jobs:
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests and update snapshots
id: playwright-tests

View File

@@ -2,9 +2,15 @@ name: Tests CI
on:
push:
branches: [ main, master ]
branches:
- main
- master
- 'dev*'
pull_request:
branches: [ main, master ]
branches:
- main
- master
- 'dev*'
jobs:
test:
@@ -30,7 +36,9 @@ jobs:
with:
repository: "comfyanonymous/ComfyUI_examples"
path: "ComfyUI_frontend/tests-ui/ComfyUI_examples"
ref: master
# Re-enable tracking latest master branch after fixing the issue
# https://github.com/Comfy-Org/ComfyUI_frontend/issues/277
ref: 58b2e103bb8e424b66044fd07f1d3a6d80834ed4
- name: Skip CI
if: contains(steps.commit-message.outputs.message, '[skip ci]')
run: echo "Skipping CI as commit contains '[skip ci]'"
@@ -68,7 +76,7 @@ jobs:
npm test -- --verbose
working-directory: ComfyUI_frontend
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests
run: npx playwright test

View File

@@ -1,4 +1,6 @@
{
"semi": true,
"trailingComma": "es5"
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none"
}

108
README.md
View File

@@ -2,6 +2,102 @@
Front-end of [ComfyUI](https://github.com/comfyanonymous/ComfyUI) modernized. This repo is fully compatible with the existing extension system.
## How To Use
Add command line argument `--front-end-version Comfy-Org/ComfyUI_frontend@latest` to your
ComfyUI launch script.
For Windows stand-alone build users, please edit the `run_cpu.bat` / `run_nvidia_gpu.bat` file as following
```bat
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --front-end-version Comfy-Org/ComfyUI_frontend@latest
pause
```
## Release Summary
### Major features
<details>
<summary>v1.2.4: Node library sidebar tab</summary>
#### Drag & Drop
https://github.com/user-attachments/assets/853e20b7-bc0e-49c9-bbce-a2ba7566f92f
#### Filter
https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
</details>
<details>
<summary>v1.2.0: Queue/History sidebar tab</summary>
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
</details>
<details>
<summary>v1.1.0: Node search box</summary>
#### Fuzzy search & Node preview
![image](https://github.com/user-attachments/assets/94733e32-ea4e-4a9c-b321-c1a05db48709)
#### Release link with shift
https://github.com/user-attachments/assets/a1b2b5c3-10d1-4256-b620-345de6858f25
</details>
### QoL changes
<details>
<summary>v1.2.7: **Litegraph** drags multiple links with shift pressed</summary>
https://github.com/user-attachments/assets/68826715-bb55-4b2a-be6e-675cfc424afe
https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
</details>
<details>
<summary>v1.2.2: **Litegraph** auto connects to correct slot</summary>
#### Before
https://github.com/user-attachments/assets/c253f778-82d5-4e6f-aec0-ea2ccf421651
#### After
https://github.com/user-attachments/assets/b6360ac0-f0d2-447c-9daa-8a2e20c0dc1d
</details>
<details>
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
</details>
### Node developers API
<details>
<summary>v1.2.4: Extension API to register custom side bar tab</summary>
Extensions now can call following API to register a sidebar tab.
```js
app.extensionManager.registerSidebarTab({
id: "search",
icon: "pi pi-search",
title: "search",
tooltip: "search",
type: "custom",
render: (el) => {
el.innerHTML = "<div>Custom search tab</div>";
},
});
```
The list of supported icons can be find here: https://primevue.org/icons/#list
We will support custom icon later.
![image](https://github.com/user-attachments/assets/7bff028a-bf91-4cab-bf97-55c243b3f5e0)
</details>
## Road Map
### What has been done
@@ -12,19 +108,19 @@ Front-end of [ComfyUI](https://github.com/comfyanonymous/ComfyUI) modernized. Th
- Front-end dev server.
- Zod schema for input validation on ComfyUI workflow.
- Make litegraph a npm dependency. <https://github.com/Comfy-Org/ComfyUI_frontend/pull/89>
- Introduce Vue to start managing part of the UI.
- Easy install and version management (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
- Better node management. Sherlock <https://github.com/Nuked88/ComfyUI-N-Sidebar>.
### What to be done
- Replace the existing ComfyUI front-end impl (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
- Replace the existing ComfyUI front-end impl
- Remove `@ts-ignore`s.
- Turn on `strict` on `tsconfig.json`.
- Introduce Vue to start managing part of the UI.
- Starting with node search box revamp
- Introduce a UI library to add more widget types for node developers.
- Add more widget types for node developers.
- LLM streaming node.
- Linear mode (Similar to InvokeAI's linear mode).
- Better node search. Sherlock https://github.com/Nuked88/ComfyUI-N-Sidebar.
- Keybinding settings management. Register keybindings API for custom nodes.
- New extensions API for adding UI-related features.

View File

@@ -3,13 +3,6 @@
"@babel/preset-env"
],
"plugins": [
"babel-plugin-transform-import-meta",
[
"transform-rename-import",
{
"original": "^(.+?)\\.js$",
"replacement": "$1"
}
]
"babel-plugin-transform-import-meta"
]
}

View File

@@ -1,46 +1,204 @@
import type { Page, Locator } from '@playwright/test';
import { test as base } from '@playwright/test';
import dotenv from "dotenv";
dotenv.config();
import type { Page, Locator } from '@playwright/test'
import { test as base } from '@playwright/test'
import dotenv from 'dotenv'
dotenv.config()
interface Position {
x: number;
y: number;
x: number
y: number
}
interface Size {
width: number
height: number
}
class ComfyNodeSearchBox {
public readonly input: Locator
public readonly dropdown: Locator
constructor(public readonly page: Page) {
this.input = page.locator(
'.comfy-vue-node-search-container input[type="text"]'
)
this.dropdown = page.locator(
'.comfy-vue-node-search-container .p-autocomplete-list'
)
}
async fillAndSelectFirstNode(nodeName: string) {
await this.input.waitFor({ state: 'visible' })
await this.input.fill(nodeName)
await this.dropdown.waitFor({ state: 'visible' })
// Wait for some time for the auto complete list to update.
// The auto complete list is debounced and may take some time to update.
await this.page.waitForTimeout(500)
await this.dropdown.locator('li').nth(0).click()
}
}
class NodeLibrarySideBarTab {
public readonly tabId: string = 'node-library'
constructor(public readonly page: Page) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
)
}
get nodeLibraryTree() {
return this.page.locator('.node-lib-tree')
}
get nodePreview() {
return this.page.locator('.node-lib-node-preview')
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async toggleFirstFolder() {
await this.page.locator('.p-tree-node-toggle-button').nth(0).click()
}
}
class ComfyMenu {
public readonly sideToolBar: Locator
public readonly themeToggleButton: Locator
constructor(public readonly page: Page) {
this.sideToolBar = page.locator('.side-tool-bar-container')
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
}
get nodeLibraryTab() {
return new NodeLibrarySideBarTab(this.page)
}
async toggleTheme() {
await this.themeToggleButton.click()
await this.page.evaluate(() => {
return new Promise((resolve) => {
window['app'].ui.settings.addEventListener(
'Comfy.ColorPalette.change',
resolve,
{ once: true }
)
setTimeout(resolve, 5000)
})
})
}
async getThemeId() {
return await this.page.evaluate(async () => {
return await window['app'].ui.settings.getSettingValue(
'Comfy.ColorPalette'
)
})
}
}
export class ComfyPage {
public readonly url: string;
public readonly url: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator;
public readonly widgetTextBox: Locator;
public readonly canvas: Locator
public readonly widgetTextBox: Locator
// Buttons
public readonly resetViewButton: Locator;
public readonly resetViewButton: Locator
constructor(
public readonly page: Page,
) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188';
this.canvas = page.locator('#graph-canvas');
this.widgetTextBox = page.getByPlaceholder('text').nth(1);
this.resetViewButton = page.getByRole('button', { name: 'Reset View' });
// Inputs
public readonly workflowUploadInput: Locator
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
constructor(public readonly page: Page) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.canvas = page.locator('#graph-canvas')
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
}
async getGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return window['app']?.graph?._nodes?.length || 0
})
}
async setup() {
await this.goto()
// Unify font for consistent screenshots.
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
content: `
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}`
})
await this.page.waitForFunction(() => document.fonts.ready)
await this.page.waitForFunction(
() => window['app'] !== undefined && window['app'].vueAppReady
)
await this.page.evaluate(() => {
window['app']['canvas'].show_info = false
})
await this.nextFrame()
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await this.resetView()
}
async realod() {
await this.page.reload({ timeout: 15000 })
await this.setup()
}
async goto() {
await this.page.goto(this.url);
await this.page.goto(this.url)
}
async nextFrame() {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame);
});
return new Promise<number>(requestAnimationFrame)
})
}
async loadWorkflow(workflowName: string) {
await this.workflowUploadInput.setInputFiles(
`./browser_tests/assets/${workflowName}.json`
)
await this.nextFrame()
}
async resetView() {
await this.resetViewButton.click();
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
// Avoid "Reset View" button highlight.
await this.page.mouse.move(10, 10);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async clickTextEncodeNode1() {
@@ -49,8 +207,8 @@ export class ComfyPage {
x: 618,
y: 191
}
});
await this.nextFrame();
})
await this.nextFrame()
}
async clickTextEncodeNode2() {
@@ -59,8 +217,8 @@ export class ComfyPage {
x: 622,
y: 400
}
});
await this.nextFrame();
})
await this.nextFrame()
}
async clickEmptySpace() {
@@ -69,84 +227,81 @@ export class ComfyPage {
x: 35,
y: 31
}
});
await this.nextFrame();
})
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y);
await this.page.mouse.down();
await this.page.mouse.move(target.x, target.y);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y)
await this.page.mouse.up()
await this.nextFrame()
}
async dragNode2() {
await this.dragAndDrop(
{ x: 622, y: 400 },
{ x: 622, y: 300 },
);
await this.nextFrame();
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
await this.nextFrame()
}
async disconnectEdge() {
// CLIP input anchor
await this.page.mouse.move(427, 198);
await this.page.mouse.down();
await this.page.mouse.move(427, 98);
await this.page.mouse.up();
await this.page.mouse.move(427, 198)
await this.page.mouse.down()
await this.page.mouse.move(427, 98)
await this.page.mouse.up()
// Move out the way to avoid highlight of menu item.
await this.page.mouse.move(10, 10);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async connectEdge() {
// CLIP output anchor on Load Checkpoint Node.
await this.page.mouse.move(332, 509);
await this.page.mouse.down();
await this.page.mouse.move(332, 509)
await this.page.mouse.down()
// CLIP input anchor on CLIP Text Encode Node.
await this.page.mouse.move(427, 198);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(427, 198)
await this.page.mouse.up()
await this.nextFrame()
}
async adjustWidgetValue() {
// Adjust Empty Latent Image's width input.
const page = this.page;
const page = this.page
await page.locator('#graph-canvas').click({
position: {
x: 724,
y: 645
}
});
await page.locator('input[type="text"]').click();
await page.locator('input[type="text"]').fill('128');
await page.locator('input[type="text"]').press('Enter');
await this.nextFrame();
})
await page.locator('input[type="text"]').click()
await page.locator('input[type="text"]').fill('128')
await page.locator('input[type="text"]').press('Enter')
await this.nextFrame()
}
async zoom(deltaY: number) {
await this.page.mouse.move(10, 10);
await this.page.mouse.wheel(0, deltaY);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.page.mouse.wheel(0, deltaY)
await this.nextFrame()
}
async pan(offset: Position) {
await this.page.mouse.move(10, 10);
await this.page.mouse.down();
await this.page.mouse.move(offset.x, offset.y);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.page.mouse.down()
await this.page.mouse.move(offset.x, offset.y)
await this.page.mouse.up()
await this.nextFrame()
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' });
await this.nextFrame();
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10);
await this.nextFrame();
await this.page.mouse.dblclick(10, 10)
await this.nextFrame()
}
async clickEmptyLatentNode() {
@@ -154,10 +309,10 @@ export class ComfyPage {
position: {
x: 724,
y: 625
},
});
this.page.mouse.move(10, 10);
await this.nextFrame();
}
})
this.page.mouse.move(10, 10)
await this.nextFrame()
}
async rightClickEmptyLatentNode() {
@@ -167,60 +322,127 @@ export class ComfyPage {
y: 645
},
button: 'right'
});
this.page.mouse.move(10, 10);
await this.nextFrame();
})
this.page.mouse.move(10, 10)
await this.nextFrame()
}
async select2Nodes() {
// Select 2 CLIP nodes.
await this.page.keyboard.down('Control');
await this.clickTextEncodeNode1();
await this.clickTextEncodeNode2();
await this.page.keyboard.up('Control');
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.clickTextEncodeNode1()
await this.clickTextEncodeNode2()
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlC() {
await this.page.keyboard.down('Control');
await this.page.keyboard.press('KeyC');
await this.page.keyboard.up('Control');
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyC')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlV() {
await this.page.keyboard.down('Control');
await this.page.keyboard.press('KeyV');
await this.page.keyboard.up('Control');
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyV')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async closeMenu() {
await this.page.click('button.comfy-close-menu-btn')
await this.nextFrame()
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
ratioX: number,
ratioY: number,
revertAfter: boolean = false
) {
const bottomRight = {
x: nodePos.x + nodeSize.width,
y: nodePos.y + nodeSize.height
}
const target = {
x: nodePos.x + nodeSize.width * ratioX,
y: nodePos.y + nodeSize.height * ratioY
}
await this.dragAndDrop(bottomRight, target)
await this.nextFrame()
if (revertAfter) {
await this.dragAndDrop(target, bottomRight)
await this.nextFrame()
}
}
async resizeKsamplerNode(
percentX: number,
percentY: number,
revertAfter: boolean = false
) {
const ksamplerPos = {
x: 864,
y: 157
}
const ksamplerSize = {
width: 315,
height: 292
}
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter)
}
async resizeLoadCheckpointNode(
percentX: number,
percentY: number,
revertAfter: boolean = false
) {
const loadCheckpointPos = {
x: 25,
y: 440
}
const loadCheckpointSize = {
width: 320,
height: 120
}
this.resizeNode(
loadCheckpointPos,
loadCheckpointSize,
percentX,
percentY,
revertAfter
)
}
async resizeEmptyLatentNode(
percentX: number,
percentY: number,
revertAfter: boolean = false
) {
const emptyLatentPos = {
x: 475,
y: 580
}
const emptyLatentSize = {
width: 303,
height: 132
}
this.resizeNode(
emptyLatentPos,
emptyLatentSize,
percentX,
percentY,
revertAfter
)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page }, use) => {
const comfyPage = new ComfyPage(page);
await comfyPage.goto();
// Unify font for consistent screenshots.
await page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
});
await page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
});
await page.addStyleTag({
content: `
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}`
});
await page.waitForFunction(() => document.fonts.ready);
await page.waitForFunction(() => window['app'] != undefined);
await page.evaluate(() => { window['app']['canvas'].show_info = false; });
await comfyPage.nextFrame();
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await comfyPage.resetView();
await use(comfyPage);
},
});
const comfyPage = new ComfyPage(page)
await comfyPage.setup()
await use(comfyPage)
}
})

View File

@@ -0,0 +1,193 @@
{
"last_node_id": 10,
"last_link_id": 9,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
0,
92
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
3,
5
],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"3Guofeng3_v32Light.safetensors"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
460,
92
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
460,
368
],
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 10,
"type": "CheckpointLoaderSimple",
"pos": [
0,
276
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"3Guofeng3_v32Light.safetensors"
]
}
],
"links": [
[
3,
4,
1,
6,
0,
"CLIP"
],
[
5,
4,
1,
7,
0,
"CLIP"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,164 @@
{
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
590,
40
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null,
"slot_index": 0
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 3,
"slot_index": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null,
"slot_index": 2
},
{
"name": "latent_image",
"type": "LATENT",
"link": null,
"slot_index": 3
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [
20,
50
],
"size": {
"0": 400,
"1": 200
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
3
],
"shape": 3
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 5,
"type": "CLIPTextEncode",
"pos": [
20,
320
],
"size": {
"0": 400,
"1": 200
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
}
],
"links": [
[
3,
4,
0,
1,
1,
"CONDITIONING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -1,56 +1,89 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe("Copy Paste", () => {
test("Can copy and paste node", async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode();
await comfyPage.page.mouse.move(10, 10);
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await expect(comfyPage.canvas).toHaveScreenshot("copied-node.png");
});
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
})
test("Can copy and paste text", async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
const originalString = await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await comfyPage.ctrlV();
const resultString = await textBox.inputValue();
expect(resultString).toBe(originalString + originalString);
});
test('Can copy and paste text', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
await textBox.click()
const originalString = await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
})
test('Can copy and paste widget value', async ({ comfyPage }) => {
// Copy width value (512) from empty latent node to KSampler's seed.
// Empty latent node's width
await comfyPage.canvas.click({
position: {
x: 718,
y: 643
}
})
await comfyPage.ctrlC()
// KSampler's seed
await comfyPage.canvas.click({
position: {
x: 1005,
y: 281
}
})
await comfyPage.ctrlV()
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
})
/**
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/98
*/
test("Paste in text area with node previously copied", async ({
comfyPage,
test('Paste in text area with node previously copied', async ({
comfyPage
}) => {
await comfyPage.clickEmptyLatentNode();
await comfyPage.ctrlC();
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await comfyPage.ctrlV();
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot(
"paste-in-text-area-with-node-previously-copied.png"
);
});
'paste-in-text-area-with-node-previously-copied.png'
)
})
test("Copy text area does not copy node", async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
test('Copy text area does not copy node', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
// Unfocus textbox.
await comfyPage.page.mouse.click(10, 10);
await comfyPage.ctrlV();
await expect(comfyPage.canvas).toHaveScreenshot("no-node-copied.png");
});
});
await comfyPage.page.mouse.click(10, 10)
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
})
test('Copy node by dragging + alt', async ({ comfyPage }) => {
// TextEncodeNode1
await comfyPage.page.mouse.move(618, 191)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(100, 100)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
await expect(comfyPage.canvas).toHaveScreenshot('drag-copy-copied-node.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -1,54 +1,97 @@
import { expect } from '@playwright/test';
import { comfyPageFixture as test } from './ComfyPage';
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from './ComfyPage'
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.fill('Hello World');
await expect(textBox).toHaveValue('Hello World');
await textBox.fill('Hello World 2');
await expect(textBox).toHaveValue('Hello World 2');
});
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('Hello World')
await expect(textBox).toHaveValue('Hello World')
await textBox.fill('Hello World 2')
await expect(textBox).toHaveValue('Hello World 2')
})
test('Can highlight selected', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png');
await comfyPage.clickTextEncodeNode1();
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png');
await comfyPage.clickTextEncodeNode2();
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png');
});
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
// Flaky. See https://github.com/comfyanonymous/ComfyUI/issues/3866
test.skip('Can drag node', async ({ comfyPage }) => {
await comfyPage.dragNode2();
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png');
});
await comfyPage.dragNode2()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.disconnectEdge();
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge-with-menu.png');
await comfyPage.connectEdge();
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot(
'disconnected-edge-with-menu.png'
)
await comfyPage.connectEdge()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', { maxDiffPixels: 50 });
});
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
})
})
test('Can adjust widget value', async ({ comfyPage }) => {
await comfyPage.adjustWidgetValue();
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png');
});
});
await comfyPage.adjustWidgetValue()
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png')
})
test('Link snap to slot', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('snap_to_slot')
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png')
const outputSlotPos = {
x: 406,
y: 333
}
const samplerNodeCenterPos = {
x: 748,
y: 77
}
await comfyPage.dragAndDrop(outputSlotPos, samplerNodeCenterPos)
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
})
test('Can batch move links by drag with shift', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('batch_move_links')
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
const outputSlot1Pos = {
x: 304,
y: 127
}
const outputSlot2Pos = {
x: 307,
y: 310
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot(
'batch_move_links_moved.png'
)
})
})
test.describe('Canvas Interaction', () => {
test('Can zoom in/out', async ({ comfyPage }) => {
await comfyPage.zoom(-100);
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png');
await comfyPage.zoom(200);
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png');
});
await comfyPage.zoom(-100)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png')
await comfyPage.zoom(200)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png')
})
test('Can pan', async ({ comfyPage }) => {
await comfyPage.pan({ x: 200, y: 200 });
await expect(comfyPage.canvas).toHaveScreenshot('panned.png');
});
});
await comfyPage.pan({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -1,34 +1,36 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {
document.addEventListener("litegraph:canvas", (e) => resolve(e), { once: true });
});
document.addEventListener('litegraph:canvas', (e) => resolve(e), {
once: true
})
})
}
test.describe("Canvas Event", () => {
test("Emit litegraph:canvas empty-release", async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent);
const disconnectPromise = comfyPage.disconnectEdge();
const event = await eventPromise;
await disconnectPromise;
test.describe('Canvas Event', () => {
test('Emit litegraph:canvas empty-release', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const disconnectPromise = comfyPage.disconnectEdge()
const event = await eventPromise
await disconnectPromise
expect(event).not.toBeNull();
expect(event).not.toBeNull()
// No further check on event content as the content is dropped by
// playwright for some reason.
// See https://github.com/microsoft/playwright/issues/31580
});
})
test("Emit litegraph:canvas empty-double-click", async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent);
const doubleClickPromise = comfyPage.doubleClickCanvas();
const event = await eventPromise;
await doubleClickPromise;
test('Emit litegraph:canvas empty-double-click', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const doubleClickPromise = comfyPage.doubleClickCanvas()
const event = await eventPromise
await doubleClickPromise
expect(event).not.toBeNull();
expect(event).not.toBeNull()
// No further check on event content as the content is dropped by
// playwright for some reason.
// See https://github.com/microsoft/playwright/issues/31580
});
});
})
})

View File

@@ -0,0 +1,95 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(async () => {
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Top'
)
})
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.page.evaluate(async () => {
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Disabled'
)
})
})
test('Toggle theme', async ({ comfyPage }) => {
test.setTimeout(30000)
expect(await comfyPage.menu.getThemeId()).toBe('dark')
await comfyPage.menu.toggleTheme()
expect(await comfyPage.menu.getThemeId()).toBe('light')
// Theme id should persist after reload.
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.menu.getThemeId()).toBe('light')
await comfyPage.menu.toggleTheme()
expect(await comfyPage.menu.getThemeId()).toBe('dark')
})
test('Can register sidebar tab', async ({ comfyPage }) => {
const initialChildrenCount = await comfyPage.menu.sideToolBar.evaluate(
(el) => el.children.length
)
await comfyPage.page.evaluate(async () => {
window['app'].extensionManager.registerSidebarTab({
id: 'search',
icon: 'pi pi-search',
title: 'search',
tooltip: 'search',
type: 'custom',
render: (el) => {
el.innerHTML = '<div>Custom search tab</div>'
}
})
})
await comfyPage.nextFrame()
const newChildrenCount = await comfyPage.menu.sideToolBar.evaluate(
(el) => el.children.length
)
expect(newChildrenCount).toBe(initialChildrenCount + 1)
})
test('Sidebar node preview and drag to canvas', async ({ comfyPage }) => {
// Open the sidebar
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
await tab.toggleFirstFolder()
// Hover over a node to display the preview
const nodeSelector = '.p-tree-node-leaf'
await comfyPage.page.hover(nodeSelector)
// Verify the preview is displayed
const previewVisible = await comfyPage.page.isVisible(
'.node-lib-node-preview'
)
expect(previewVisible).toBe(true)
const count = await comfyPage.getGraphNodesCount()
// Drag the node onto the canvas
const canvasSelector = '#graph-canvas'
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector)
// Verify the node is added to the canvas
expect(await comfyPage.getGraphNodesCount()).toBe(count + 1)
})
})

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Node search box', () => {
test('Can trigger on empty canvas double click', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('Shift')
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test('Does not trigger on link release (no shift)', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(0)
})
test('Can add node', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
})
test('Can auto link node', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('Shift')
await comfyPage.disconnectEdge()
await comfyPage.page.keyboard.up('Shift')
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode')
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
})
test('Can auto link batch moved node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('batch_move_links')
const outputSlot1Pos = {
x: 304,
y: 127
}
const emptySpacePos = {
x: 5,
y: 5
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint')
await expect(comfyPage.canvas).toHaveScreenshot(
'auto-linked-node-batch.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -1,80 +1,88 @@
import { expect } from '@playwright/test';
import { comfyPageFixture as test } from './ComfyPage';
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Canvas Right Click Menu', () => {
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png');
await comfyPage.page.getByText('Add Node').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu.png');
await comfyPage.page.getByText('loaders').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu-loaders.png');
await comfyPage.page.getByText('Load VAE').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png');
});
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu.png')
await comfyPage.page.getByText('loaders').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu-loaders.png')
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
})
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add group', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png');
await comfyPage.page.getByText('Add Group', { exact: true }).click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png');
});
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add group', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes();
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png');
comfyPage.page.on('dialog', async dialog => {
await dialog.accept("GroupNode2CLIP");
});
await comfyPage.rightClickCanvas();
await comfyPage.page.getByText('Convert to Group Node').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-group-node.png');
});
});
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
comfyPage.page.on('dialog', async (dialog) => {
await dialog.accept('GroupNode2CLIP')
})
await comfyPage.rightClickCanvas()
await comfyPage.page.getByText('Convert to Group Node').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-group-node.png'
)
})
})
test.describe('Node Right Click Menu', () => {
test('Can open properties panel', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Properties Panel').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-properties-panel.png');
});
test('Can open properties panel', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-properties-panel.png'
)
})
test('Can collapse', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Collapse').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-collapsed.png');
});
test('Can collapse', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-collapsed.png'
)
})
// See https://github.com/Comfy-Org/ComfyUI_frontend/pull/57
// Bypass produces different output on Windows VS Linux.
test.skip('Can bypass', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Bypass').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-bypassed.png');
});
test('Can bypass', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-bypassed.png'
)
})
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Convert Widget to Input').click();
await comfyPage.nextFrame();
await comfyPage.page.getByText('Convert width to input').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-widget-converted.png');
});
});
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Combo text widget', () => {
test('Truncates text when resized', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
await expect(comfyPage.canvas).toHaveScreenshot(
'load-checkpoint-resized-min-width.png'
)
await comfyPage.closeMenu()
await comfyPage.resizeKsamplerNode(0.2, 1)
await expect(comfyPage.canvas).toHaveScreenshot(
`ksampler-resized-min-width.png`
)
})
test("Doesn't truncate when space still available", async ({ comfyPage }) => {
await comfyPage.resizeEmptyLatentNode(0.8, 0.8)
await expect(comfyPage.canvas).toHaveScreenshot(
'empty-latent-resized-80-percent.png'
)
})
test('Can revert to full text', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.8, 1, true)
await expect(comfyPage.canvas).toHaveScreenshot('resized-to-original.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -12,19 +12,18 @@
font-family: 'Roboto Mono', 'Noto Color Emoji';
}
</style> -->
<script src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module">
import 'reflect-metadata';
window["__COMFYUI_FRONTEND_VERSION__"] = __COMFYUI_FRONTEND_VERSION__;
console.log("ComfyUI Front-end version:", __COMFYUI_FRONTEND_VERSION__);
import { app } from "./src/scripts/app";
(async () => {
await app.setup();
window.app = app;
window.graph = app.graph;
})();
</script>
<script type="module" src="/src/main.ts"></script>
<link rel="stylesheet" type="text/css" href="/user.css" />
<link rel="stylesheet" type="text/css" href="/materialdesignicons.min.css" />
</head>
<body class="litegraph">
<div id="vue-app"></div>
<div id="comfy-user-selection" class="comfy-user-selection" style="display: none;">
<main class="comfy-user-selection-inner">
<h1>ComfyUI</h1>

View File

@@ -1,23 +1,26 @@
import type { JestConfigWithTsJest } from "ts-jest";
import type { JestConfigWithTsJest } from 'ts-jest'
const jestConfig: JestConfigWithTsJest = {
testMatch: ["**/tests-ui/**/*.test.ts"],
testEnvironment: "jsdom",
transform: {
'^.+\\.m?[tj]sx?$': ["ts-jest", {
tsconfig: "./tsconfig.json",
babelConfig: "./babel.config.json",
}],
},
setupFiles: ["./tests-ui/globalSetup.ts"],
setupFilesAfterEnv: ["./tests-ui/afterSetup.ts"],
clearMocks: true,
resetModules: true,
testTimeout: 10000,
moduleNameMapper: {
"^src/(.*)$": "<rootDir>/src/$1",
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
};
testMatch: ['**/tests-ui/**/*.test.ts'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.m?[tj]sx?$': [
'ts-jest',
{
tsconfig: './tsconfig.json',
babelConfig: './babel.config.json'
}
]
},
setupFiles: ['./tests-ui/globalSetup.ts'],
setupFilesAfterEnv: ['./tests-ui/afterSetup.ts'],
clearMocks: true,
resetModules: true,
testTimeout: 10000,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
}
}
export default jestConfig;
export default jestConfig

1252
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.0.2",
"version": "1.2.9",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"deploy": "node scripts/deploy.js",
"deploy": "npm run build && node scripts/deploy.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit",
"format": "prettier --write 'src/**/*.{js,ts,tsx}'",
"format": "prettier --write './**/*.{js,ts,tsx,vue}'",
"test": "npm run build && jest",
"test:generate:examples": "npx tsx tests-ui/extractExamples",
"test:generate": "npx tsx tests-ui/setup",
@@ -22,7 +22,9 @@
"@babel/preset-env": "^7.22.20",
"@playwright/test": "^1.44.1",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"autoprefixer": "^10.4.19",
"babel-plugin-transform-import-meta": "^2.2.1",
"babel-plugin-transform-rename-import": "^2.3.0",
"chalk": "^5.3.0",
@@ -32,7 +34,9 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^15.2.7",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.15.6",
@@ -42,13 +46,25 @@
"zip-dir": "^2.0.0"
},
"dependencies": {
"@comfyorg/litegraph": "^0.7.23",
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.35",
"@primevue/themes": "^4.0.0-rc.2",
"@vitejs/plugin-vue": "^5.0.5",
"class-transformer": "^0.5.1",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.0.0-rc.2",
"reflect-metadata": "^0.2.2",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"src/**/*.{js,ts,tsx}": [
"./**/*.{js,ts,tsx,vue}": [
"prettier --write",
"git add"
]

View File

@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
@@ -28,7 +28,7 @@ export default defineConfig({
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
@@ -36,7 +36,8 @@ export default defineConfig({
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
timeout: 5000
}
// {
// name: 'firefox',
@@ -67,7 +68,7 @@ export default defineConfig({
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
]
/* Run your local dev server before starting the tests */
// webServer: {
@@ -75,4 +76,4 @@ export default defineConfig({
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});
})

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

@@ -1,14 +1,14 @@
import { copy } from 'fs-extra';
import { config } from "dotenv";
config();
import { copy } from 'fs-extra'
import { config } from 'dotenv'
config()
const sourceDir = './dist';
const targetDir = process.env.DEPLOY_COMFYUI_DIR;
const sourceDir = './dist'
const targetDir = process.env.DEPLOY_COMFYUI_DIR
copy(sourceDir, targetDir)
.then(() => {
console.log(`Directory copied successfully! ${sourceDir} -> ${targetDir}`);
console.log(`Directory copied successfully! ${sourceDir} -> ${targetDir}`)
})
.catch((err) => {
console.error('Error copying directory:', err);
});
console.error('Error copying directory:', err)
})

View File

@@ -1,9 +1,9 @@
import zipdir from 'zip-dir';
import zipdir from 'zip-dir'
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
if (err) {
console.error('Error zipping "dist" directory:', err);
console.error('Error zipping "dist" directory:', err)
} else {
console.log('Successfully zipped "dist" directory.');
console.log('Successfully zipped "dist" directory.')
}
});
})

72
src/App.vue Normal file
View File

@@ -0,0 +1,72 @@
<template>
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
<BlockUI full-screen :blocked="isLoading" />
<GraphCanvas />
</template>
<script setup lang="ts">
import { computed, markRaw, onMounted, watch } from 'vue'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import QueueSideBarTab from '@/components/sidebar/tabs/QueueSideBarTab.vue'
import { app } from './scripts/app'
import { useSettingStore } from './stores/settingStore'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from './stores/workspaceStateStore'
import NodeLibrarySideBarTab from './components/sidebar/tabs/NodeLibrarySideBarTab.vue'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
const theme = computed<string>(() =>
useSettingStore().get('Comfy.ColorPalette')
)
watch(
theme,
(newTheme) => {
const DARK_THEME_CLASS = 'dark-theme'
const isDarkTheme = newTheme !== 'light'
if (isDarkTheme) {
document.body.classList.add(DARK_THEME_CLASS)
} else {
document.body.classList.remove(DARK_THEME_CLASS)
}
},
{ immediate: true }
)
const { t } = useI18n()
const init = () => {
useSettingStore().addSettings(app.ui.settings)
app.extensionManager = useWorkspaceStore()
app.extensionManager.registerSidebarTab({
id: 'queue',
icon: 'pi pi-history',
title: t('sideToolBar.queue'),
tooltip: t('sideToolBar.queue'),
component: markRaw(QueueSideBarTab),
type: 'vue'
})
app.extensionManager.registerSidebarTab({
id: 'node-library',
icon: 'pi pi-book',
title: t('sideToolBar.nodeLibrary'),
tooltip: t('sideToolBar.nodeLibrary'),
component: markRaw(NodeLibrarySideBarTab),
type: 'vue'
})
}
onMounted(() => {
try {
init()
} catch (e) {
console.error('Failed to init Vue app', e)
}
})
</script>
<style scoped>
.spinner {
@apply absolute inset-0 flex justify-center items-center h-screen;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
<template>
<Splitter class="splitter-overlay" :pt:gutter="gutterClass">
<SplitterPanel
class="side-bar-panel"
:minSize="10"
:size="20"
v-show="sideBarPanelVisible"
>
<slot name="side-bar-panel"></slot>
</SplitterPanel>
<SplitterPanel class="graph-canvas-panel" :size="100">
<div></div>
</SplitterPanel>
</Splitter>
</template>
<script setup lang="ts">
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
const sideBarPanelVisible = computed(
() => useWorkspaceStore().activeSidebarTab !== null
)
const gutterClass = computed(() => {
return sideBarPanelVisible.value ? '' : 'gutter-hidden'
})
</script>
<style>
.p-splitter-gutter {
pointer-events: auto;
}
.gutter-hidden {
display: none !important;
}
</style>
<style scoped>
.side-bar-panel {
background-color: var(--bg-color);
pointer-events: auto;
}
.splitter-overlay {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-color: transparent;
pointer-events: none;
/* Set it the same as the ComfyUI menu */
/* Note: Lite-graph DOM widgets have the same z-index as the node id, so
999 should be sufficient to make sure splitter overlays on node's DOM
widgets */
z-index: 999;
border: none;
}
</style>

View File

@@ -0,0 +1,229 @@
<!-- Reference:
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
-->
<template>
<div class="_sb_node_preview">
<div class="_sb_table">
<div class="node_header">
<div class="_sb_dot headdot"></div>
{{ nodeDef.display_name }}
</div>
<div class="_sb_preview_badge">PREVIEW</div>
<!-- Node slot I/O -->
<div
v-for="[slotInput, slotOutput] in _.zip(slotInputDefs, allOutputDefs)"
class="_sb_row slot_row"
:key="(slotInput?.name || '') + (slotOutput?.index.toString() || '')"
>
<div class="_sb_col">
<div v-if="slotInput" :class="['_sb_dot', slotInput.type]"></div>
</div>
<div class="_sb_col">{{ slotInput ? slotInput.name : '' }}</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
{{ slotOutput ? slotOutput.name : '' }}
</div>
<div class="_sb_col">
<div v-if="slotOutput" :class="['_sb_dot', slotOutput.type]"></div>
</div>
</div>
<!-- Node widget inputs -->
<div
v-for="widgetInput in widgetInputDefs"
class="_sb_row _long_field"
:key="widgetInput.name"
>
<div class="_sb_col _sb_arrow">&#x25C0;</div>
<div class="_sb_col">{{ widgetInput.name }}</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
{{ truncateDefaultValue(widgetInput.default) }}
</div>
<div class="_sb_col _sb_arrow">&#x25B6;</div>
</div>
</div>
<div class="_sb_description" v-if="nodeDef.description">
{{ nodeDef.description }}
</div>
</div>
</template>
<script setup lang="ts">
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import _ from 'lodash'
const props = defineProps({
nodeDef: {
type: ComfyNodeDefImpl,
required: true
}
})
const nodeDefStore = useNodeDefStore()
const nodeDef = props.nodeDef
const allInputDefs = nodeDef.input.all
const allOutputDefs = nodeDef.output.all
const slotInputDefs = allInputDefs.filter(
(input) => !nodeDefStore.inputIsWidget(input)
)
const widgetInputDefs = allInputDefs.filter((input) =>
nodeDefStore.inputIsWidget(input)
)
const truncateDefaultValue = (value: any): string => {
if (value instanceof Object) {
return _.truncate(JSON.stringify(value), { length: 20 })
}
return value
}
</script>
<style scoped>
.slot_row {
padding: 2px;
}
/* Original N-SideBar styles */
._sb_dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: grey;
}
.node_header {
line-height: 1;
padding: 8px 13px 7px;
background: var(--comfy-input-bg);
margin-bottom: 5px;
font-size: 15px;
text-wrap: nowrap;
overflow: hidden;
display: flex;
align-items: center;
}
.headdot {
width: 10px;
height: 10px;
float: inline-start;
margin-right: 8px;
}
.IMAGE {
background-color: #64b5f6;
}
.VAE {
background-color: #ff6e6e;
}
.LATENT {
background-color: #ff9cf9;
}
.MASK {
background-color: #81c784;
}
.CONDITIONING {
background-color: #ffa931;
}
.CLIP {
background-color: #ffd500;
}
.MODEL {
background-color: #b39ddb;
}
.CONTROL_NET {
background-color: #a5d6a7;
}
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif;
font-size: small;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);
min-width: 300px;
width: min-content;
height: fit-content;
z-index: 9999;
border-radius: 12px;
overflow: hidden;
font-size: 12px;
padding-bottom: 10px;
}
._sb_node_preview ._sb_description {
margin: 10px;
padding: 6px;
background: var(--border-color);
border-radius: 5px;
font-style: italic;
font-weight: 500;
font-size: 0.9rem;
}
._sb_table {
display: grid;
grid-column-gap: 10px;
/* Spazio tra le colonne */
width: 100%;
/* Imposta la larghezza della tabella al 100% del contenitore */
}
._sb_row {
display: grid;
grid-template-columns: 10px 1fr 1fr 1fr 10px;
grid-column-gap: 10px;
align-items: center;
padding-left: 9px;
padding-right: 9px;
}
._sb_row_string {
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
}
._sb_col {
border: 0px solid #000;
display: flex;
align-items: flex-end;
flex-direction: row-reverse;
flex-wrap: nowrap;
align-content: flex-start;
justify-content: flex-end;
}
._sb_inherit {
display: inherit;
}
._long_field {
background: var(--bg-color);
border: 2px solid var(--border-color);
margin: 5px 5px 0 5px;
border-radius: 10px;
line-height: 1.7;
text-wrap: nowrap;
}
._sb_arrow {
color: var(--fg-color);
}
._sb_preview_badge {
text-align: center;
background: var(--comfy-input-bg);
font-weight: bold;
color: var(--error-text);
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<div class="comfy-vue-node-search-container">
<div class="comfy-vue-node-preview-container">
<NodePreview
:nodeDef="hoveredSuggestion"
:key="hoveredSuggestion?.name || ''"
v-if="hoveredSuggestion"
/>
</div>
<NodeSearchFilter @addFilter="onAddFilter" />
<AutoCompletePlus
:model-value="props.filters"
class="comfy-vue-node-search-box"
scrollHeight="40vh"
:placeholder="placeholder"
:input-id="inputId"
append-to="self"
:suggestions="suggestions"
:min-length="0"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@focused-option-changed="setHoverSuggestion($event)"
complete-on-focus
auto-option-focus
force-selection
multiple
>
<template v-slot:option="{ option }">
<div class="option-container">
<div class="option-display-name">
{{ option.display_name }}
<NodeSourceChip
v-if="option.python_module !== undefined"
:python_module="option.python_module"
/>
</div>
<div v-if="option.description" class="option-description">
{{ option.description }}
</div>
</div>
</template>
<!-- FilterAndValue -->
<template v-slot:chip="{ value }">
<Chip removable @remove="onRemoveFilter($event, value)">
<Badge size="small" :class="value[0].invokeSequence + '-badge'">
{{ value[0].invokeSequence.toUpperCase() }}
</Badge>
{{ value[1] }}
</Chip>
</template>
</AutoCompletePlus>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import AutoCompletePlus from './primevueOverride/AutoCompletePlus.vue'
import Chip from 'primevue/chip'
import Badge from 'primevue/badge'
import NodeSearchFilter from '@/components/NodeSearchFilter.vue'
import NodeSourceChip from '@/components/NodeSourceChip.vue'
import { type FilterAndValue } from '@/services/nodeSearchService'
import NodePreview from './NodePreview.vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
const props = defineProps({
filters: {
type: Array<FilterAndValue>
},
searchLimit: {
type: Number,
default: 64
}
})
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const placeholder = computed(() => {
return props.filters.length === 0 ? 'Search for nodes' : ''
})
const search = (query: string) => {
suggestions.value = useNodeDefStore().nodeSearchService.searchNode(
query,
props.filters,
{
limit: props.searchLimit
}
)
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const reFocusInput = () => {
const inputElement = document.getElementById(inputId) as HTMLInputElement
if (inputElement) {
inputElement.blur()
inputElement.focus()
}
}
onMounted(reFocusInput)
const onAddFilter = (filterAndValue: FilterAndValue) => {
emit('addFilter', filterAndValue)
reFocusInput()
}
const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
event.stopPropagation()
event.preventDefault()
emit('removeFilter', filterAndValue)
reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1) {
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index]
hoveredSuggestion.value = value
}
</script>
<style scoped>
.comfy-vue-node-search-container {
@apply flex justify-center items-center w-full min-w-96;
}
.comfy-vue-node-search-container * {
pointer-events: auto;
}
.comfy-vue-node-preview-container {
position: absolute;
left: -350px;
top: 50px;
}
.comfy-vue-node-search-box {
@apply z-10 flex-grow;
}
.option-container {
@apply flex flex-col px-4 py-2 cursor-pointer overflow-hidden w-full;
}
.option-container:hover .option-description {
@apply overflow-visible;
/* Allows text to wrap */
white-space: normal;
}
.option-display-name {
@apply font-semibold;
}
.option-description {
@apply text-sm text-gray-400 overflow-hidden text-ellipsis;
/* Keeps the text on a single line by default */
white-space: nowrap;
}
.i-badge {
@apply bg-green-500 text-white;
}
.o-badge {
@apply bg-red-500 text-white;
}
.c-badge {
@apply bg-blue-500 text-white;
}
.s-badge {
@apply bg-yellow-500;
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div>
<Dialog
v-model:visible="visible"
pt:root="invisible-dialog-root"
pt:mask="node-search-box-dialog-mask"
modal
:dismissable-mask="dismissable"
@hide="clearFilters"
>
<template #container>
<NodeSearchBox
:filters="nodeFilters"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
/>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { app } from '@/scripts/app'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import NodeSearchBox from './NodeSearchBox.vue'
import Dialog from 'primevue/dialog'
import { LiteGraphCanvasEvent, ConnectingLink } from '@comfyorg/litegraph'
import { FilterAndValue } from '@/services/nodeSearchService'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { ConnectingLinkImpl } from '@/types/litegraphTypes'
interface LiteGraphPointerEvent extends Event {
canvasX: number
canvasY: number
}
const visible = ref(false)
const dismissable = ref(true)
const triggerEvent = ref<LiteGraphCanvasEvent | null>(null)
const getNewNodeLocation = (): [number, number] => {
if (triggerEvent.value === null) {
return [100, 100]
}
const originalEvent = triggerEvent.value.detail
.originalEvent as LiteGraphPointerEvent
return [originalEvent.canvasX, originalEvent.canvasY]
}
const nodeFilters = reactive([])
const addFilter = (filter: FilterAndValue) => {
nodeFilters.push(filter)
}
const removeFilter = (filter: FilterAndValue) => {
const index = nodeFilters.findIndex((f) => f === filter)
if (index !== -1) {
nodeFilters.splice(index, 1)
}
}
const clearFilters = () => {
nodeFilters.splice(0, nodeFilters.length)
}
const closeDialog = () => {
visible.value = false
}
const addNode = (nodeDef: ComfyNodeDefImpl) => {
closeDialog()
const node = app.addNodeOnGraph(nodeDef, { pos: getNewNodeLocation() })
const eventDetail = triggerEvent.value.detail
if (eventDetail.subType === 'empty-release') {
eventDetail.linkReleaseContext.links.forEach((link: ConnectingLink) => {
ConnectingLinkImpl.createFromPlainObject(link).connectTo(node)
})
}
}
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey
// Ignore empty releases unless shift is pressed
// Empty release without shift is trigger right click menu
if (e.detail.subType === 'empty-release' && !shiftPressed) {
return
}
if (e.detail.subType === 'empty-release') {
const context = e.detail.linkReleaseContext
if (context.links.length === 0) {
console.warn('Empty release with no links! This should never happen')
return
}
const firstLink = ConnectingLinkImpl.createFromPlainObject(context.links[0])
const filter = useNodeDefStore().nodeSearchService.getFilterById(
firstLink.releaseSlotType
)
const dataType = firstLink.type
addFilter([filter, dataType])
}
triggerEvent.value = e
visible.value = true
// Prevent the dialog from being dismissed immediately
dismissable.value = false
setTimeout(() => {
dismissable.value = true
}, 300)
}
const handleEscapeKeyPress = (event) => {
if (event.key === 'Escape') {
closeDialog()
}
}
onMounted(() => {
document.addEventListener('litegraph:canvas', canvasEventHandler)
document.addEventListener('keydown', handleEscapeKeyPress)
})
onUnmounted(() => {
document.removeEventListener('litegraph:canvas', canvasEventHandler)
document.removeEventListener('keydown', handleEscapeKeyPress)
})
</script>
<style>
.invisible-dialog-root {
width: 30%;
min-width: 24rem;
max-width: 48rem;
border: 0 !important;
background-color: transparent !important;
margin-top: 25vh;
}
.node-search-box-dialog-mask {
align-items: flex-start !important;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<Button
icon="pi pi-filter"
severity="secondary"
class="_filter-button"
@click="showModal"
/>
<Dialog v-model:visible="visible" class="_dialog">
<template #header>
<h3>Add node filter condition</h3>
</template>
<div class="_dialog-body">
<SelectButton
v-model="selectedFilter"
:options="filters"
:allowEmpty="false"
optionLabel="name"
@change="updateSelectedFilterValue"
/>
<AutoComplete
v-model="selectedFilterValue"
:suggestions="filterValues"
:min-length="0"
@complete="(event) => updateFilterValues(event.query)"
completeOnFocus
forceSelection
dropdown
></AutoComplete>
</div>
<template #footer>
<Button type="button" label="Add" @click="submit"></Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { NodeFilter, type FilterAndValue } from '@/services/nodeSearchService'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import SelectButton from 'primevue/selectbutton'
import AutoComplete from 'primevue/autocomplete'
import { ref, onMounted } from 'vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const visible = ref<boolean>(false)
const filters = ref<NodeFilter[]>([])
const selectedFilter = ref<NodeFilter>()
const filterValues = ref<string[]>([])
const selectedFilterValue = ref<string>('')
onMounted(() => {
const nodeSearchService = useNodeDefStore().nodeSearchService
filters.value = nodeSearchService.nodeFilters
selectedFilter.value = nodeSearchService.nodeFilters[0]
})
const emit = defineEmits(['addFilter'])
const updateSelectedFilterValue = () => {
updateFilterValues('')
if (filterValues.value.includes(selectedFilterValue.value)) {
return
}
selectedFilterValue.value = filterValues.value[0]
}
const updateFilterValues = (query: string) => {
filterValues.value = selectedFilter.value.fuseSearch.search(query)
}
const submit = () => {
visible.value = false
emit('addFilter', [
selectedFilter.value,
selectedFilterValue.value
] as FilterAndValue)
}
const showModal = () => {
updateSelectedFilterValue()
visible.value = true
}
</script>
<style scoped>
._filter-button {
z-index: 10;
}
._dialog {
@apply min-w-96;
}
._dialog-body {
@apply flex flex-col space-y-2;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<Chip :class="nodeSource.className">
{{ nodeSource.displayText }}
</Chip>
</template>
<script setup lang="ts">
import { getNodeSource } from '@/types/nodeSource'
import Chip from 'primevue/chip'
import { computed } from 'vue'
const props = defineProps({
python_module: {
type: String,
required: true
}
})
const nodeSource = computed(() => getNodeSource(props.python_module))
</script>
<style scoped>
.comfy-core,
.comfy-custom-nodes,
.comfy-unknown {
font-size: small;
font-weight: lighter;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<teleport to=".graph-canvas-container">
<LiteGraphCanvasSplitterOverlay v-if="betaMenuEnabled">
<template #side-bar-panel>
<SideToolBar />
</template>
</LiteGraphCanvasSplitterOverlay>
<canvas ref="canvasRef" id="graph-canvas" tabindex="1" />
</teleport>
<NodeSearchboxPopover v-if="nodeSearchEnabled" />
</template>
<script setup lang="ts">
import SideToolBar from '@/components/sidebar/SideToolBar.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import NodeSearchboxPopover from '@/components/NodeSearchBoxPopover.vue'
import { ref, computed, onUnmounted, watch, onMounted } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const nodeSearchEnabled = computed<boolean>(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)
watch(nodeSearchEnabled, (newVal) => {
if (comfyApp.canvas) comfyApp.canvas.allow_searchbox = !newVal
})
let dropTargetCleanup = () => {}
onMounted(async () => {
comfyApp.vueAppReady = true
workspaceStore.spinner = true
await comfyApp.setup(canvasRef.value)
comfyApp.canvas.allow_searchbox = !nodeSearchEnabled.value
workspaceStore.spinner = false
window['app'] = comfyApp
window['graph'] = comfyApp.graph
dropTargetCleanup = dropTargetForElements({
element: canvasRef.value,
onDrop: (event) => {
const loc = event.location.current.input
// Add an offset on x to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = comfyApp.clientPosToCanvasPos([loc.clientX - 20, loc.clientY])
const comfyNodeName = event.source.element.getAttribute(
'data-comfy-node-name'
)
const nodeDef = useNodeDefStore().nodeDefsByName[comfyNodeName]
comfyApp.addNodeOnGraph(nodeDef, { pos })
}
})
emit('ready')
})
onUnmounted(() => {
dropTargetCleanup()
})
</script>

View File

@@ -0,0 +1,24 @@
<!-- Auto complete with extra event "focused-option-changed" -->
<script>
import AutoComplete from 'primevue/autocomplete'
export default {
name: 'AutoCompletePlus',
extends: AutoComplete,
emits: ['focused-option-changed'],
mounted() {
if (typeof AutoComplete.mounted === 'function') {
AutoComplete.mounted.call(this)
}
// Add a watcher on the focusedOptionIndex property
this.$watch(
() => this.focusedOptionIndex,
(newVal, oldVal) => {
// Emit a custom event when focusedOptionIndex changes
this.$emit('focused-option-changed', newVal)
}
)
}
}
</script>

View File

@@ -0,0 +1,106 @@
<!-- Tree with all leaf nodes draggable -->
<script>
import Tree from 'primevue/tree'
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { h, onMounted, onBeforeUnmount, computed } from 'vue'
export default {
name: 'TreePlus',
extends: Tree,
props: {
dragSelector: {
type: String,
default: '.p-tree-node'
},
// Explicitly declare all v-model props
expandedKeys: {
type: Object,
default: () => ({})
},
selectionKeys: {
type: Object,
default: () => ({})
}
},
emits: ['update:expandedKeys', 'update:selectionKeys'],
setup(props, context) {
// Create computed properties for each v-model prop
const computedExpandedKeys = computed({
get: () => props.expandedKeys,
set: (value) => context.emit('update:expandedKeys', value)
})
const computedSelectionKeys = computed({
get: () => props.selectionKeys,
set: (value) => context.emit('update:selectionKeys', value)
})
let observer = null
const makeDraggable = (element) => {
if (!element._draggableCleanup) {
element._draggableCleanup = draggable({
element
})
}
}
const observeTreeChanges = (treeElement) => {
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
node.querySelectorAll(props.dragSelector).forEach(makeDraggable)
}
})
}
})
})
observer.observe(treeElement, { childList: true, subtree: true })
// Make existing nodes draggable
treeElement.querySelectorAll(props.dragSelector).forEach(makeDraggable)
}
onMounted(() => {
const treeElement = document.querySelector('.p-tree')
if (treeElement) {
observeTreeChanges(treeElement)
}
})
onBeforeUnmount(() => {
if (observer) {
observer.disconnect()
}
// Clean up draggable instances if necessary
const treeElement = document.querySelector('.p-tree')
if (treeElement) {
treeElement.querySelectorAll(props.dragSelector).forEach((node) => {
if (node._draggableCleanup) {
node._draggableCleanup()
}
})
}
})
return () =>
h(
Tree,
{
...context.attrs,
...props,
expandedKeys: computedExpandedKeys.value,
selectionKeys: computedSelectionKeys.value,
'onUpdate:expandedKeys': (value) =>
(computedExpandedKeys.value = value),
'onUpdate:selectionKeys': (value) =>
(computedSelectionKeys.value = value)
},
context.slots
)
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<Button
:icon="props.icon"
:class="props.class"
text
:pt="{
root: `side-bar-button ${
props.selected
? 'p-button-primary side-bar-button-selected'
: 'p-button-secondary'
}`,
icon: 'side-bar-button-icon'
}"
@click="emit('click', $event)"
v-tooltip="{ value: props.tooltip, showDelay: 300, hideDelay: 300 }"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const props = defineProps({
icon: String,
selected: Boolean,
tooltip: {
type: String,
default: ''
},
class: {
type: String,
default: ''
}
})
const emit = defineEmits(['click'])
</script>
<style>
.p-button-icon.side-bar-button-icon {
font-size: var(--sidebar-icon-size) !important;
}
.side-bar-button-selected .p-button-icon.side-bar-button-icon {
font-size: var(--sidebar-icon-size) !important;
font-weight: bold;
}
</style>
<style scoped>
.side-bar-button {
width: var(--sidebar-width);
height: var(--sidebar-width);
border-radius: 0;
}
.side-bar-button.side-bar-button-selected,
.side-bar-button.side-bar-button-selected:hover {
border-left: 4px solid var(--p-button-text-primary-color);
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<SideBarIcon
icon="pi pi-cog"
@click="showSetting"
:tooltip="$t('sideToolBar.settings')"
/>
</template>
<script setup lang="ts">
import { app } from '@/scripts/app'
import SideBarIcon from './SideBarIcon.vue'
const showSetting = () => {
app.ui.settings.show()
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<SideBarIcon
:icon="icon"
@click="toggleTheme"
:tooltip="$t('sideToolBar.themeToggle')"
class="comfy-vue-theme-toggle"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import SideBarIcon from './SideBarIcon.vue'
import { useSettingStore } from '@/stores/settingStore'
const previousDarkTheme = ref('dark')
const currentTheme = computed(() => useSettingStore().get('Comfy.ColorPalette'))
const isDarkMode = computed(() => currentTheme.value !== 'light')
const icon = computed(() => (isDarkMode.value ? 'pi pi-moon' : 'pi pi-sun'))
const toggleTheme = () => {
if (isDarkMode.value) {
previousDarkTheme.value = currentTheme.value
useSettingStore().set('Comfy.ColorPalette', 'light')
} else {
useSettingStore().set('Comfy.ColorPalette', previousDarkTheme.value)
}
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<teleport to=".comfyui-body-left">
<nav class="side-tool-bar-container">
<SideBarIcon
v-for="tab in tabs"
:key="tab.id"
:icon="tab.icon"
:tooltip="tab.tooltip"
:selected="tab === selectedTab"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<div class="side-tool-bar-end">
<SideBarThemeToggleIcon />
<SideBarSettingsToggleIcon />
</div>
</nav>
</teleport>
<div v-if="selectedTab" class="sidebar-content-container">
<component v-if="selectedTab.type === 'vue'" :is="selectedTab.component" />
<div
v-else
:ref="
(el) => {
if (el)
mountCustomTab(
selectedTab as CustomSidebarTabExtension,
el as HTMLElement
)
}
"
></div>
</div>
</template>
<script setup lang="ts">
import SideBarIcon from './SideBarIcon.vue'
import SideBarThemeToggleIcon from './SideBarThemeToggleIcon.vue'
import SideBarSettingsToggleIcon from './SideBarSettingsToggleIcon.vue'
import { computed, onBeforeUnmount } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
CustomSidebarTabExtension,
SidebarTabExtension
} from '@/types/extensionTypes'
const workspaceStore = useWorkspaceStore()
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed<SidebarTabExtension | null>(() => {
const tabId = workspaceStore.activeSidebarTab
return tabs.value.find((tab) => tab.id === tabId) || null
})
const mountCustomTab = (tab: CustomSidebarTabExtension, el: HTMLElement) => {
tab.render(el)
}
const onTabClick = (item: SidebarTabExtension) => {
workspaceStore.updateActiveSidebarTab(
workspaceStore.activeSidebarTab === item.id ? null : item.id
)
}
onBeforeUnmount(() => {
tabs.value.forEach((tab) => {
if (tab.type === 'custom' && tab.destroy) {
tab.destroy()
}
})
})
</script>
<style>
:root {
--sidebar-width: 64px;
--sidebar-icon-size: 1.5rem;
}
</style>
<style scoped>
.side-tool-bar-container {
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
width: var(--sidebar-width);
height: 100%;
background-color: var(--comfy-menu-bg);
color: var(--fg-color);
}
.side-tool-bar-end {
align-self: flex-end;
margin-top: auto;
}
.sidebar-content-container {
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<SideBarTabTemplate :title="$t('sideToolBar.nodeLibrary')">
<template #tool-buttons>
<ToggleButton
v-model:model-value="alphabeticalSort"
on-icon="pi pi-sort-alpha-down"
off-icon="pi pi-sort-alt"
aria-label="Sort"
:pt="{
label: { style: { display: 'none' } }
}"
v-tooltip="$t('sideToolBar.nodeLibraryTab.sortOrder')"
>
</ToggleButton>
</template>
<template #body>
<TreePlus
class="node-lib-tree"
v-model:expandedKeys="expandedKeys"
selectionMode="single"
:value="renderedRoot.children"
:filter="true"
filterMode="lenient"
dragSelector=".p-tree-node-leaf"
:pt="{
nodeLabel: 'node-lib-tree-node-label',
nodeChildren: ({ props }) => ({
'data-comfy-node-name': props.node?.data?.name,
onMouseenter: (event: MouseEvent) => {
hoveredComfyNodeName = props.node?.data?.name
const hoverTarget = event.target as HTMLElement
const targetRect = hoverTarget.getBoundingClientRect()
nodePreviewStyle.top = `${targetRect.top - 40}px`
nodePreviewStyle.left = `${targetRect.right}px`
},
onMouseleave: () => {
hoveredComfyNodeName = null
}
})
}"
>
<template #folder="{ node }">
<span class="folder-label">{{ node.label }}</span>
<Badge
:value="node.totalNodes"
severity="secondary"
:style="{ marginLeft: '0.5rem' }"
></Badge>
</template>
<template #node="{ node }">
<span class="node-label">{{ node.label }}</span>
</template>
</TreePlus>
<div
v-if="hoveredComfyNode"
class="node-lib-node-preview"
:style="nodePreviewStyle"
>
<NodePreview
:key="hoveredComfyNode.name"
:nodeDef="hoveredComfyNode"
></NodePreview>
</div>
</template>
</SideBarTabTemplate>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import ToggleButton from 'primevue/togglebutton'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { computed, ref } from 'vue'
import { TreeNode } from 'primevue/treenode'
import TreePlus from '@/components/primevueOverride/TreePlus.vue'
import NodePreview from '@/components/NodePreview.vue'
import SideBarTabTemplate from '@/components/sidebar/tabs/SideBarTabTemplate.vue'
const nodeDefStore = useNodeDefStore()
const alphabeticalSort = ref(false)
const expandedKeys = ref({})
const hoveredComfyNodeName = ref<string | null>(null)
const hoveredComfyNode = computed<ComfyNodeDefImpl | null>(() => {
if (!hoveredComfyNodeName.value) {
return null
}
return nodeDefStore.nodeDefsByName[hoveredComfyNodeName.value] || null
})
const nodePreviewStyle = ref<Record<string, string>>({
position: 'absolute',
top: '0px',
left: '0px'
})
const root = computed(() =>
alphabeticalSort.value ? nodeDefStore.sortedNodeTree : nodeDefStore.nodeTree
)
const renderedRoot = computed(() => {
return fillNodeInfo(root.value)
})
const fillNodeInfo = (node: TreeNode): TreeNode => {
const isExpanded = expandedKeys.value[node.key]
const icon = node.leaf
? 'pi pi-circle-fill'
: isExpanded
? 'pi pi-folder-open'
: 'pi pi-folder'
const children = node.children?.map(fillNodeInfo)
return {
...node,
icon,
children,
type: node.leaf ? 'node' : 'folder',
totalNodes: node.leaf
? 1
: children.reduce((acc, child) => acc + child.totalNodes, 0)
}
}
</script>

View File

@@ -0,0 +1,171 @@
<template>
<DataTable
v-if="tasks.length > 0"
:value="tasks"
dataKey="promptId"
class="queue-table"
>
<Column header="STATUS">
<template #body="{ data }">
<Tag :severity="taskTagSeverity(data.displayStatus)">
{{ data.displayStatus.toUpperCase() }}
</Tag>
</template>
</Column>
<Column header="TIME" :pt="{ root: { class: 'queue-time-cell' } }">
<template #body="{ data }">
<div v-if="data.isHistory" class="queue-time-cell-content">
{{ formatTime(data.executionTimeInSeconds) }}
</div>
<div v-else-if="data.isRunning" class="queue-time-cell-content">
<i class="pi pi-spin pi-spinner"></i>
</div>
<div v-else class="queue-time-cell-content">...</div>
</template>
</Column>
<Column
:pt="{
headerCell: {
class: 'queue-tool-header-cell'
},
bodyCell: {
class: 'queue-tool-body-cell'
}
}"
>
<template #header>
<Toast />
<ConfirmPopup />
<Button
icon="pi pi-trash"
text
severity="primary"
@click="confirmRemoveAll($event)"
/>
</template>
<template #body="{ data }">
<Button
icon="pi pi-file-export"
text
severity="primary"
@click="data.loadWorkflow()"
/>
<Button
icon="pi pi-times"
text
severity="secondary"
@click="removeTask(data)"
/>
</template>
</Column>
</DataTable>
<div v-else>
<Message icon="pi pi-info" severity="error">
<span class="ml-2">No tasks</span>
</Message>
</div>
</template>
<script setup lang="ts">
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import Toast from 'primevue/toast'
import Message from 'primevue/message'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import {
TaskItemDisplayStatus,
TaskItemImpl,
useQueueStore
} from '@/stores/queueStore'
import { computed, onMounted } from 'vue'
import { api } from '@/scripts/api'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const tasks = computed(() => queueStore.tasks)
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warning'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
const removeTask = (task: TaskItemImpl) => {
if (task.isRunning) {
api.interrupt()
}
queueStore.delete(task)
}
const removeAllTasks = async () => {
await queueStore.clear()
}
const confirmRemoveAll = (event) => {
confirm.require({
target: event.currentTarget,
message: 'Do you want to delete all tasks?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
await removeAllTasks()
toast.add({
severity: 'info',
summary: 'Confirmed',
detail: 'Tasks deleted',
life: 3000
})
}
})
}
onMounted(() => {
api.addEventListener('status', () => {
queueStore.update()
})
queueStore.update()
})
</script>
<style>
.queue-tool-header-cell {
display: flex;
justify-content: flex-end;
}
.queue-tool-body-cell {
display: table-cell;
text-align: right !important;
}
</style>
<style scoped>
.queue-time-cell-content {
width: fit-content;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div class="comfy-vue-side-bar-container">
<Toolbar class="comfy-vue-side-bar-header">
<template #start>
<span class="comfy-vue-side-bar-header-span">{{
props.title.toUpperCase()
}}</span>
</template>
<template #end>
<slot name="tool-buttons"></slot>
</template>
</Toolbar>
<div class="comfy-vue-side-bar-body">
<slot name="body"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import Toolbar from 'primevue/toolbar'
const props = defineProps({
title: {
type: String,
required: true
}
})
</script>
<style scoped>
.comfy-vue-side-bar-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.comfy-vue-side-bar-header {
flex-shrink: 0;
border-left: none;
border-right: none;
border-top: none;
border-radius: 0;
padding: 0.25rem 1rem;
}
.comfy-vue-side-bar-header-span {
font-size: small;
}
.comfy-vue-side-bar-body {
flex-grow: 1;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.comfy-vue-side-bar-body::-webkit-scrollbar {
width: 1px;
}
.comfy-vue-side-bar-body::-webkit-scrollbar-thumb {
background-color: transparent;
}
</style>

View File

@@ -1,20 +1,20 @@
import { app } from "../../scripts/app";
import { ComfyDialog, $el } from "../../scripts/ui";
import { ComfyApp } from "../../scripts/app";
import { app } from '../../scripts/app'
import { ComfyDialog, $el } from '../../scripts/ui'
import { ComfyApp } from '../../scripts/app'
export class ClipspaceDialog extends ComfyDialog {
static items = [];
static instance = null;
static items = []
static instance = null
static registerButton(name, contextPredicate, callback) {
const item = $el("button", {
type: "button",
const item = $el('button', {
type: 'button',
textContent: name,
contextPredicate: contextPredicate,
onclick: callback,
});
onclick: callback
})
ClipspaceDialog.items.push(item);
ClipspaceDialog.items.push(item)
}
static invalidatePreview() {
@@ -24,161 +24,161 @@ export class ClipspaceDialog extends ComfyDialog {
ComfyApp.clipspace.imgs.length > 0
) {
const img_preview = document.getElementById(
"clipspace_preview"
) as HTMLImageElement;
'clipspace_preview'
) as HTMLImageElement
if (img_preview) {
img_preview.src =
ComfyApp.clipspace.imgs[ComfyApp.clipspace["selectedIndex"]].src;
img_preview.style.maxHeight = "100%";
img_preview.style.maxWidth = "100%";
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
img_preview.style.maxHeight = '100%'
img_preview.style.maxWidth = '100%'
}
}
}
static invalidate() {
if (ClipspaceDialog.instance) {
const self = ClipspaceDialog.instance;
const self = ClipspaceDialog.instance
// allow reconstruct controls when copying from non-image to image content.
const children = $el("div.comfy-modal-content", [
const children = $el('div.comfy-modal-content', [
self.createImgSettings(),
...self.createButtons(),
]);
...self.createButtons()
])
if (self.element) {
// update
self.element.removeChild(self.element.firstChild);
self.element.appendChild(children);
self.element.removeChild(self.element.firstChild)
self.element.appendChild(children)
} else {
// new
self.element = $el("div.comfy-modal", { parent: document.body }, [
children,
]);
self.element = $el('div.comfy-modal', { parent: document.body }, [
children
])
}
if (self.element.children[0].children.length <= 1) {
self.element.children[0].appendChild(
$el("p", {}, [
"Unable to find the features to edit content of a format stored in the current Clipspace.",
$el('p', {}, [
'Unable to find the features to edit content of a format stored in the current Clipspace.'
])
);
)
}
ClipspaceDialog.invalidatePreview();
ClipspaceDialog.invalidatePreview()
}
}
constructor() {
super();
super()
}
createButtons() {
const buttons = [];
const buttons = []
for (let idx in ClipspaceDialog.items) {
const item = ClipspaceDialog.items[idx];
const item = ClipspaceDialog.items[idx]
if (!item.contextPredicate || item.contextPredicate())
buttons.push(ClipspaceDialog.items[idx]);
buttons.push(ClipspaceDialog.items[idx])
}
buttons.push(
$el("button", {
type: "button",
textContent: "Close",
$el('button', {
type: 'button',
textContent: 'Close',
onclick: () => {
this.close();
},
this.close()
}
})
);
)
return buttons;
return buttons
}
createImgSettings() {
if (ComfyApp.clipspace.imgs) {
const combo_items = [];
const imgs = ComfyApp.clipspace.imgs;
const combo_items = []
const imgs = ComfyApp.clipspace.imgs
for (let i = 0; i < imgs.length; i++) {
combo_items.push($el("option", { value: i }, [`${i}`]));
combo_items.push($el('option', { value: i }, [`${i}`]))
}
const combo1 = $el(
"select",
'select',
{
id: "clipspace_img_selector",
id: 'clipspace_img_selector',
onchange: (event) => {
ComfyApp.clipspace["selectedIndex"] = event.target.selectedIndex;
ClipspaceDialog.invalidatePreview();
},
ComfyApp.clipspace['selectedIndex'] = event.target.selectedIndex
ClipspaceDialog.invalidatePreview()
}
},
combo_items
);
)
const row1 = $el("tr", {}, [
$el("td", {}, [$el("font", { color: "white" }, ["Select Image"])]),
$el("td", {}, [combo1]),
]);
const row1 = $el('tr', {}, [
$el('td', {}, [$el('font', { color: 'white' }, ['Select Image'])]),
$el('td', {}, [combo1])
])
const combo2 = $el(
"select",
'select',
{
id: "clipspace_img_paste_mode",
id: 'clipspace_img_paste_mode',
onchange: (event) => {
ComfyApp.clipspace["img_paste_mode"] = event.target.value;
},
ComfyApp.clipspace['img_paste_mode'] = event.target.value
}
},
[
$el("option", { value: "selected" }, "selected"),
$el("option", { value: "all" }, "all"),
$el('option', { value: 'selected' }, 'selected'),
$el('option', { value: 'all' }, 'all')
]
) as HTMLSelectElement;
combo2.value = ComfyApp.clipspace["img_paste_mode"];
) as HTMLSelectElement
combo2.value = ComfyApp.clipspace['img_paste_mode']
const row2 = $el("tr", {}, [
$el("td", {}, [$el("font", { color: "white" }, ["Paste Mode"])]),
$el("td", {}, [combo2]),
]);
const row2 = $el('tr', {}, [
$el('td', {}, [$el('font', { color: 'white' }, ['Paste Mode'])]),
$el('td', {}, [combo2])
])
const td = $el(
"td",
{ align: "center", width: "100px", height: "100px", colSpan: "2" },
[$el("img", { id: "clipspace_preview", ondragstart: () => false }, [])]
);
'td',
{ align: 'center', width: '100px', height: '100px', colSpan: '2' },
[$el('img', { id: 'clipspace_preview', ondragstart: () => false }, [])]
)
const row3 = $el("tr", {}, [td]);
const row3 = $el('tr', {}, [td])
return $el("table", {}, [row1, row2, row3]);
return $el('table', {}, [row1, row2, row3])
} else {
return [];
return []
}
}
createImgPreview() {
if (ComfyApp.clipspace.imgs) {
return $el("img", { id: "clipspace_preview", ondragstart: () => false });
} else return [];
return $el('img', { id: 'clipspace_preview', ondragstart: () => false })
} else return []
}
show() {
const img_preview = document.getElementById("clipspace_preview");
ClipspaceDialog.invalidate();
const img_preview = document.getElementById('clipspace_preview')
ClipspaceDialog.invalidate()
this.element.style.display = "block";
this.element.style.display = 'block'
}
}
app.registerExtension({
name: "Comfy.Clipspace",
name: 'Comfy.Clipspace',
init(app) {
app.openClipspace = function () {
if (!ClipspaceDialog.instance) {
ClipspaceDialog.instance = new ClipspaceDialog();
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate;
ClipspaceDialog.instance = new ClipspaceDialog()
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate
}
if (ComfyApp.clipspace) {
ClipspaceDialog.instance.show();
} else app.ui.dialog.show("Clipspace is Empty!");
};
},
});
ClipspaceDialog.instance.show()
} else app.ui.dialog.show('Clipspace is Empty!')
}
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +1,172 @@
import { LiteGraph, LGraphCanvas } from "@comfyorg/litegraph";
import { app } from "../../scripts/app";
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
// Adds filtering to combo context menus
const ext = {
name: "Comfy.ContextMenuFilter",
name: 'Comfy.ContextMenuFilter',
init() {
const ctxMenu = LiteGraph.ContextMenu;
const ctxMenu = LiteGraph.ContextMenu
// @ts-ignore
// TODO Very hacky way to modify Litegraph behaviour. Fix this later.
LiteGraph.ContextMenu = function (values, options) {
const ctx = ctxMenu.call(this, values, options);
const ctx = ctxMenu.call(this, values, options)
// If we are a dark menu (only used for combo boxes) then add a filter input
if (options?.className === "dark" && values?.length > 10) {
const filter = document.createElement("input");
filter.classList.add("comfy-context-menu-filter");
filter.placeholder = "Filter list";
this.root.prepend(filter);
if (options?.className === 'dark' && values?.length > 10) {
const filter = document.createElement('input')
filter.classList.add('comfy-context-menu-filter')
filter.placeholder = 'Filter list'
this.root.prepend(filter)
const items = Array.from(
this.root.querySelectorAll(".litemenu-entry")
) as HTMLElement[];
let displayedItems = [...items];
let itemCount = displayedItems.length;
this.root.querySelectorAll('.litemenu-entry')
) as HTMLElement[]
let displayedItems = [...items]
let itemCount = displayedItems.length
// We must request an animation frame for the current node of the active canvas to update.
requestAnimationFrame(() => {
// @ts-ignore
const currentNode = LGraphCanvas.active_canvas.current_node;
const currentNode = LGraphCanvas.active_canvas.current_node
const clickedComboValue = currentNode.widgets
?.filter(
(w) =>
w.type === "combo" && w.options.values.length === values.length
w.type === 'combo' && w.options.values.length === values.length
)
.find((w) =>
w.options.values.every((v, i) => v === values[i])
)?.value;
)?.value
let selectedIndex = clickedComboValue
? values.findIndex((v) => v === clickedComboValue)
: 0;
: 0
if (selectedIndex < 0) {
selectedIndex = 0;
selectedIndex = 0
}
let selectedItem = displayedItems[selectedIndex];
updateSelected();
let selectedItem = displayedItems[selectedIndex]
updateSelected()
// Apply highlighting to the selected item
function updateSelected() {
selectedItem?.style.setProperty("background-color", "");
selectedItem?.style.setProperty("color", "");
selectedItem = displayedItems[selectedIndex];
selectedItem?.style.setProperty('background-color', '')
selectedItem?.style.setProperty('color', '')
selectedItem = displayedItems[selectedIndex]
selectedItem?.style.setProperty(
"background-color",
"#ccc",
"important"
);
selectedItem?.style.setProperty("color", "#000", "important");
'background-color',
'#ccc',
'important'
)
selectedItem?.style.setProperty('color', '#000', 'important')
}
const positionList = () => {
const rect = this.root.getBoundingClientRect();
const rect = this.root.getBoundingClientRect()
// If the top is off-screen then shift the element with scaling applied
if (rect.top < 0) {
const scale =
1 -
this.root.getBoundingClientRect().height /
this.root.clientHeight;
const shift = (this.root.clientHeight * scale) / 2;
this.root.style.top = -shift + "px";
this.root.clientHeight
const shift = (this.root.clientHeight * scale) / 2
this.root.style.top = -shift + 'px'
}
};
}
// Arrow up/down to select items
filter.addEventListener("keydown", (event) => {
filter.addEventListener('keydown', (event) => {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
case 'ArrowUp':
event.preventDefault()
if (selectedIndex === 0) {
selectedIndex = itemCount - 1;
selectedIndex = itemCount - 1
} else {
selectedIndex--;
selectedIndex--
}
updateSelected();
break;
case "ArrowRight":
event.preventDefault();
selectedIndex = itemCount - 1;
updateSelected();
break;
case "ArrowDown":
event.preventDefault();
updateSelected()
break
case 'ArrowRight':
event.preventDefault()
selectedIndex = itemCount - 1
updateSelected()
break
case 'ArrowDown':
event.preventDefault()
if (selectedIndex === itemCount - 1) {
selectedIndex = 0;
selectedIndex = 0
} else {
selectedIndex++;
selectedIndex++
}
updateSelected();
break;
case "ArrowLeft":
event.preventDefault();
selectedIndex = 0;
updateSelected();
break;
case "Enter":
selectedItem?.click();
break;
case "Escape":
this.close();
break;
updateSelected()
break
case 'ArrowLeft':
event.preventDefault()
selectedIndex = 0
updateSelected()
break
case 'Enter':
selectedItem?.click()
break
case 'Escape':
this.close()
break
}
});
})
filter.addEventListener("input", () => {
filter.addEventListener('input', () => {
// Hide all items that don't match our filter
const term = filter.value.toLocaleLowerCase();
const term = filter.value.toLocaleLowerCase()
// When filtering, recompute which items are visible for arrow up/down and maintain selection.
displayedItems = items.filter((item) => {
const isVisible =
!term || item.textContent.toLocaleLowerCase().includes(term);
item.style.display = isVisible ? "block" : "none";
return isVisible;
});
!term || item.textContent.toLocaleLowerCase().includes(term)
item.style.display = isVisible ? 'block' : 'none'
return isVisible
})
selectedIndex = 0;
selectedIndex = 0
if (displayedItems.includes(selectedItem)) {
selectedIndex = displayedItems.findIndex(
(d) => d === selectedItem
);
)
}
itemCount = displayedItems.length;
itemCount = displayedItems.length
updateSelected();
updateSelected()
// If we have an event then we can try and position the list under the source
if (options.event) {
let top = options.event.clientY - 10;
let top = options.event.clientY - 10
const bodyRect = document.body.getBoundingClientRect();
const rootRect = this.root.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect()
const rootRect = this.root.getBoundingClientRect()
if (
bodyRect.height &&
top > bodyRect.height - rootRect.height - 10
) {
top = Math.max(0, bodyRect.height - rootRect.height - 10);
top = Math.max(0, bodyRect.height - rootRect.height - 10)
}
this.root.style.top = top + "px";
positionList();
this.root.style.top = top + 'px'
positionList()
}
});
})
requestAnimationFrame(() => {
// Focus the filter box when opening
filter.focus();
filter.focus()
positionList();
});
});
positionList()
})
})
}
return ctx;
};
return ctx
}
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
},
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype
}
}
app.registerExtension(ext);
app.registerExtension(ext)

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