Compare commits

..

122 Commits

Author SHA1 Message Date
filtered
c684732199 Allow fast widget conversion from any node 2024-10-31 03:32:26 +11:00
Chenlei Hu
74f7311585 Fix jest test mock (#1375) 2024-10-30 10:37:54 -04:00
Chenlei Hu
97c38583e9 Rename workspaceStateStore to workspaceStore (#1374) 2024-10-30 09:49:23 -04:00
Chenlei Hu
324eff93fd Update Litegraph API - canvas.state (#1372)
* Update Litegraph API - canvas.state

* Update litegraph

---------

Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2024-10-29 22:07:04 -04:00
Chenlei Hu
4b1104f52c Add node id to execution error report (#1371) 2024-10-29 21:48:45 -04:00
Chenlei Hu
d702fc81a2 1.3.28 (#1370) 2024-10-29 20:30:07 -04:00
Chenlei Hu
2a94ab4423 Fix empty model library folder content (#1369) 2024-10-29 20:28:50 -04:00
Chenlei Hu
37f6c89383 1.3.27 (#1366) 2024-10-29 17:56:28 -04:00
Chenlei Hu
35fab0bef3 Focus mode (#1365)
* Menu hamburger

* Focus

* nit

* nit

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-29 17:25:18 -04:00
filtered
795e932b8f Fix husky pre-commit & type check only staged (#1361)
* Fix husky pre-commit on some Windows clients

https://github.com/typicode/husky/issues/1072#issuecomment-1784006332

* Limit commit type check to staged files

Adds tsc-files package (dev only) and its config

* Remove deprecated git add from lint-staged
2024-10-29 14:15:07 -04:00
Chenlei Hu
8dddffe840 Use browser download in missing model dialog (#1362)
* Remove custom backend download logic

* Add download hooks

* Download button

* Use browser download

* Update test
2024-10-29 14:07:16 -04:00
Chenlei Hu
1f91a88d7b Move linkRenderMode extension to core (#1359) 2024-10-29 11:00:10 -04:00
Chenlei Hu
10f43be911 Remove show model folder checkbox in missing model dialog (#1358)
* Remove show model folder checkbox in missing model dialog

* nit

* nit
2024-10-29 10:15:31 -04:00
Chenlei Hu
87517daf1f Restyle missing model warning dialog (#1354)
* Restyle missing model dialog

* nit

* nit

* nit

* nit
2024-10-29 09:26:02 -04:00
Chenlei Hu
739ebd3d04 Auto-expand model library tree on search (#1357) 2024-10-29 09:24:56 -04:00
Chenlei Hu
4582c71583 [Refactor] Rework modelStore and ModelLibrarySidebarTab (#1350)
* nit

* Rename

* nit

* Move load model folders to app level

* Various fixes

* nit

* nit

* wip

* nit

* nit

* nit

* Split

* nit

* Add back spinner

* nit

* nit

* Add refresh button

* nit

* nit

* Preserve model folder order

* Avoid order change on folder open
2024-10-28 21:23:53 -04:00
Chenlei Hu
757f0ced81 1.3.26 (#1353) 2024-10-28 19:59:43 -04:00
Chenlei Hu
a471a3f302 [skip ci] Add typecheck pre-commit hook (#1352) 2024-10-28 19:56:04 -04:00
Chenlei Hu
5a3a8d32ab Update litegraph 0.8.10 (#1351) 2024-10-28 19:49:48 -04:00
Chenlei Hu
44b109a449 Add type annotation for missingNodeTypes (#1349)
* Add type annotation for missingNodeTypes

* nit

* nit
2024-10-28 17:26:52 -04:00
Chenlei Hu
229896a4b7 Restyle missing node warning dialog (#1348)
* nit

* Restyle missing node warning dialog

* nit

* nit
2024-10-28 16:45:47 -04:00
Chenlei Hu
997b5ee819 Update litegraph (Fix group select) (#1342)
* Update litegraph (Fix group select)

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-27 22:05:18 -04:00
Chenlei Hu
ba99eca700 1.3.25 (#1341) 2024-10-27 20:07:47 -04:00
Chenlei Hu
82c369322d Handle invalid node def errors (#1340)
* nit

* Add error handling

* nit

* nit
2024-10-27 20:07:05 -04:00
Chenlei Hu
546c5dabc8 Update litegraph (Slot highlight) (#1339)
* Update litegraph (Slot highlight)

* Disable tooltip setting in playwright tests

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-27 19:31:22 -04:00
filtered
7d450adf93 Remove unused param - litegraph update (#1335) 2024-10-27 16:21:14 -04:00
Chenlei Hu
eed92864f2 Enable ts-strict for changeTracker (#1338)
* Fix app getter

* nit

* nit

* nit

* Fix rest of errors

* nit
2024-10-27 16:20:32 -04:00
Chenlei Hu
8fd7852740 Enable ts-strict for nodeBookmarkStore (#1336) 2024-10-27 15:06:37 -04:00
Chenlei Hu
2a927bb9ea [skip ci] Update README (Dev section) (#1334)
* Expand test section

* Add techstack section
2024-10-27 10:12:54 -04:00
Chenlei Hu
c566491ac7 Enable ts-strict for queueStore (#1333) 2024-10-27 10:07:09 -04:00
Zoltán Dócs
7729611a2a Bugfix node widgets wrongly being prepared for qeueing on saving the workflow (#1331)
- calling graphToPrompt() invokes beforeQueued() which should not happen when saving the workflow
2024-10-27 09:59:27 -04:00
Chenlei Hu
8861492655 Enable ts-strict for workflowStore (#1332) 2024-10-27 09:59:17 -04:00
Chenlei Hu
fa9d944b32 Convert pinia stores from options API to composition API (#1330)
* Convert toastStore

* Convert workspaceStateStore

* Convert settingStore

* Convert queueStore

* Convert modelToNodeStore

* Convert modelStore

* Convert dialogStore

* nit

* nit

* nit
2024-10-27 08:47:24 -04:00
Chenlei Hu
880437f3c0 1.3.24 (#1326) 2024-10-26 19:50:05 -04:00
Chenlei Hu
c1e960c83c Update litegraph (Multi-group selection) (#1325) 2024-10-26 18:26:59 -04:00
Chenlei Hu
571386c061 Enable ts-strict for api.ts (#1324) 2024-10-26 18:08:24 -04:00
Chenlei Hu
44d886a18b Enable ts-strict for ui/settings (#1323) 2024-10-26 17:25:15 -04:00
Chenlei Hu
92ac403679 Enable ts-strict for settingStore (#1322) 2024-10-26 17:07:17 -04:00
Chenlei Hu
dc3dab4e1c Enable ts-strict for commandStore (#1321) 2024-10-26 17:05:42 -04:00
Chenlei Hu
386594554e Enable ts-strict for executionStore (#1320)
* Fix listener types

* nit

* nit

* fix type
2024-10-26 17:00:18 -04:00
Chenlei Hu
02a951ad58 Enable ts-strict for nodeDefStore (#1319) 2024-10-26 16:02:07 -04:00
Chenlei Hu
92f0f4a21c Convert nodeDefStore to use composition API (#1318)
* Convert nodeDefStore to use composition API

* nit
2024-10-26 15:48:40 -04:00
Chenlei Hu
645897f8b8 [Refactor] Make node badge a vue component (#1317)
* [Refactor] Make node badge a vue component

* Simplify badge logic

* nit
2024-10-26 15:28:14 -04:00
Chenlei Hu
ef4179a06c Update litegraph to 0.8.6 (#1316)
* Update litegraph

* Remove ts-expect-error
2024-10-26 14:36:45 -04:00
Chenlei Hu
a3e4af40c1 1.3.23 (#1314) 2024-10-25 20:13:42 -04:00
Björn Söderqvist
1dedce5ec6 Enable ts-strict for colorUtil, contextMenuFilter and linkRenderMode (#1313)
* Add types for colorUtil.ts

* Add types for contextMenuFilter

* Add types to linkRenderMode.ts
2024-10-25 15:42:15 -04:00
Chenlei Hu
3a4b36fb31 Disallow node library bookmark folder name with / (#1311) 2024-10-25 12:35:53 -04:00
Chenlei Hu
25457d31d4 Revert "Fix playwright test on settings API (#1303)" (#1310)
This reverts commit 54e833502a.
2024-10-25 12:09:50 -04:00
Chenlei Hu
4f9dc830b6 Setup clean setting state before every playwright test (#1309)
* Use reload

* setting setup

* nit

* Remove setting cleanups

* Wait for frame

* nit

* nit
2024-10-25 11:25:17 -04:00
Chenlei Hu
12d421b42c Update litegraph (TypeScript LiteGraphGlobal) (#1308) 2024-10-25 08:45:04 -04:00
Chenlei Hu
16ebfd6171 Fix import of NodeReference in ComfyPage (#1306) 2024-10-25 08:32:12 -04:00
Chenlei Hu
59c999324e Split ComfyPage fixture (#1305)
* Split down page components

* Move litegraph utils

* nit
2024-10-25 08:29:02 -04:00
Chenlei Hu
624bcc75ab Move ComfyPage to fixtures folder (#1304) 2024-10-25 08:01:50 -04:00
Chenlei Hu
54e833502a Fix playwright test on settings API (#1303) 2024-10-25 07:53:23 -04:00
Chenlei Hu
c3242711c7 Update bug-report.yaml (Add settings section) (#1301) 2024-10-25 07:46:45 -04:00
Chenlei Hu
17391e4aad Handle potential undefined node data in NodeBookmarkTreeExplorer (#1300) 2024-10-25 07:41:43 -04:00
Chenlei Hu
48b840a88d 1.3.22 (#1297) 2024-10-24 20:03:40 -04:00
Chenlei Hu
377fed584f [Extension API] Register custom bottom panel tabs (#1296)
* Bottom panel API

* Update README
2024-10-24 19:58:44 -04:00
Chenlei Hu
14a6687cc9 Integrated terminal (#1295)
* Add terminal tab

* Add basic terminal

* Style terminal

* Add keybinding

* Auto scroll:

* Mock for jest test
2024-10-24 17:31:00 -04:00
Chenlei Hu
d142893244 Add bottom panel support (#1294)
* Add bottom panel

* Bottom panel store

* Extract ExtensionSlot component

* Tab rendering

* Add toggle button on top menu bar

* nit

* Add toggle button tooltip

* Add command
2024-10-24 15:15:19 -04:00
Chenlei Hu
957a767ed0 New settings API (#1292)
* Add settings API

* Add playwright test

* Update README
2024-10-24 10:26:01 -04:00
YANG Zhitao
3553c8e0d4 Enable ts-strict for slotDefaults.ts (#1290) 2024-10-24 07:31:45 -04:00
YANG Zhitao
05221f7961 Enable ts-strict for clipspace.ts (#1291) 2024-10-24 07:31:15 -04:00
Chenlei Hu
d113072a64 1.3.21 (#1289) 2024-10-23 20:38:50 -04:00
Chenlei Hu
afa619b7df Revert "Enable ts-strict for invertMenuScrolling.ts (#1264)" (#1288)
This reverts commit 9388ee0705.
2024-10-23 20:37:40 -04:00
Chenlei Hu
e25bbc19cb Revert "Enable ts-strict for contextMenuFilter.ts (#1263)" (#1287)
This reverts commit b655c5544d.
2024-10-23 20:37:03 -04:00
Chenlei Hu
0251bc9e6c 1.3.20 (#1285) 2024-10-23 19:53:33 -04:00
Chenlei Hu
7a3f20c57d Fix sidebar scrollpanel style (#1283) 2024-10-23 18:27:54 -04:00
Chenlei Hu
269fc7c8c9 Remove misleading console error on paste (#1282) 2024-10-23 14:59:24 -04:00
Chenlei Hu
db08f74d6a Update litegraph (TypeScript LgraphNode) (#1281) 2024-10-23 14:38:09 -04:00
Chenlei Hu
5db757ade2 Enable ts-strict for uploadImage.ts (#1280) 2024-10-23 13:59:47 -04:00
Chenlei Hu
59c03d2de5 [Refactor] Rename ModelStore to ModelFolder (#1244)
* Refactor click

* Rename ModelStore to ModelFolder
2024-10-23 12:04:49 -04:00
YANG Zhitao
b655c5544d Enable ts-strict for contextMenuFilter.ts (#1263)
* Enable ts-strict for contextMenuFilter.ts

* function instead of arrow function

* tackle @ts-expect-error at contextMenuFilter.ts by use class instead of function + prototype
2024-10-23 12:03:50 -04:00
YANG Zhitao
9388ee0705 Enable ts-strict for invertMenuScrolling.ts (#1264) 2024-10-23 12:03:33 -04:00
juju
f228ec29eb Enable ts-strict for editAttention.ts (#1269)
* fix typecheck errors

* revert tsconfig strict true
2024-10-23 12:02:52 -04:00
Yoland Yan
7239e94092 Mini lang (#1245) 2024-10-13 09:56:58 +02:00
Chenlei Hu
e33a5f7736 1.3.19 (#1243) 2024-10-12 17:21:59 -04:00
Chenlei Hu
c1c990e6f3 Properly show empty folder in model sidebar tab (#1242)
* Properly show empty folder in model sidebar tab

* nit

* nit
2024-10-12 17:02:58 -04:00
Chenlei Hu
a890756868 Enable ts-strict for modelStore.ts (#1241) 2024-10-12 16:38:27 -04:00
Chenlei Hu
015ee2df15 Revert "Enable ts-strict for colorUtil.ts (#1239)" (#1240)
This reverts commit c3b2697568.
2024-10-12 15:58:43 -04:00
Chenlei Hu
c3b2697568 Enable ts-strict for colorUtil.ts (#1239) 2024-10-12 14:11:32 -04:00
Chenlei Hu
dfcabd2834 Enable ts-strict for treeUtil.ts (#1238) 2024-10-12 14:10:08 -04:00
Chenlei Hu
634196cbd6 Enable ts-strict for dialogStore.ts (#1236) 2024-10-12 12:17:55 -04:00
Chenlei Hu
5611e90fda Add ts-strict-ignore plugin (#1235)
* Add ts-strict-ignore plugin

* nit

* Add to typecheck script
2024-10-12 11:56:49 -04:00
Chenlei Hu
c23d95f8f9 1.3.18 (#1232) 2024-10-11 16:27:50 -04:00
Chenlei Hu
c2377b62ac Fix sidebar tab bg color (#1229)
* Fix node library sidebar style

* Fix styles

* nit
2024-10-11 15:04:29 -04:00
Chenlei Hu
f328d4cd81 Hide system scrollbar for queue sidebar tab (#1227) 2024-10-11 13:59:51 -04:00
Chenlei Hu
fbc1482b90 Use scrollpanel in sidebar template (#1226)
* Use scrollpanel in sidebar template

* Migrate

* nit
2024-10-11 13:41:41 -04:00
Chenlei Hu
90e07af4f5 Update litegraph (TypeScript LGraphCanvas) (#1225) 2024-10-11 13:39:19 -04:00
AustinMroz
014c3f3172 Fix load workflow with converted widget (#1222)
f74973 introduced a regression where a node which has had an input
added, will clobber a converted input of in a workflow created before
the input was added.

The prior behaviour was also incorrect (the new input would not exist on
the node), but would often be runnable.

Keeping the position of the converted widget and adding the new input to
the end is an unfortunate compromise. Doing it the other way around
breaks primitive nodes
2024-10-11 12:20:03 -04:00
Chenlei Hu
419009424b Handle missing extraPngInfo field in queue (#1223) 2024-10-11 12:16:35 -04:00
Chenlei Hu
f599c9bcb8 Fix format (#1224) 2024-10-11 12:16:17 -04:00
Chenlei Hu
6c696cddb9 Fix dialog header component error (#1221) 2024-10-11 11:55:13 -04:00
Chenlei Hu
d787c21f8b [skip ci] Explicitly specify prettier printWidth (#1220) 2024-10-11 11:53:35 -04:00
Chenlei Hu
f96f08be32 Add ru to locale list (#1218) 2024-10-11 10:43:10 -04:00
Ioan
8ebb51b9a3 Update i18n.ts (#1214)
* Update i18n.ts

Added Russian translation

* Update i18n.ts
2024-10-11 09:14:04 -04:00
Chenlei Hu
60e1b82df6 Update litegraph (TypeScript LLink) (#1213)
* Update litegraph (TypeScript LLink)

* Remove ts-expect-error
2024-10-10 20:54:31 -04:00
Chenlei Hu
459afa158c Restyle dialog with scrollpanel (#1212) 2024-10-10 20:27:14 -04:00
Chenlei Hu
1c3d3b33f6 1.3.17 (#1211) 2024-10-10 16:52:01 -04:00
Chenlei Hu
f64365915b Mark app.showMissingNodeDialog private (#1210) 2024-10-10 16:35:29 -04:00
Chenlei Hu
ec8e6f79b3 Fix create group node command error states (#1209)
* Fix edge cases

* Add playwright test

* nit
2024-10-10 15:56:00 -04:00
Chenlei Hu
b89f467983 Add group node commands/keybindings (#1208)
* Add group node commands/keybindings

* Fix jest tests
2024-10-10 12:50:05 -04:00
Chenlei Hu
009dbcf8c7 Wrap pragmatic dnd API with hooks (#1207) 2024-10-10 10:53:49 -04:00
Chenlei Hu
4413fd248c Remove question mark badge on folders in model library tree (#1205) 2024-10-10 10:13:14 -04:00
Chenlei Hu
8962597e69 Update litegraph (TypeScript LGraph) (#1206) 2024-10-10 10:13:02 -04:00
Chenlei Hu
f4d4111fbd 1.3.16 (#1202) 2024-10-09 22:17:57 -04:00
Chenlei Hu
babac5a4a9 1.3.15 (#1201) 2024-10-09 22:15:06 -04:00
Chenlei Hu
f71595fcc9 Fix node def handling of undefined fields (#1199)
* Fix node def handling

* nit

* Add test
2024-10-09 22:11:27 -04:00
Chenlei Hu
59a5f5f5d0 Add help menu on command menu bar (#1197)
* Add help menu on command menu bar

* nit
2024-10-09 20:35:17 -04:00
Chenlei Hu
2d5faa7f3d Anchor floating actionbar to closest side when resizing window (#1195)
* Anchor floating actionbar to closest side when resizing window

* nit

* nit
2024-10-09 17:41:36 -04:00
Chenlei Hu
32fa950aa1 1.3.14 (#1194) 2024-10-09 16:41:38 -04:00
Chenlei Hu
82112c2c6e Move low priority init to idle task (#1192) 2024-10-09 16:22:58 -04:00
Chenlei Hu
f94bdc358b Disable node def validation by default (#1190)
* Add setting

* Make node def validation optional
2024-10-09 16:02:27 -04:00
Chenlei Hu
f6466d7062 Avoid calling settingStore.set when there is no legacy node bookmark (#1191)
* Avoid calling settingStore.set when there is no legacy node bookmark

* nit
2024-10-09 16:02:14 -04:00
Chenlei Hu
1c5fd2465e Move vitejs/plugin-vue to devDep (#1189) 2024-10-09 15:16:43 -04:00
Chenlei Hu
e99329cff5 Remove class-transformer dependency (#1187)
* nit

* Fix test

* Remove class-transformer and its deps

* nit

* Fix invalid type for dummy node
2024-10-09 15:10:19 -04:00
bymyself
fabcbaec82 Restore backend state when Playwright test finishes (#1168)
* Restore backend state when Playwright test finishes. Resolve #1094

* Add warning when env var not set

* Rename and replace with scaffolding option for models dir

* Rename

* Define another env var [skip ci]

* Fix paths [skip ci]

* Update README.md

---------

Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2024-10-09 15:05:19 -04:00
Chenlei Hu
c8f50509ed Fix VHS advanced preview html video type (#1186)
* Fix VHS advanced preview html video type

* nit
2024-10-09 15:02:02 -04:00
Chenlei Hu
165604bb80 Support VHS advanced preview in queue sidebar tab (#1183)
* Map VHS video type

* Advance preview format

* nit

* View VHS advanced preview

* Disable result gallery vitest

* Proper disable
2024-10-09 12:42:00 -04:00
Chenlei Hu
829bce1c8c Load Keybinding and Extension panel async (#1179) 2024-10-08 22:20:30 -04:00
Chenlei Hu
c3b82165fa 1.3.13 (#1178) 2024-10-08 20:14:56 -04:00
Chenlei Hu
d673a521d8 Add always snap to grid setting (#1177)
* Always snap to grid

* Ban pysssss.SnapToGrid

* nit
2024-10-08 20:12:23 -04:00
202 changed files with 4481 additions and 3029 deletions

View File

@@ -12,6 +12,10 @@ DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# to ComfyUI launch script to serve the custom web version.
DEPLOY_COMFYUI_DIR=/home/ComfyUI/web
# The directory containing the ComfyUI installation used to run Playwright tests.
# If you aren't using a separate install for testing, point this to your regular install.
TEST_COMFYUI_DIR=/home/ComfyUI
# The directory containing the ComfyUI_examples repo used to extract test workflows.
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples

View File

@@ -50,6 +50,12 @@ body:
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
validations:
required: true
- type: textarea
attributes:
label: Setting JSON
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
validations:
required: true
- type: dropdown
id: browsers
attributes:

View File

@@ -1 +1,5 @@
npx lint-staged
if [[ "$OS" == "Windows_NT" ]]; then
npx.cmd lint-staged
else
npx lint-staged
fi

View File

@@ -2,5 +2,6 @@
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none"
"trailingComma": "none",
"printWidth": 80
}

View File

@@ -193,7 +193,76 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
</details>
### Node developers API
### Developer APIs
<details>
<summary>v1.3.22: Register bottom panel tabs</summary>
```js
app.registerExtension({
name: 'TestExtension',
bottomPanelTabs: [
{
id: 'TestTab',
title: 'Test Tab',
type: 'custom',
render: (el) => {
el.innerHTML = '<div>Custom tab</div>'
}
}
]
})
```
![image](https://github.com/user-attachments/assets/2114f8b8-2f55-414b-b027-78e61c870b64)
</details>
<details>
<summary>v1.3.22: New settings API</summary>
Legacy settings API.
```js
// Register a new setting
app.ui.settings.addSetting({
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!'
})
// Get the value of a setting
const value = app.ui.settings.getSettingValue('TestSetting')
// Set the value of a setting
app.ui.settings.setSettingValue('TestSetting', 'Hello, universe!')
```
New settings API.
```js
// Register a new setting
app.registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!'
}
]
})
// Get the value of a setting
const value = app.extensionManager.setting.get('TestSetting')
// Set the value of a setting
app.extensionManager.setting.set('TestSetting', 'Hello, universe!')
```
</details>
<details>
<summary>v1.3.7: Register commands and keybindings</summary>
@@ -298,6 +367,14 @@ We will support custom icons later.
## Development
### Tech Stack
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [Litegraph](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
### Git pre-commit hooks
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
@@ -312,7 +389,7 @@ core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
### Test
### Unit Test
- `git clone https://github.com/comfyanonymous/ComfyUI_examples.git` to `tests-ui/ComfyUI_examples` or the EXAMPLE_REPO_PATH location specified in .env
- `npm i` to install all dependencies
@@ -320,6 +397,16 @@ core extensions will be loaded.
- `npm run test:generate:examples` to extract the example workflows
- `npm run test` to execute all unit tests.
### Component Test
Component test verifies Vue components in `src/components/`.
- `npm run test:component` to execute all component tests.
### Playwright Test
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
### LiteGraph
This repo is using litegraph package hosted on <https://github.com/Comfy-Org/litegraph.js>. Any changes to litegraph should be submitted in that repo instead.
@@ -327,7 +414,6 @@ This repo is using litegraph package hosted on <https://github.com/Comfy-Org/lit
### Test litegraph changes
- Run `npm link` in the local litegraph repo.
- Run `npm uninstall @comfyorg/litegraph` in this repo.
- Run `npm link @comfyorg/litegraph` in this repo.
This will replace the litegraph package in this repo with the local litegraph repo.

View File

@@ -5,7 +5,7 @@ This document outlines the setup and usage of Playwright for testing the ComfyUI
## WARNING
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
Please backup your ComfyUI data before running the tests locally.
If `TEST_COMFYUI_DIR` in `.env` isn't set to your `(Comfy Path)/ComfyUI` directory, these changes won't be automatically restored.
## Setup

View File

@@ -1,7 +1,7 @@
import type { Response } from '@playwright/test'
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from './ComfyPage'
import { comfyPageFixture } from './fixtures/ComfyPage'
import { webSocketFixture } from './fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -11,10 +11,6 @@ test.describe('Actionbar', () => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
/**
* This test ensures that the autoqueue change mode can only queue one change at a time
*/

View File

@@ -0,0 +1,128 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "ControlNetApplyAdvanced",
"pos": {
"0": 449,
"1": 204
},
"size": [
340.20001220703125,
166
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "control_net",
"type": "CONTROL_NET",
"link": null
},
{
"name": "image",
"type": "IMAGE",
"link": null
},
{
"name": "strength",
"type": "FLOAT",
"link": 1,
"widget": {
"name": "strength"
}
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": null
},
{
"name": "negative",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "ControlNetApplyAdvanced"
},
"widgets_values": [
1,
0,
1
]
},
{
"id": 2,
"type": "PrimitiveNode",
"pos": {
"0": 177,
"1": 265
},
"size": [
210,
82
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [
1
],
"widget": {
"name": "strength"
}
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
1,
"fixed"
]
}
],
"links": [
[
1,
2,
0,
1,
4,
"FLOAT"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": {
"0": 47.541666666666515,
"1": 186.9375
}
}
},
"version": 0.4
}

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Browser tab title', () => {
test.describe('Beta Menu', () => {
@@ -7,10 +7,6 @@ test.describe('Browser tab title', () => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can display workflow name', async ({ comfyPage }) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].workflowManager.activeWorkflow.name

View File

@@ -2,7 +2,7 @@ import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from './ComfyPage'
} from './fixtures/ComfyPage'
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
const customColorPalettes = {
obsidian: {
@@ -135,12 +135,6 @@ test.describe('Color Palette', () => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', {})
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
})
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
await comfyPage.nextFrame()
@@ -158,11 +152,6 @@ test.describe('Node Color Adjustments', () => {
await comfyPage.loadWorkflow('every_node_color')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Keybindings', () => {
test('Should execute command', async ({ comfyPage }) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
@@ -63,45 +63,15 @@ test.describe('Missing models warning', () => {
const downloadButton = comfyPage.page.getByLabel('Download')
await expect(downloadButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadButton.click()
const downloadComplete = comfyPage.page.locator('.download-complete')
await expect(downloadComplete).toBeVisible()
})
test('Can configure download folder', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const folderSelectToggle = comfyPage.page.locator(
'.model-path-select-checkbox'
)
const folderSelect = comfyPage.page.locator('.model-path-select')
await expect(folderSelectToggle).toBeVisible()
await expect(folderSelect).not.toBeVisible()
await folderSelectToggle.click() // show the selectors
await expect(folderSelect).toBeVisible()
await folderSelect.click() // open dropdown
await expect(folderSelect).toHaveClass(/p-select-open/)
await folderSelect.click() // close the dropdown
await expect(folderSelect).not.toHaveClass(/p-select-open/)
await folderSelectToggle.click() // hide the selectors
await expect(folderSelect).not.toBeVisible()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
})
})
test.describe('Settings', () => {
test.afterEach(async ({ comfyPage }) => {
// Restore default setting value
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const searchBox = comfyPage.page.locator('.settings-content')

View File

@@ -1,15 +1,11 @@
import { expect, Locator } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Topbar commands', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
@@ -83,4 +79,23 @@ test.describe('Topbar commands', () => {
true
)
})
test('Should allow adding settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!'
}
]
})
})
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
})
})

View File

@@ -1,287 +1,22 @@
import type { Page, Locator, APIRequestContext } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
import { ComfyActionbar } from './helpers/actionbar'
import { ComfyActionbar } from '../helpers/actionbar'
import dotenv from 'dotenv'
dotenv.config()
import * as fs from 'fs'
import { NodeBadgeMode } from '../src/types/nodeSource'
import type { NodeId } from '../src/types/comfyWorkflow'
import type { KeyCombo } from '../src/types/keyBindingTypes'
import { ManageGroupNode } from './helpers/manageGroupNode'
import { ComfyTemplates } from './helpers/templates'
interface Position {
x: number
y: number
}
interface Size {
width: number
height: number
}
class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
async selectFilterType(filterType: string) {
await this.page
.locator(
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
)
.click()
}
async selectFilterValue(filterValue: string) {
await this.page.locator('.filter-value-select .p-select-dropdown').click()
await this.page
.locator(
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.locator('.p-button-label:has-text("Add")').click()
}
}
class ComfyNodeSearchBox {
public readonly input: Locator
public readonly dropdown: Locator
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
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'
)
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
}
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
}
async fillAndSelectFirstNode(
nodeName: string,
options?: { suggestionIndex: number }
) {
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(options?.suggestionIndex || 0)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.filterButton.click()
await this.filterSelectionPanel.addFilter(filterValue, filterType)
}
get filterChips() {
return this.page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
}
class SidebarTab {
constructor(
public readonly page: Page,
public readonly tabId: string
) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
)
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
}
}
class NodeLibrarySidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'node-library')
}
get nodeLibrarySearchBoxInput() {
return this.page.locator('.node-lib-search-box input[type="text"]')
}
get nodeLibraryTree() {
return this.page.locator('.node-lib-tree-explorer')
}
get nodePreview() {
return this.page.locator('.node-lib-node-preview')
}
get tabContainer() {
return this.page.locator('.sidebar-content-container')
}
get newFolderButton() {
return this.tabContainer.locator('.new-folder-button')
}
async open() {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async close() {
if (!this.tabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
folderSelector(folderName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
}
getFolder(folderName: string) {
return this.page.locator(this.folderSelector(folderName))
}
nodeSelector(nodeName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
}
getNode(nodeName: string) {
return this.page.locator(this.nodeSelector(nodeName))
}
}
class WorkflowsSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'workflows')
}
get browseGalleryButton() {
return this.page.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.page.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.page.locator('.open-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.page.locator(
'.comfyui-workflows-open .node-label',
{ hasText: workflowName }
)
await workflowLocator.click()
await this.page.waitForTimeout(300)
}
}
class Topbar {
constructor(public readonly page: Page) {}
async getTabNames(): Promise<string[]> {
return await this.page
.locator('.workflow-tabs .workflow-label')
.allInnerTexts()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
async getMenuItem(itemLabel: string): Promise<Locator> {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
async getWorkflowTab(tabName: string): Promise<Locator> {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
async closeWorkflowTab(tabName: string) {
const tab = await this.getWorkflowTab(tabName)
await tab.locator('.close-button').click({ force: true })
}
async saveWorkflow(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
)
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page
.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
}
}
}
import { NodeBadgeMode } from '../../src/types/nodeSource'
import type { NodeId } from '../../src/types/comfyWorkflow'
import type { KeyCombo } from '../../src/types/keyBindingTypes'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import {
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { NodeReference } from './utils/litegraphUtils'
import type { Position, Size } from './types'
class ComfyMenu {
public readonly sideToolbar: Locator
@@ -420,7 +155,20 @@ export class ComfyPage {
}
}
async setup({ resetView = true } = {}) {
async setupSettings(settings: Record<string, any>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
{
data: settings
}
)
if (resp.status() !== 200) {
throw new Error(`Failed to setup settings: ${await resp.text()}`)
}
}
async setup() {
await this.goto()
await this.page.evaluate(() => {
localStorage.clear()
@@ -441,26 +189,13 @@ export class ComfyPage {
})
await this.page.waitForFunction(() => document.fonts.ready)
await this.page.waitForFunction(
() => window['app'] !== undefined && window['app'].vueAppReady
() =>
// window['app'] => GraphCanvas ready
// window['app'].extensionManager => GraphView ready
window['app'] && window['app'].extensionManager
)
await this.page.evaluate(() => {
window['app']['canvas'].show_info = false
})
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
await this.nextFrame()
if (resetView) {
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await this.resetView()
}
// Hide all badges by default.
await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None)
await this.setSetting(
'Comfy.NodeBadge.NodeSourceBadgeMode',
NodeBadgeMode.None
)
// Hide canvas menu by default.
await this.setSetting('Comfy.Graph.CanvasMenu', false)
}
public assetPath(fileName: string) {
@@ -528,7 +263,7 @@ export class ComfyPage {
async setSetting(settingId: string, settingValue: any) {
return await this.page.evaluate(
async ({ id, value }) => {
await window['app'].ui.settings.setSettingValueAsync(id, value)
await window['app'].extensionManager.setting.set(id, value)
},
{ id: settingId, value: settingValue }
)
@@ -536,7 +271,7 @@ export class ComfyPage {
async getSetting(settingId: string) {
return await this.page.evaluate(async (id) => {
return await window['app'].ui.settings.getSettingValue(id)
return await window['app'].extensionManager.setting.get(id)
}, settingId)
}
@@ -575,6 +310,10 @@ export class ComfyPage {
await this.nextFrame()
}
async getVisibleToastCount() {
return await this.page.locator('.p-toast:visible').count()
}
async clickTextEncodeNode1() {
await this.canvas.click({
position: {
@@ -980,261 +719,19 @@ export class ComfyPage {
}
}
export class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
readonly node: NodeReference
) {}
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
},
[this.type, this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
}
return node.outputs[index].links?.length ?? 0
},
[this.type, this.node.id, this.index] as const
)
}
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
} else {
node.disconnectOutput(index)
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, h] = node.getBounding()
return window['app'].canvas.ds.convertOffsetToCanvas([
x + w / 2,
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
])
},
[this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeReference {
constructor(
readonly id: NodeId,
readonly comfyPage: ComfyPage
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return !!node
}, this.id)
}
getType(): Promise<string> {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
x: pos[0],
y: pos[1]
}
}
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
return {
x,
y,
width,
height
}
}
async getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
width: size[0],
height: size[1]
}
}
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
return await this.getProperty('flags')
}
async isPinned() {
return !!(await this.getFlags()).pinned
}
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
[this.id, prop] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}
async getInput(index: number) {
return new NodeSlotReference('input', index, this)
}
async getWidget(index: number) {
return new NodeWidgetReference(index, this)
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
break
case 'collapse':
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
break
default:
throw new Error(`Invalid click position ${position}`)
}
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
if (options) {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
await this.comfyPage.moveMouseToEmptyArea()
}
}
async copy() {
await this.click('title')
await this.comfyPage.ctrlC()
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,
targetWidgetIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getPosition()
)
return originSlot
}
async connectOutput(
originSlotIndex: number,
targetNode: NodeReference,
targetSlotIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
return originSlot
}
async getContextMenuOptionNames() {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
return await ctx.locator('.litemenu-entry').allInnerTexts()
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
return new ManageGroupNode(
this.comfyPage.page,
this.comfyPage.page.locator('.comfy-group-manage')
)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page, request }, use) => {
const comfyPage = new ComfyPage(page, request)
await comfyPage.setupSettings({
// Hide canvas menu/info by default.
'Comfy.Graph.CanvasInfo': false,
'Comfy.Graph.CanvasMenu': false,
// Hide all badges by default.
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
// Disable tooltips by default to avoid flakiness.
'Comfy.EnableTooltips': false
})
await comfyPage.setup()
await use(comfyPage)
}

View File

@@ -0,0 +1,79 @@
import { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
async selectFilterType(filterType: string) {
await this.page
.locator(
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
)
.click()
}
async selectFilterValue(filterValue: string) {
await this.page.locator('.filter-value-select .p-select-dropdown').click()
await this.page
.locator(
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.locator('.p-button-label:has-text("Add")').click()
}
}
export class ComfyNodeSearchBox {
public readonly input: Locator
public readonly dropdown: Locator
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
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'
)
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
}
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
}
async fillAndSelectFirstNode(
nodeName: string,
options?: { suggestionIndex: number }
) {
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(options?.suggestionIndex || 0)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.filterButton.click()
await this.filterSelectionPanel.addFilter(filterValue, filterType)
}
get filterChips() {
return this.page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
}

View File

@@ -0,0 +1,120 @@
import { Page } from '@playwright/test'
class SidebarTab {
constructor(
public readonly page: Page,
public readonly tabId: string
) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
)
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
}
}
export class NodeLibrarySidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'node-library')
}
get nodeLibrarySearchBoxInput() {
return this.page.locator('.node-lib-search-box input[type="text"]')
}
get nodeLibraryTree() {
return this.page.locator('.node-lib-tree-explorer')
}
get nodePreview() {
return this.page.locator('.node-lib-node-preview')
}
get tabContainer() {
return this.page.locator('.sidebar-content-container')
}
get newFolderButton() {
return this.tabContainer.locator('.new-folder-button')
}
async open() {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async close() {
if (!this.tabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
folderSelector(folderName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
}
getFolder(folderName: string) {
return this.page.locator(this.folderSelector(folderName))
}
nodeSelector(nodeName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
}
getNode(nodeName: string) {
return this.page.locator(this.nodeSelector(nodeName))
}
}
export class WorkflowsSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'workflows')
}
get browseGalleryButton() {
return this.page.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.page.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.page.locator('.open-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.page.locator(
'.comfyui-workflows-open .node-label',
{ hasText: workflowName }
)
await workflowLocator.click()
await this.page.waitForTimeout(300)
}
}

View File

@@ -0,0 +1,66 @@
import { Locator, Page } from '@playwright/test'
export class Topbar {
constructor(public readonly page: Page) {}
async getTabNames(): Promise<string[]> {
return await this.page
.locator('.workflow-tabs .workflow-label')
.allInnerTexts()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
async getMenuItem(itemLabel: string): Promise<Locator> {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
async getWorkflowTab(tabName: string): Promise<Locator> {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
async closeWorkflowTab(tabName: string) {
const tab = await this.getWorkflowTab(tabName)
await tab.locator('.close-button').click({ force: true })
}
async saveWorkflow(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
)
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page
.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
}
}
}

View File

@@ -0,0 +1,9 @@
export interface Position {
x: number
y: number
}
export interface Size {
width: number
height: number
}

View File

@@ -0,0 +1,257 @@
import { ManageGroupNode } from '../../helpers/manageGroupNode'
import type { NodeId } from '../../../src/types/comfyWorkflow'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import type { Position, Size } from '../types'
export class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
readonly node: NodeReference
) {}
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
},
[this.type, this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
}
return node.outputs[index].links?.length ?? 0
},
[this.type, this.node.id, this.index] as const
)
}
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
} else {
node.disconnectOutput(index)
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, h] = node.getBounding()
return window['app'].canvas.ds.convertOffsetToCanvas([
x + w / 2,
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
])
},
[this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeReference {
constructor(
readonly id: NodeId,
readonly comfyPage: ComfyPage
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return !!node
}, this.id)
}
getType(): Promise<string> {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
x: pos[0],
y: pos[1]
}
}
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
return {
x,
y,
width,
height
}
}
async getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
width: size[0],
height: size[1]
}
}
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
return await this.getProperty('flags')
}
async isPinned() {
return !!(await this.getFlags()).pinned
}
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
[this.id, prop] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}
async getInput(index: number) {
return new NodeSlotReference('input', index, this)
}
async getWidget(index: number) {
return new NodeWidgetReference(index, this)
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
break
case 'collapse':
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
break
default:
throw new Error(`Invalid click position ${position}`)
}
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
if (options) {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
await this.comfyPage.moveMouseToEmptyArea()
}
}
async copy() {
await this.click('title')
await this.comfyPage.ctrlC()
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,
targetWidgetIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getPosition()
)
return originSlot
}
async connectOutput(
originSlotIndex: number,
targetNode: NodeReference,
targetSlotIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
return originSlot
}
async getContextMenuOptionNames() {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
return await ctx.locator('.litemenu-entry').allInnerTexts()
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
return new ManageGroupNode(
this.comfyPage.page,
this.comfyPage.page.locator('.comfy-group-manage')
)
}
}

View File

@@ -0,0 +1,20 @@
import { FullConfig } from '@playwright/test'
import { backupPath } from './utils/backupUtils'
import dotenv from 'dotenv'
dotenv.config()
export default function globalSetup(config: FullConfig) {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
backupPath([process.env.TEST_COMFYUI_DIR, 'models'], {
renameAndReplaceWithScaffolding: true
})
} else {
console.warn(
'Set TEST_COMFYUI_DIR in .env to prevent user data (settings, workflows, etc.) from being overwritten'
)
}
}
}

View File

@@ -0,0 +1,12 @@
import { FullConfig } from '@playwright/test'
import { restorePath } from './utils/backupUtils'
import dotenv from 'dotenv'
dotenv.config()
export default function globalTeardown(config: FullConfig) {
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])
}
}

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Graph Canvas Menu', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,11 +1,8 @@
import { expect } from '@playwright/test'
import { ComfyPage, NodeReference, comfyPageFixture as test } from './ComfyPage'
import { ComfyPage, comfyPageFixture as test } from './fixtures/ComfyPage'
import type { NodeReference } from './fixtures/utils/litegraphUtils'
test.describe('Group Node', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
@@ -19,11 +16,6 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await libraryTab.close()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
@@ -98,6 +90,7 @@ test.describe('Group Node', () => {
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
const tooltipTimeout = 500
@@ -192,13 +185,6 @@ test.describe('Group Node', () => {
await groupNode.copy()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.page.evaluate((groupNodeName) => {
window['LiteGraph'].unregisterNodeType(groupNodeName)
}, GROUP_NODE_TYPE)
})
test('Copies and pastes group node within the same workflow', async ({
comfyPage
}) => {
@@ -252,4 +238,20 @@ test.describe('Group Node', () => {
})
})
})
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.page.keyboard.press('Alt+g')
await comfyPage.page.waitForTimeout(300)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.clickTextEncodeNode1()
await comfyPage.page.keyboard.press('Alt+g')
await comfyPage.page.waitForTimeout(300)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
@@ -73,6 +73,8 @@ test.describe('Node Interaction', () => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge()
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
@@ -493,10 +495,6 @@ test.describe('Load duplicate workflow', () => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('A workflow can be loaded multiple times in a row', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 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: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.2 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: 90 KiB

After

Width:  |  Height:  |  Size: 89 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: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Keybindings', () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Load Workflow in Media', () => {
;[

View File

@@ -1,19 +1,11 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// Skip reason: Flaky.
test.skip('Toggle theme', async ({ comfyPage }) => {
test.setTimeout(30000)
@@ -25,8 +17,7 @@ test.describe('Menu', () => {
expect(await comfyPage.menu.getThemeId()).toBe('light')
// Theme id should persist after reload.
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.reload()
expect(await comfyPage.menu.getThemeId()).toBe('light')
await comfyPage.menu.toggleTheme()
@@ -368,8 +359,7 @@ test.describe('Menu', () => {
'KSampler'
])
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.reload()
expect(await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks')).toEqual(
[]
)
@@ -412,7 +402,7 @@ test.describe('Menu', () => {
'workflow2.json': 'default.json'
})
// Avoid reset view as the button is not visible in BetaMenu UI.
await comfyPage.setup({ resetView: false })
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import type { ComfyApp } from '../src/scripts/app'
import { NodeBadgeMode } from '../src/types/nodeSource'

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
@@ -32,4 +32,16 @@ test.describe('Optional input', () => {
// If the node's multiline text widget is visible, then it was loaded successfully
expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(1)
})
test('Old workflow with converted input', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('old_workflow_converted_input')
const node = await comfyPage.getNodeRefById('1')
const inputs = await node.getProperty('inputs')
const vaeInput = inputs.find((w) => w.name === 'vae')
const convertedInput = inputs.find((w) => w.name === 'strength')
expect(vaeInput).toBeDefined()
expect(convertedInput).toBeDefined()
expect(vaeInput.link).toBeNull()
expect(convertedInput.link).not.toBeNull()
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Node search box', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import { type NodeReference, comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import type { NodeReference } from './fixtures/utils/litegraphUtils'
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {

View File

@@ -1,15 +1,11 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Properties Panel', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// TODO: Update expectation after new menu dropdown is added.
test.skip('Can change property value', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { NodeBadgeMode } from '../src/types/nodeSource'
test.describe('Canvas Right Click Menu', () => {
@@ -96,11 +96,6 @@ test.describe('Node Right Click Menu', () => {
test.describe('Widget conversion', () => {
const convertibleWidgetTypes = ['text', 'string', 'number', 'toggle']
test.afterEach(async ({ comfyPage }) => {
// Restore default setting value
await comfyPage.setSetting('Comfy.NodeInputConversionSubmenus', true)
})
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,15 +1,11 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can load template workflows', async ({ comfyPage }) => {
// This test will need expanding on once the templates are decided

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Combo text widget', () => {
test('Truncates text when resized', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,69 @@
import path from 'path'
import fs from 'fs-extra'
type PathParts = readonly [string, ...string[]]
const getBackupPath = (originalPath: string): string => `${originalPath}.bak`
const resolvePathIfExists = (pathParts: PathParts): string | null => {
const resolvedPath = path.resolve(...pathParts)
if (!fs.pathExistsSync(resolvedPath)) {
console.warn(`Path not found: ${resolvedPath}`)
return null
}
return resolvedPath
}
const createScaffoldingCopy = (srcDir: string, destDir: string) => {
// Get all items (files and directories) in the source directory
const items = fs.readdirSync(srcDir, { withFileTypes: true })
for (const item of items) {
const srcPath = path.join(srcDir, item.name)
const destPath = path.join(destDir, item.name)
if (item.isDirectory()) {
// Create the corresponding directory in the destination
fs.ensureDirSync(destPath)
// Recursively copy the directory structure
createScaffoldingCopy(srcPath, destPath)
}
}
}
export function backupPath(
pathParts: PathParts,
{ renameAndReplaceWithScaffolding = false } = {}
) {
const originalPath = resolvePathIfExists(pathParts)
if (!originalPath) return
const backupPath = getBackupPath(originalPath)
try {
if (renameAndReplaceWithScaffolding) {
// Rename the original path and create scaffolding in its place
fs.moveSync(originalPath, backupPath)
createScaffoldingCopy(backupPath, originalPath)
} else {
// Create a copy of the original path
fs.copySync(originalPath, backupPath)
}
} catch (error) {
console.error(`Failed to backup ${originalPath} from ${backupPath}`, error)
}
}
export function restorePath(pathParts: PathParts) {
const originalPath = resolvePathIfExists(pathParts)
if (!originalPath) return
const backupPath = getBackupPath(originalPath)
if (!fs.pathExistsSync(backupPath)) return
try {
fs.moveSync(backupPath, originalPath, { overwrite: true })
} catch (error) {
console.error(`Failed to restore ${originalPath} from ${backupPath}`, error)
}
}

View File

@@ -36,7 +36,6 @@
</form>
</main>
</div>
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module" src="src/main.ts"></script>
</body>
</html>

769
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.3.12",
"version": "1.3.28",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"deploy": "npm run build && node scripts/deploy.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc-strict",
"format": "prettier --write './**/*.{js,ts,tsx,vue}'",
"test": "npm run build && jest",
"test:generate:examples": "npx tsx tests-ui/extractExamples",
@@ -30,6 +30,7 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
"@vue/vue3-jest": "^29.2.6",
"autoprefixer": "^10.4.19",
@@ -52,9 +53,11 @@
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsc-files": "^1.1.4",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
"typescript-strict-plugin": "^2.4.4",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.6",
@@ -63,20 +66,17 @@
"zip-dir": "^2.0.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.8.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/litegraph": "^0.8.11",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
"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.5",
"reflect-metadata": "^0.2.2",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
@@ -84,9 +84,7 @@
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"./**/*.{js,ts,tsx,vue}": [
"prettier --write",
"git add"
]
"./**/*.{js,ts,tsx,vue}": "prettier --write",
"**/*.ts": "tsc-files --noEmit"
}
}

View File

@@ -30,6 +30,10 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
/* Path to global setup file. Exported function runs once before all the tests */
globalSetup: './browser_tests/globalSetup.ts',
/* Path to global teardown file. Exported function runs once after all the tests */
globalTeardown: './browser_tests/globalTeardown.ts',
/* Configure projects for major browsers */
projects: [

View File

@@ -11,7 +11,7 @@
<script setup lang="ts">
import config from '@/config'
import { computed, onMounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'

View File

@@ -181,25 +181,6 @@ body {
margin: 3px 3px 3px 4px;
}
.comfy-menu-hamburger {
position: fixed;
top: 10px;
z-index: 9999;
right: 10px;
width: 30px;
display: none;
gap: 8px;
flex-direction: column;
cursor: pointer;
}
.comfy-menu-hamburger div {
height: 3px;
width: 100%;
border-radius: 20px;
background-color: white;
}
.comfy-menu {
font-size: 15px;
position: absolute;

View File

@@ -1,5 +1,8 @@
<template>
<Splitter class="splitter-overlay" :pt:gutter="gutterClass">
<Splitter
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
>
<SplitterPanel
class="side-bar-panel"
:minSize="10"
@@ -9,9 +12,22 @@
>
<slot name="side-bar-panel"></slot>
</SplitterPanel>
<SplitterPanel class="graph-canvas-panel relative" :size="100">
<slot name="graph-canvas-panel"></slot>
<SplitterPanel :size="100">
<Splitter
class="splitter-overlay"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel"></slot>
</SplitterPanel>
<SplitterPanel class="bottom-panel" v-show="bottomPanelVisible">
<slot name="bottom-panel"></slot>
</SplitterPanel>
</Splitter>
</SplitterPanel>
<SplitterPanel
class="side-bar-panel"
:minSize="10"
@@ -26,7 +42,8 @@
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
@@ -37,42 +54,39 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
)
const sidebarPanelVisible = computed(
() => useWorkspaceStore().sidebarTab.activeSidebarTab !== null
() => useSidebarTabStore().activeSidebarTab !== null
)
const bottomPanelVisible = computed(
() => useBottomPanelStore().bottomPanelVisible
)
const gutterClass = computed(() => {
return sidebarPanelVisible.value ? '' : 'gutter-hidden'
})
</script>
<style>
.p-splitter-gutter {
<style scoped>
:deep(.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;
}
.bottom-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;
@apply bg-transparent pointer-events-none border-none;
}
.splitter-overlay-root {
@apply w-full h-full absolute top-0 left-0;
/* 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,46 @@
<template>
<Button
v-show="workspaceState.focusMode"
class="comfy-menu-hamburger"
icon="pi pi-bars"
severity="secondary"
text
size="large"
@click="exitFocusMode"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { watchEffect } from 'vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const exitFocusMode = () => {
workspaceState.focusMode = false
}
watchEffect(() => {
if (settingStore.get('Comfy.UseNewMenu') !== 'Disabled') {
return
}
if (workspaceState.focusMode) {
app.ui.menuContainer.style.display = 'none'
} else {
app.ui.menuContainer.style.display = 'block'
}
})
</script>
<style scoped>
.comfy-menu-hamburger {
pointer-events: auto;
position: fixed;
top: 0px;
right: 0px;
z-index: 9999;
}
</style>

View File

@@ -83,6 +83,7 @@ const setInitialPosition = () => {
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
x.value = storedPosition.value.x
y.value = storedPosition.value.y
captureLastDragState()
return
}
if (panelRef.value) {
@@ -97,6 +98,7 @@ const setInitialPosition = () => {
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
captureLastDragState()
}
}
onMounted(setInitialPosition)
@@ -106,6 +108,31 @@ watch(visible, (newVisible) => {
}
})
const lastDragState = ref({
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
})
const captureLastDragState = () => {
lastDragState.value = {
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
}
}
watch(
isDragging,
(newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
captureLastDragState()
}
},
{ immediate: true }
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const screenWidth = window.innerWidth
@@ -113,10 +140,34 @@ const adjustMenuPosition = () => {
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Adjust x position if menu is off-screen horizontally
x.value = clamp(x.value, 0, screenWidth - menuWidth)
// Calculate the distance from each edge
const distanceRight =
lastDragState.value.windowWidth - (lastDragState.value.x + menuWidth)
const distanceBottom =
lastDragState.value.windowHeight - (lastDragState.value.y + menuHeight)
// Adjust y position if menu is off-screen vertically
// Determine if the menu is closer to right/bottom or left/top
const anchorRight = distanceRight < lastDragState.value.x
const anchorBottom = distanceBottom < lastDragState.value.y
// Calculate new position
if (anchorRight) {
x.value =
screenWidth - (lastDragState.value.windowWidth - lastDragState.value.x)
} else {
x.value = lastDragState.value.x
}
if (anchorBottom) {
y.value =
screenHeight -
(lastDragState.value.windowHeight - lastDragState.value.y)
} else {
y.value = lastDragState.value.y
}
// Ensure the menu stays within the screen bounds
x.value = clamp(x.value, 0, screenWidth - menuWidth)
y.value = clamp(y.value, 0, screenHeight - menuHeight)
}
}

View File

@@ -65,7 +65,7 @@ import type { MenuItem } from 'primevue/menuitem'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())

View File

@@ -0,0 +1,51 @@
<template>
<div class="flex flex-col h-full">
<Tabs v-model:value="bottomPanelStore.activeBottomPanelTabId">
<TabList pt:tabList="border-none">
<div class="w-full flex justify-between">
<div class="tabs-container">
<Tab
v-for="tab in bottomPanelStore.bottomPanelTabs"
:key="tab.id"
:value="tab.id"
class="p-3 border-none"
>
<span class="font-bold">
{{ tab.title.toUpperCase() }}
</span>
</Tab>
</div>
<Button
class="justify-self-end"
icon="pi pi-times"
severity="secondary"
size="small"
text
@click="bottomPanelStore.bottomPanelVisible = false"
/>
</div>
</TabList>
</Tabs>
<!-- h-0 to force the div to flex-grow -->
<div class="flex-grow h-0">
<ExtensionSlot
v-if="
bottomPanelStore.bottomPanelVisible &&
bottomPanelStore.activeBottomPanelTab
"
:extension="bottomPanelStore.activeBottomPanelTab"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import Tab from 'primevue/tab'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div class="p-terminal rounded-none h-full w-full">
<ScrollPanel class="h-full w-full" ref="scrollPanelRef">
<pre class="px-4 whitespace-pre-wrap">{{ log }}</pre>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { api } from '@/scripts/api'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const log = ref<string>('')
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
/**
* Whether the user has scrolled to the bottom of the terminal.
* This is used to prevent the terminal from scrolling to the bottom
* when new logs are fetched.
*/
const scrolledToBottom = ref(false)
let intervalId: number = 0
onMounted(async () => {
const element = scrollPanelRef.value?.$el
const scrollContainer = element?.querySelector('.p-scrollpanel-content')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
scrolledToBottom.value =
scrollContainer.scrollTop + scrollContainer.clientHeight ===
scrollContainer.scrollHeight
})
}
const scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
watch(log, () => {
if (scrolledToBottom.value) {
scrollToBottom()
}
})
const fetchLogs = async () => {
log.value = await api.getLogs()
}
await fetchLogs()
scrollToBottom()
intervalId = window.setInterval(fetchLogs, 500)
})
onBeforeUnmount(() => {
window.clearInterval(intervalId)
})
</script>

View File

@@ -9,7 +9,7 @@
<script setup lang="ts">
import type { DeviceStats } from '@/types/apiTypes'
import { formatMemory } from '@/utils/formatUtil'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
device: DeviceStats
@@ -30,7 +30,7 @@ const formatValue = (value: any, field: string) => {
field
)
) {
return formatMemory(value)
return formatSize(value)
}
return value
}

View File

@@ -0,0 +1,34 @@
<template>
<component v-if="extension.type === 'vue'" :is="extension.component" />
<div
v-else
:ref="
(el) => {
if (el)
mountCustomExtension(
props.extension as CustomExtension,
el as HTMLElement
)
}
"
></div>
</template>
<script setup lang="ts">
import { CustomExtension, VueExtension } from '@/types/extensionTypes'
import { onBeforeUnmount } from 'vue'
const props = defineProps<{
extension: VueExtension | CustomExtension
}>()
const mountCustomExtension = (extension: CustomExtension, el: HTMLElement) => {
extension.render(el)
}
onBeforeUnmount(() => {
if (props.extension.type === 'custom' && props.extension.destroy) {
props.extension.destroy()
}
})
</script>

View File

@@ -0,0 +1,44 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
</div>
</div>
<div class="file-action">
<Button
class="file-action-button"
:label="$t('download') + ' (' + fileSize + ')'"
size="small"
outlined
:disabled="props.error"
@click="download.triggerBrowserDownload"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useDownload } from '@/hooks/downloadHooks'
import Button from 'primevue/button'
import { computed } from 'vue'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="no-results-placeholder">
<div class="no-results-placeholder p-8 h-full" :class="props.class">
<Card>
<template #content>
<div class="flex flex-col items-center">
@@ -22,7 +22,8 @@
import Card from 'primevue/card'
import Button from 'primevue/button'
defineProps<{
const props = defineProps<{
class?: string
icon?: string
title: string
message: string
@@ -33,11 +34,6 @@ defineEmits(['action'])
</script>
<style scoped>
.no-results-placeholder {
height: 100%;
padding: 2rem;
}
.no-results-placeholder :deep(.p-card) {
background-color: var(--surface-ground);
text-align: center;

View File

@@ -35,7 +35,7 @@ import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import type { SystemStats } from '@/types/apiTypes'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import { formatMemory } from '@/utils/formatUtil'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
stats: SystemStats
@@ -58,7 +58,7 @@ const systemColumns = [
const formatValue = (value: any, field: string) => {
if (['ram_total', 'ram_free'].includes(field)) {
return formatMemory(value)
return formatSize(value)
}
return value
}

View File

@@ -21,8 +21,8 @@
<slot name="after-label" :node="props.node"></slot>
</span>
<Badge
v-if="!props.node.leaf"
:value="props.node.badgeText ?? props.node.totalLeaves"
v-if="showNodeBadgeText"
:value="nodeBadgeText"
severity="secondary"
class="leaf-count-badge"
/>
@@ -34,12 +34,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, inject, Ref, computed } from 'vue'
import { ref, inject, Ref, computed } from 'vue'
import Badge from 'primevue/badge'
import {
dropTargetForElements,
draggable
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type {
TreeExplorerDragAndDropData,
RenderedTreeExplorerNode,
@@ -47,6 +43,7 @@ import type {
} from '@/types/treeExplorerTypes'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/hooks/errorHooks'
import { usePragmaticDraggable, usePragmaticDroppable } from '@/hooks/dndHooks'
const props = defineProps<{
node: RenderedTreeExplorerNode
@@ -62,6 +59,17 @@ const emit = defineEmits<{
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
}>()
const nodeBadgeText = computed<string>(() => {
if (props.node.leaf) {
return ''
}
if (props.node.badgeText !== undefined && props.node.badgeText !== null) {
return props.node.badgeText
}
return props.node.totalLeaves.toString()
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const labelEditable = computed<boolean>(() => !!props.node.handleRename)
const renameEditingNode =
inject<Ref<TreeExplorerNode | null>>('renameEditingNode')
@@ -78,54 +86,44 @@ const handleRename = errorHandling.wrapWithErrorHandlingAsync(
)
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)
const treeNodeElement = ref<HTMLElement | null>(null)
let dropTargetCleanup = () => {}
let draggableCleanup = () => {}
onMounted(() => {
treeNodeElement.value = container.value?.closest(
'.p-tree-node-content'
) as HTMLElement
if (props.node.droppable) {
dropTargetCleanup = dropTargetForElements({
element: treeNodeElement.value,
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = true
}
},
onDragLeave: () => {
canDrop.value = false
}
})
}
if (props.node.draggable) {
draggableCleanup = draggable({
element: treeNodeElement.value,
getInitialData() {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
}
})
onUnmounted(() => {
dropTargetCleanup()
draggableCleanup()
})
const treeNodeElementGetter = () =>
container.value?.closest('.p-tree-node-content') as HTMLElement
if (props.node.draggable) {
usePragmaticDraggable(treeNodeElementGetter, {
getInitialData: () => {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
}
if (props.node.droppable) {
usePragmaticDroppable(treeNodeElementGetter, {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = true
}
},
onDragLeave: () => {
canDrop.value = false
}
})
}
</script>
<style scoped>

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeAll } from 'vitest'
import EditableText from '../EditableText.vue'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -46,10 +46,14 @@ const onUnmaximize = () => {
maximized.value = false
}
const contentProps = computed(() => ({
...dialogStore.props,
maximized: maximized.value
}))
const contentProps = computed(() =>
maximizable.value
? {
...dialogStore.props,
maximized: maximized.value
}
: dialogStore.props
)
const headerId = `dialog-${Math.random().toString(36).substr(2, 9)}`
</script>

View File

@@ -93,6 +93,7 @@ const generateReport = (systemStats: SystemStats, logs: string) => {
reportContent.value = `
# ComfyUI Error Report
## Error Details
- **Node ID:** ${props.error.node_id}
- **Node Type:** ${props.error.node_type}
- **Exception Type:** ${props.error.exception_type}
- **Exception Message:** ${props.error.exception_message}

View File

@@ -1,57 +1,46 @@
<template>
<div class="comfy-missing-nodes">
<h4 class="warning-title">Warning: Missing Node Types</h4>
<p class="warning-description">
When loading the graph, the following node types were not found:
</p>
<ListBox
:options="uniqueNodes"
optionLabel="label"
scrollHeight="100%"
:class="'missing-nodes-list' + (props.maximized ? ' maximized' : '')"
:pt="{
list: { class: 'border-none' }
}"
>
<template #option="slotProps">
<div class="missing-node-item">
<span class="node-type">{{ slotProps.option.label }}</span>
<span v-if="slotProps.option.hint" class="node-hint">{{
slotProps.option.hint
}}</span>
<Button
v-if="slotProps.option.action"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
class="p-button-sm p-button-outlined"
/>
</div>
</template>
</ListBox>
<p v-if="hasAddedNodes" class="added-nodes-warning">
Nodes that have failed to load will show as red on the graph.
</p>
</div>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Missing Node Types"
message="When loading the graph, the following node types were not found"
/>
<ListBox
:options="uniqueNodes"
optionLabel="label"
scrollHeight="100%"
class="comfy-missing-nodes"
:pt="{
list: { class: 'border-none' }
}"
>
<template #option="slotProps">
<div class="flex align-items-center">
<span class="node-type">{{ slotProps.option.label }}</span>
<span v-if="slotProps.option.hint" class="node-hint">{{
slotProps.option.hint
}}</span>
<Button
v-if="slotProps.option.action"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
size="small"
outlined
/>
</div>
</template>
</ListBox>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ListBox from 'primevue/listbox'
import Button from 'primevue/button'
interface NodeType {
type: string
hint?: string
action?: {
text: string
callback: () => void
}
}
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import type { MissingNodeType } from '@/types/comfy'
const props = defineProps<{
missingNodeTypes: (string | NodeType)[]
hasAddedNodes: boolean
maximized: boolean
missingNodeTypes: MissingNodeType[]
}>()
const uniqueNodes = computed(() => {
@@ -76,51 +65,12 @@ const uniqueNodes = computed(() => {
})
</script>
<style>
:root {
--red-600: #dc3545;
}
</style>
<style scoped>
.comfy-missing-nodes {
font-family: monospace;
color: var(--red-600);
padding: 1.5rem;
background-color: var(--surface-ground);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.warning-title {
margin-top: 0;
margin-bottom: 1rem;
}
.warning-description {
margin-bottom: 1rem;
}
.missing-nodes-list {
max-height: 300px;
overflow-y: auto;
}
.missing-nodes-list.maximized {
max-height: unset;
}
.missing-node-item {
display: flex;
align-items: center;
padding: 0.5rem;
}
.node-type {
font-weight: 600;
color: var(--text-color);
}
.node-hint {
margin-left: 0.5rem;
font-style: italic;
@@ -130,9 +80,4 @@ const uniqueNodes = computed(() => {
:deep(.p-button) {
margin-left: auto;
}
.added-nodes-warning {
margin-top: 1rem;
font-style: italic;
}
</style>

View File

@@ -1,96 +1,26 @@
<template>
<div class="comfy-missing-models">
<h4 class="warning-title">Warning: Missing Models</h4>
<p class="warning-description">
When loading the graph, the following models were not found:
</p>
<p class="warning-options">
<Checkbox
class="model-path-select-checkbox"
v-model="showFolderSelect"
label="Show folder selector"
:binary="true"
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Missing Models"
message="When loading the graph, the following models were not found"
/>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<FileDownload
:url="option.url"
:label="option.label"
:error="option.error"
/>
Show folder selector
</p>
<ListBox
:options="missingModels"
optionLabel="label"
scrollHeight="100%"
:class="'missing-models-list' + (props.maximized ? ' maximized' : '')"
:pt="{
list: { class: 'border-none' }
}"
>
<template #option="slotProps">
<div
class="missing-model-item"
:style="{ '--progress': `${slotProps.option.progress}%` }"
>
<div class="model-info">
<div class="model-details">
<span class="model-type" :title="slotProps.option.hint">{{
slotProps.option.label
}}</span>
</div>
<div v-if="slotProps.option.error" class="model-error">
{{ slotProps.option.error }}
</div>
</div>
<div class="model-action">
<Select
class="model-path-select"
v-if="
slotProps.option.action &&
!slotProps.option.downloading &&
!slotProps.option.completed &&
!slotProps.option.error &&
showFolderSelect
"
v-model="slotProps.option.folderPath"
:options="slotProps.option.paths"
@change="updateFolderPath(slotProps.option, $event)"
/>
<Button
v-if="
slotProps.option.action &&
!slotProps.option.downloading &&
!slotProps.option.completed &&
!slotProps.option.error
"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
class="p-button-sm p-button-outlined model-action-button"
/>
<div v-if="slotProps.option.downloading" class="download-progress">
<span class="progress-text"
>{{ slotProps.option.progress.toFixed(2) }}%</span
>
</div>
<div v-if="slotProps.option.completed" class="download-complete">
<i class="pi pi-check" style="color: var(--green-500)"></i>
</div>
<div v-if="slotProps.option.error" class="download-error">
<i class="pi pi-times" style="color: var(--red-600)"></i>
</div>
</div>
</div>
</template>
</ListBox>
</div>
</template>
</ListBox>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import Select from 'primevue/select'
import { SelectChangeEvent } from 'primevue/select'
import Button from 'primevue/button'
import { api } from '@/scripts/api'
import { DownloadModelStatus } from '@/types/apiTypes'
const showFolderSelect = ref(false)
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FileDownload from '@/components/common/FileDownload.vue'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
@@ -116,90 +46,16 @@ interface ModelInfo {
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
maximized: boolean
}>()
const modelDownloads = ref<Record<string, ModelInfo>>({})
let lastModel: string | null = null
const updateFolderPath = (model: any, event: SelectChangeEvent) => {
const downloadInfo = modelDownloads.value[model.name]
downloadInfo.folder_path = event.value
return false
}
const handleDownloadProgress = (detail: DownloadModelStatus) => {
if (detail.download_path) {
lastModel = detail.download_path
}
if (!lastModel) return
if (detail.status === 'in_progress') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: true,
progress: detail.progress_percentage,
completed: false
}
} else if (detail.status === 'pending') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: true,
progress: 0,
completed: false
}
} else if (detail.status === 'completed') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: false,
progress: 100,
completed: true
}
} else if (detail.status === 'error') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: false,
progress: 0,
error: detail.message,
completed: false
}
}
// TODO: other statuses?
}
const triggerDownload = async (
url: string,
directory: string,
filename: string,
folder_path: string
) => {
modelDownloads.value[filename] = {
name: filename,
directory,
url,
downloading: true,
progress: 0
}
const download = await api.internalDownloadModel(
url,
directory,
filename,
1,
folder_path
)
lastModel = filename
handleDownloadProgress(download)
}
api.addEventListener('download_progress', (event: CustomEvent) => {
handleDownloadProgress(event.detail)
})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (model.directory_invalid || !paths) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
url: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
@@ -217,168 +73,35 @@ const missingModels = computed(() => {
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
url: model.url,
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
url: model.url,
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
}
}
return {
url: model.url,
label: `${model.directory} / ${model.name}`,
hint: model.url,
downloading: downloadInfo.downloading,
completed: downloadInfo.completed,
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
folderPath: downloadInfo.folder_path,
action: {
text: 'Download',
callback: () =>
triggerDownload(
model.url,
model.directory,
model.name,
downloadInfo.folder_path
)
}
folderPath: downloadInfo.folder_path
}
})
})
</script>
<style>
:root {
--red-600: #dc3545;
--green-500: #28a745;
}
</style>
<style scoped>
.comfy-missing-models {
font-family: monospace;
color: var(--red-600);
padding: 1.5rem;
background-color: var(--surface-ground);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.warning-title {
margin-top: 0;
margin-bottom: 1rem;
}
.warning-description {
margin-bottom: 1rem;
}
.warning-options {
color: var(--fg-color);
}
.missing-models-list {
max-height: 300px;
overflow-y: auto;
}
.missing-models-list.maximized {
max-height: unset;
}
.missing-model-item {
display: flex;
align-items: flex-start;
padding: 0.5rem;
position: relative;
overflow: hidden;
width: 100%;
}
.missing-model-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: var(--progress);
background-color: var(--green-500);
opacity: 0.2;
transition: width 0.3s ease;
}
.model-info {
flex: 1;
min-width: 0;
z-index: 1;
display: flex;
flex-direction: column;
margin-right: 1rem;
overflow: hidden;
}
.model-details {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.model-type {
font-weight: 600;
color: var(--text-color);
margin-right: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-hint {
font-style: italic;
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-error {
color: var(--red-600);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.model-action {
display: flex;
align-items: center;
justify-content: flex-end;
z-index: 1;
}
.model-action-button {
min-width: 80px;
}
.download-progress,
.download-complete,
.download-error {
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
}
.progress-text {
font-size: 0.8rem;
color: var(--text-color);
}
.download-complete i,
.download-error i {
font-size: 1.2rem;
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="settings-container">
<div class="settings-sidebar">
<ScrollPanel class="settings-sidebar flex-shrink-0 p-2 w-64">
<SearchBox
class="settings-search-box"
class="settings-search-box w-full mb-2"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchSettings') + '...'"
@@ -13,11 +13,11 @@
optionLabel="label"
scrollHeight="100%"
:disabled="inSearch"
:pt="{ root: { class: 'border-none' } }"
class="border-none w-full"
/>
</div>
</ScrollPanel>
<Divider layout="vertical" />
<div class="settings-content">
<ScrollPanel class="settings-content flex-grow">
<Tabs :value="tabValue">
<TabPanels class="settings-tab-panels">
<TabPanel key="search-results" value="Search Results">
@@ -55,24 +55,35 @@
<AboutPanel />
</TabPanel>
<TabPanel key="keybinding" value="Keybinding">
<KeybindingPanel />
<Suspense>
<KeybindingPanel />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
</TabPanel>
<TabPanel key="extension" value="Extension">
<ExtensionPanel />
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</template>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, defineAsyncComponent } from 'vue'
import Listbox from 'primevue/listbox'
import Tabs from 'primevue/tabs'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import SettingGroup from './setting/SettingGroup.vue'
@@ -80,8 +91,13 @@ import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
import KeybindingPanel from './setting/KeybindingPanel.vue'
import ExtensionPanel from './setting/ExtensionPanel.vue'
const KeybindingPanel = defineAsyncComponent(
() => import('./setting/KeybindingPanel.vue')
)
const ExtensionPanel = defineAsyncComponent(
() => import('./setting/ExtensionPanel.vue')
)
interface ISettingGroup {
label: string
@@ -193,44 +209,8 @@ const tabValue = computed(() =>
display: flex;
height: 70vh;
width: 60vw;
max-width: 1000px;
max-width: 1024px;
overflow: hidden;
/* Prevents container from scrolling */
}
.settings-sidebar {
width: 250px;
flex-shrink: 0;
/* Prevents sidebar from shrinking */
overflow-y: auto;
padding: 10px;
}
.settings-search-box {
width: 100%;
margin-bottom: 10px;
}
.settings-content {
flex-grow: 1;
overflow-y: auto;
/* Allows vertical scrolling */
}
/* Ensure the Listbox takes full width of the sidebar */
.settings-sidebar :deep(.p-listbox) {
width: 100%;
}
/* Optional: Style scrollbars for webkit browsers */
.settings-sidebar::-webkit-scrollbar,
.settings-content::-webkit-scrollbar {
width: 1px;
}
.settings-sidebar::-webkit-scrollbar-thumb,
.settings-content::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (max-width: 768px) {

View File

@@ -4,6 +4,9 @@
<template #side-bar-panel>
<SideToolbar />
</template>
<template #bottom-panel>
<BottomPanel />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu v-if="canvasMenuEnabled" />
</template>
@@ -14,24 +17,22 @@
</teleport>
<NodeSearchboxPopover />
<NodeTooltip v-if="tooltipEnabled" />
<NodeBadge />
</template>
<script setup lang="ts">
import TitleEditor from '@/components/graph/TitleEditor.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import { ref, computed, onUnmounted, onMounted, watchEffect } from 'vue'
import NodeBadge from '@/components/graph/NodeBadge.vue'
import { ref, computed, onMounted, watchEffect } 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 {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
LiteGraph,
LGraph,
@@ -44,7 +45,6 @@ import {
LGraphBadge
} from '@comfyorg/litegraph'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyModelDef } from '@/stores/modelStore'
import {
@@ -52,7 +52,7 @@ import {
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -62,7 +62,9 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const modelToNodeStore = useModelToNodeStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
() =>
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
!workspaceStore.focusMode
)
const canvasMenuEnabled = computed(() =>
settingStore.get('Comfy.Graph.CanvasMenu')
@@ -105,15 +107,23 @@ watchEffect(() => {
})
})
watchEffect(() => {
const linkRenderMode = settingStore.get('Comfy.LinkRenderMode')
if (canvasStore.canvas) {
canvasStore.canvas.links_render_mode = linkRenderMode
canvasStore.canvas.setDirty(/* fg */ false, /* bg */ true)
}
})
watchEffect(() => {
if (!canvasStore.canvas) return
if (canvasStore.canvas.dragging_canvas) {
if (canvasStore.canvas.state.draggingCanvas) {
canvasStore.canvas.canvas.style.cursor = 'grabbing'
return
}
if (canvasStore.canvas.read_only) {
if (canvasStore.canvas.state.readOnly) {
canvasStore.canvas.canvas.style.cursor = 'grab'
return
}
@@ -121,7 +131,60 @@ watchEffect(() => {
canvasStore.canvas.canvas.style.cursor = 'default'
})
let dropTargetCleanup = () => {}
usePragmaticDroppable(() => canvasRef.value, {
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// 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
])
comfyApp.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = comfyApp.addNodeOnGraph(provider.nodeDef, {
pos
})
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets.find(
(widget) => widget.name === targetProvider.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
onMounted(async () => {
// Backward compatible
@@ -146,78 +209,6 @@ onMounted(async () => {
window['app'] = comfyApp
window['graph'] = comfyApp.graph
dropTargetCleanup = dropTargetForElements({
element: canvasRef.value,
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// 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
])
comfyApp.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = comfyApp.addNodeOnGraph(provider.nodeDef, {
pos
})
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets.find(
(widget) => widget.name === targetProvider.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
// Load keybindings. This must be done after comfyApp loads settings.
useKeybindingStore().loadUserKeybindings()
// Migrate legacy bookmarks
useNodeBookmarkStore().migrateLegacyBookmarks()
// Explicitly initialize nodeSearchService to avoid indexing delay when
// node search is triggered
useNodeDefStore().nodeSearchService.endsWithFilterStartSequence('')
// Non-blocking load of node frequencies
useNodeFrequencyStore().loadNodeFrequencies()
emit('ready')
})
onUnmounted(() => {
dropTargetCleanup()
})
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div>
<!-- This component does not render anything visible. -->
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import {
defaultColorPalette,
getColorPalette
} from '@/extensions/core/colorPalette'
import { app } from '@/scripts/app'
import type { LGraphNode } from '@comfyorg/litegraph'
import { BadgePosition } from '@comfyorg/litegraph'
import { LGraphBadge } from '@comfyorg/litegraph'
import _ from 'lodash'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
const settingStore = useSettingStore()
const nodeSourceBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
)
const nodeIdBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
)
const nodeLifeCycleBadgeMode = computed(
() =>
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') as NodeBadgeMode
)
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
app.graph?.setDirtyCanvas(true, true)
})
const colorPalette = computed(() =>
getColorPalette(settingStore.get('Comfy.ColorPalette'))
)
const nodeDefStore = useNodeDefStore()
function badgeTextVisible(
nodeDef: ComfyNodeDefImpl | null,
badgeMode: NodeBadgeMode
): boolean {
return !(
badgeMode === NodeBadgeMode.None ||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
onMounted(() => {
app.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
node.badgePosition = BadgePosition.TopRight
const badge = computed(() => {
const nodeDef = nodeDefStore.fromLGraphNode(node)
return new LGraphBadge({
text: _.truncate(
[
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
? `#${node.id}`
: '',
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
? nodeDef?.nodeLifeCycleBadgeText ?? ''
: '',
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
? nodeDef?.nodeSource?.badgeText ?? ''
: ''
]
.filter((s) => s.length > 0)
.join(' '),
{
length: 31
}
),
fgColor:
colorPalette.value.colors.litegraph_base?.BADGE_FG_COLOR ||
defaultColorPalette.colors.litegraph_base.BADGE_FG_COLOR,
bgColor:
colorPalette.value.colors.litegraph_base?.BADGE_BG_COLOR ||
defaultColorPalette.colors.litegraph_base.BADGE_BG_COLOR
})
})
node.badges.push(() => badge.value)
}
})
})
</script>

View File

@@ -106,7 +106,6 @@ const canvasEventHandler = (event: LiteGraphCanvasEvent) => {
const [x, y] = group.pos
const e = event.detail.originalEvent
// @ts-expect-error LiteGraphCanvasEvent is not typed
const relativeY = e.canvasY - y
// Only allow editing if the click is on the title bar
if (relativeY > group.titleHeight) {

View File

@@ -17,20 +17,11 @@
</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
v-if="selectedTab"
class="sidebar-content-container h-full overflow-y-auto overflow-x-hidden"
>
<ExtensionSlot :extension="selectedTab" />
</div>
</template>
@@ -38,13 +29,11 @@
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 ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { computed } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useSettingStore } from '@/stores/settingStore'
import {
CustomSidebarTabExtension,
SidebarTabExtension
} from '@/types/extensionTypes'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { useKeybindingStore } from '@/stores/keybindingStore'
const workspaceStore = useWorkspaceStore()
@@ -62,20 +51,9 @@ const isSmall = computed(
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const mountCustomTab = (tab: CustomSidebarTabExtension, el: HTMLElement) => {
tab.render(el)
}
const onTabClick = (item: SidebarTabExtension) => {
workspaceStore.sidebarTab.toggleSidebarTab(item.id)
}
onBeforeUnmount(() => {
tabs.value.forEach((tab) => {
if (tab.type === 'custom' && tab.destroy) {
tab.destroy()
}
})
})
const keybindingStore = useKeybindingStore()
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const keybinding = keybindingStore.getKeybindingByCommandId(
@@ -115,9 +93,4 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
align-self: flex-end;
margin-top: auto;
}
.sidebar-content-container {
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -1,40 +1,59 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.modelLibrary')">
<template #tool-buttons> </template>
<SidebarTabTemplate
:title="$t('sideToolbar.modelLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
icon="pi pi-refresh"
@click="modelStore.loadModelFolders"
severity="secondary"
text
v-tooltip="$t('refresh')"
/>
<Button
icon="pi pi-cloud-download"
@click="modelStore.loadModels"
severity="secondary"
text
v-tooltip="$t('loadAllFolders')"
/>
</template>
<template #header>
<SearchBox
class="model-lib-search-box p-4"
v-model:modelValue="searchQuery"
:placeholder="$t('searchModels') + '...'"
@search="handleSearch"
/>
</template>
<template #body>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="model-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
:placeholder="$t('searchModels') + '...'"
/>
</div>
<div class="flex-grow overflow-y-auto">
<TreeExplorer
class="model-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
<TreeExplorer
class="model-lib-tree-explorer py-0"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
</template>
</TreeExplorer>
</template>
</SidebarTabTemplate>
<div id="model-library-model-preview-container" />
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import SearchBox from '@/components/common/SearchBox.vue'
import { useI18n } from 'vue-i18n'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import { ComfyModelDef, useModelStore } from '@/stores/modelStore'
import {
ComfyModelDef,
ModelFolder,
ResourceState,
useModelStore
} from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useSettingStore } from '@/stores/settingStore'
import { useTreeExpansion } from '@/hooks/treeHooks'
@@ -42,90 +61,56 @@ import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { computed, ref, type ComputedRef, watch, toRef } from 'vue'
import { computed, ref, watch, toRef, onMounted, nextTick } from 'vue'
import type { TreeNode } from 'primevue/treenode'
import { app } from '@/scripts/app'
import { buildTree } from '@/utils/treeUtil'
const { t } = useI18n()
const modelStore = useModelStore()
const modelToNodeStore = useModelToNodeStore()
const settingStore = useSettingStore()
const searchQuery = ref<string>('')
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const root: ComputedRef<TreeNode> = computed(() => {
let modelList: ComfyModelDef[] = []
if (!modelStore.modelFolders.length) {
modelStore.getModelFolders()
const filteredModels = ref<ComfyModelDef[]>([])
const handleSearch = async (query: string) => {
if (!query) {
filteredModels.value = []
expandedKeys.value = {}
return
}
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
for (let folder of modelStore.modelFolders) {
modelStore.getModelsInFolderCached(folder)
}
}
for (let folder of modelStore.modelFolders) {
const models = modelStore.modelStoreMap[folder]
if (models) {
if (Object.values(models.models).length) {
modelList.push(...Object.values(models.models))
} else {
const fakeModel = new ComfyModelDef('(No Content)', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
} else {
const fakeModel = new ComfyModelDef('Loading', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
}
if (searchQuery.value) {
const search = searchQuery.value.toLocaleLowerCase()
modelList = modelList.filter((model: ComfyModelDef) => {
return model.searchable.includes(search)
})
}
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) => {
return [
model.directory,
...model.file_name.replaceAll('\\', '/').split('/')
]
// Load all models to ensure we have the latest data
await modelStore.loadModels()
const search = query.toLocaleLowerCase()
filteredModels.value = modelStore.models.filter((model: ComfyModelDef) => {
return model.searchable.includes(search)
})
return tree
nextTick(() => {
expandNode(root.value)
})
}
type ModelOrFolder = ComfyModelDef | ModelFolder
const root = computed<TreeNode>(() => {
const allNodes: ModelOrFolder[] = [
...modelStore.modelFolders,
...(searchQuery.value ? filteredModels.value : modelStore.models)
]
return buildTree(allNodes, (modelOrFolder: ModelOrFolder) =>
modelOrFolder.key.split('/')
)
})
const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ComfyModelDef> => {
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ModelOrFolder> => {
const children = node.children?.map(fillNodeInfo)
const model: ComfyModelDef | null =
node.leaf && node.data ? node.data : null
if (model?.is_fake_object) {
if (model.file_name === '(No Content)') {
return {
key: node.key,
label: t('noContent'),
leaf: true,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
return 'pi pi-file'
},
children: []
}
} else {
return {
key: node.key,
label: t('loading') + '...',
leaf: true,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
return 'pi pi-spin pi-spinner'
},
children: []
}
}
}
const folder: ModelFolder | null =
!node.leaf && node.data ? node.data : null
return {
key: node.key,
@@ -136,34 +121,29 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
: node.label,
leaf: node.leaf,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
if (node.leaf) {
if (node.data && node.data.image) {
return 'pi pi-fake-spacer'
}
return 'pi pi-file'
getIcon: () => {
if (model) {
return model.image ? 'pi pi-image' : 'pi pi-file'
}
if (folder) {
return folder.state === ResourceState.Loading
? 'pi pi-spin pi-spinner'
: 'pi pi-folder'
}
return 'pi pi-folder'
},
getBadgeText: (node: TreeExplorerNode<ComfyModelDef>) => {
if (node.leaf) {
getBadgeText: () => {
// Return null to apply default badge text
// Return empty string to hide badge
if (!folder) {
return null
}
if (node.children?.length === 1) {
const onlyChild = node.children[0]
if (onlyChild.data?.is_fake_object) {
if (onlyChild.data.file_name === '(No Content)') {
return '0'
} else if (onlyChild.data.file_name === 'Loading') {
return '?'
}
}
}
return null
return folder.state === ResourceState.Loaded ? null : ''
},
children,
draggable: node.leaf,
handleClick: (
node: RenderedTreeExplorerNode<ComfyModelDef>,
node: RenderedTreeExplorerNode<ModelOrFolder>,
e: MouseEvent
) => {
if (node.leaf) {
@@ -179,6 +159,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
widget.value = model.file_name
}
}
} else {
toggleNodeOnEvent(e, node)
}
}
}
@@ -186,17 +168,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
return fillNodeInfo(root.value)
})
const handleNodeClick = (
node: RenderedTreeExplorerNode<ComfyModelDef>,
e: MouseEvent
) => {
if (node.leaf) {
// TODO
} else {
toggleNodeOnEvent(e, node)
}
}
watch(
toRef(expandedKeys, 'value'),
(newExpandedKeys) => {
@@ -205,24 +176,24 @@ watch(
const folderPath = key.split('/').slice(1).join('/')
if (folderPath && !folderPath.includes('/')) {
// Trigger (async) load of model data for this folder
modelStore.getModelsInFolderCached(folderPath)
modelStore.getLoadedModelFolder(folderPath)
}
}
})
},
{ deep: true }
)
onMounted(async () => {
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
await modelStore.loadModels()
}
})
</script>
<style>
.pi-fake-spacer {
<style scoped>
:deep(.pi-fake-spacer) {
height: 1px;
width: 16px;
}
</style>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -1,5 +1,8 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodeLibrary')">
<SidebarTabTemplate
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
class="new-folder-button"
@@ -18,44 +21,41 @@
v-tooltip="$t('sideToolbar.nodeLibraryTab.sortOrder')"
/>
</template>
<template #body>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="node-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<template #header>
<SearchBox
class="node-lib-search-box p-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<Popover ref="searchFilter" class="node-lib-filter-popup">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
</div>
<div class="flex-grow overflow-y-auto">
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-if="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
/>
<TreeExplorer
class="node-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
</template>
<template #body>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
class="node-lib-tree-explorer py-0"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</template>
</SidebarTabTemplate>
<div id="node-library-node-preview-container" />
@@ -193,23 +193,3 @@ const onRemoveFilter = (filterAndValue) => {
handleSearch(searchQuery.value)
}
</script>
<style>
.node-lib-filter-popup {
margin-left: -13px;
}
</style>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
:deep(.node-lib-bookmark-tree-explorer) {
padding-bottom: 2px;
}
:deep(.p-divider) {
margin: var(--comfy-tree-explorer-item-padding) 0px;
}
</style>

View File

@@ -249,7 +249,8 @@ const menuItems = computed<MenuItem[]>(() => [
{
label: t('loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app)
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
},
{
label: t('goToNode'),
@@ -327,6 +328,14 @@ watch(
overflow-y: auto;
}
.scroll-container::-webkit-scrollbar {
width: 1px;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: transparent;
}
.queue-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));

View File

@@ -1,66 +1,34 @@
<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
class="comfy-vue-side-bar-container flex flex-col h-full"
:class="props.class"
>
<div class="comfy-vue-side-bar-header">
<Toolbar
class="flex-shrink-0 border-x-0 border-t-0 rounded-none px-2 py-1 min-h-10"
>
<template #start>
<span class="text-sm">{{ props.title.toUpperCase() }}</span>
</template>
<template #end>
<slot name="tool-buttons"></slot>
</template>
</Toolbar>
<slot name="header"></slot>
</div>
<!-- h-0 to force scrollpanel to flex-grow -->
<ScrollPanel class="comfy-vue-side-bar-body flex-grow h-0">
<slot name="body"></slot>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import Toolbar from 'primevue/toolbar'
import ScrollPanel from 'primevue/scrollpanel'
const props = defineProps({
title: {
type: String,
required: true
}
})
const props = defineProps<{
title: string
class?: string
}>()
</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;
min-height: 2.5rem;
}
.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,5 +1,8 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.workflows')">
<SidebarTabTemplate
:title="$t('sideToolbar.workflows')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
class="browse-templates-button"
@@ -23,13 +26,15 @@
text
/>
</template>
<template #body>
<template #header>
<SearchBox
class="workflows-search-box mx-4 my-4"
class="workflows-search-box p-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchWorkflows') + '...'"
/>
</template>
<template #body>
<div class="comfyui-workflows-panel" v-if="!isSearching">
<div
class="comfyui-workflows-open"
@@ -214,9 +219,3 @@ const selectionKeys = computed(() => ({
[`root/${workflowStore.activeWorkflow?.name}.json`]: true
}))
</script>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -57,9 +57,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
)
const handleModelHover = async () => {
if (modelDef.value.is_fake_object) {
return
}
const hoverTarget = modelContentElement.value
const targetRect = hoverTarget.getBoundingClientRect()
@@ -87,7 +84,6 @@ const showPreview = computed(() => {
return (
isHovered.value &&
modelDef.value &&
!modelDef.value.is_fake_object &&
modelDef.value.has_loaded_metadata &&
(modelDef.value.author ||
modelDef.value.simplified_file_name != modelDef.value.title ||
@@ -99,9 +95,6 @@ const showPreview = computed(() => {
})
const handleMouseEnter = async () => {
if (modelDef.value.is_fake_object) {
return
}
isHovered.value = true
await nextTick()
handleModelHover()
@@ -113,9 +106,7 @@ onMounted(() => {
modelContentElement.value = container.value?.closest('.p-tree-node-content')
modelContentElement.value?.addEventListener('mouseenter', handleMouseEnter)
modelContentElement.value?.addEventListener('mouseleave', handleMouseLeave)
if (!modelDef.value.is_fake_object) {
modelDef.value.load()
}
modelDef.value.load()
})
onUnmounted(() => {

View File

@@ -1,6 +1,6 @@
<template>
<TreeExplorer
class="node-lib-bookmark-tree-explorer"
class="node-lib-bookmark-tree-explorer py-0"
ref="treeExplorerRef"
:roots="renderedBookmarkedRoot.children"
:expandedKeys="expandedKeys"
@@ -144,7 +144,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
return 'pi pi-circle-fill'
}
const customization =
nodeBookmarkStore.bookmarksCustomization[node.data.nodePath]
nodeBookmarkStore.bookmarksCustomization[node.data?.nodePath]
return customization?.icon
? 'pi ' + customization.icon
: 'pi pi-bookmark-fill'

View File

@@ -27,10 +27,7 @@
class="galleria-image"
v-if="item.isImage"
/>
<video v-else-if="item.isVideo" controls width="100%" height="100%">
<source :src="item.url" :type="item.format" />
{{ $t('videoFailedToLoad') }}
</video>
<ResultVideo v-else-if="item.isVideo" :result="item" />
</template>
</Galleria>
</template>
@@ -40,6 +37,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue'
import Galleria from 'primevue/galleria'
import { ResultItemImpl } from '@/stores/queueStore'
import ComfyImage from '@/components/common/ComfyImage.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)

View File

@@ -6,12 +6,7 @@
class="task-output-image"
:contain="imageFit === 'contain'"
/>
<template v-else-if="result.isVideo">
<video controls width="100%" height="100%">
<source :src="result.url" :type="result.format" />
{{ $t('videoFailedToLoad') }}
</video>
</template>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file"></i>
<span>{{ result.mediaType }}</span>
@@ -34,6 +29,7 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{
result: ResultItemImpl

View File

@@ -0,0 +1,30 @@
<template>
<video controls width="100%" height="100%">
<source :src="url" :type="htmlVideoType" />
{{ $t('videoFailedToLoad') }}
</video>
</template>
<script setup lang="ts">
import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { computed } from 'vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const settingStore = useSettingStore()
const vhsAdvancedPreviews = computed(() =>
settingStore.get('VHS.AdvancedPreviews')
)
const url = computed(() =>
vhsAdvancedPreviews.value
? props.result.vhsAdvancedPreviewUrl
: props.result.url
)
const htmlVideoType = computed(() =>
vhsAdvancedPreviews.value ? 'video/webm' : props.result.htmlVideoType
)
</script>

View File

@@ -83,11 +83,12 @@ const coverResult = flatOutputs.length
? props.task.previewOutput || flatOutputs[0]
: null
// Using `==` instead of `===` because NodeId can be a string or a number
const node: ComfyNode | null = flatOutputs.length
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
) ?? null
: null
const node: ComfyNode | null =
flatOutputs.length && props.task.workflow
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
) ?? null
: null
const progressPreviewBlobUrl = ref('')
const emit = defineEmits<{

View File

@@ -1,3 +1,6 @@
// @ts-strict-ignore
// Disabled because of https://github.com/Comfy-Org/ComfyUI_frontend/issues/1184
import { mount } from '@vue/test-utils'
import { expect, describe, it } from 'vitest'
import ResultGallery from '../ResultGallery.vue'

View File

@@ -0,0 +1,23 @@
<template>
<Button
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
severity="secondary"
text
@click="bottomPanelStore.toggleBottomPanel"
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
>
<template #icon>
<i-material-symbols:dock-to-bottom
v-if="bottomPanelStore.bottomPanelVisible"
/>
<i-material-symbols:dock-to-bottom-outline v-else />
</template>
</Button>
</template>
<script setup lang="ts">
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import Button from 'primevue/button'
const bottomPanelStore = useBottomPanelStore()
</script>

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