Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7487c565c8 | ||
|
|
a86d10b02d | ||
|
|
3d89c245e5 | ||
|
|
9dd6da3dc2 | ||
|
|
269e468425 | ||
|
|
c3ef716d53 | ||
|
|
bd7bbd9e95 | ||
|
|
447d1c95ef | ||
|
|
c4bc0e8430 | ||
|
|
f3ab9cfb8e | ||
|
|
52c8c8194e | ||
|
|
9dbc114ae9 | ||
|
|
556edea299 | ||
|
|
d5584a1d39 | ||
|
|
628f2afc34 | ||
|
|
ea01fde607 | ||
|
|
b8a3e6b1ad | ||
|
|
cfad3cd918 | ||
|
|
339e201920 | ||
|
|
c227a8af9a | ||
|
|
6b47162606 | ||
|
|
a4c5a2a3d1 | ||
|
|
45a47be7c0 | ||
|
|
2252f0a134 | ||
|
|
0dfbcfb2d6 | ||
|
|
b46036f25d | ||
|
|
f5ce42d5d5 | ||
|
|
ce75a29202 | ||
|
|
6a8a68a240 | ||
|
|
f9adaadc7d | ||
|
|
727992048e | ||
|
|
dd1e3f087d | ||
|
|
f1c1a3dab7 | ||
|
|
4a43dfe6b9 | ||
|
|
8576d3797b | ||
|
|
22e2628479 | ||
|
|
4e1f14139b | ||
|
|
0c53ab9177 | ||
|
|
eb5f4d9bc7 | ||
|
|
19681fd43d | ||
|
|
add2f9baa0 | ||
|
|
ec5f1152da | ||
|
|
17aa44d9f6 | ||
|
|
d8887a434d | ||
|
|
9d3ca763d0 | ||
|
|
a45851d7a6 | ||
|
|
266336104a | ||
|
|
8c40f83b35 | ||
|
|
5ba524fd94 | ||
|
|
966b1dd057 | ||
|
|
069766337a | ||
|
|
a1a6eeed0f | ||
|
|
b33874db2b | ||
|
|
05c6193a1d | ||
|
|
73f7889f81 | ||
|
|
c73915e31e | ||
|
|
7626d08c98 | ||
|
|
f10554d914 | ||
|
|
238225dd12 | ||
|
|
9cea54686a | ||
|
|
d1715972e3 | ||
|
|
49a910d7b0 | ||
|
|
479ca63e3c | ||
|
|
7468555c06 | ||
|
|
14a395a0ce | ||
|
|
0a99a2bd53 | ||
|
|
955c703fde | ||
|
|
ef8b952d79 | ||
|
|
e6d29656fa | ||
|
|
9e3dffd7fd | ||
|
|
429e44f74d | ||
|
|
73b0ecc8cb | ||
|
|
775f536d30 | ||
|
|
1ff7a70579 | ||
|
|
51233b4be3 | ||
|
|
5c4d1c2cec | ||
|
|
711205b499 | ||
|
|
00fe3515b9 | ||
|
|
d8ee0b4584 | ||
|
|
fc1b0b3e53 | ||
|
|
a68f7c680b | ||
|
|
c6b6bdcb67 | ||
|
|
6993a56c2d | ||
|
|
4eb56a19ba | ||
|
|
784947fdea | ||
|
|
64ee131e64 | ||
|
|
1b59e3ab6d | ||
|
|
37b414d1b2 | ||
|
|
7245eee85b | ||
|
|
9f3696e70f | ||
|
|
d5deb6d2a0 | ||
|
|
41889c3cfe | ||
|
|
e8602e88fa | ||
|
|
b5f2d334d9 | ||
|
|
c459698956 | ||
|
|
f91e335ca7 | ||
|
|
1fc173b48f | ||
|
|
4fe44339fd | ||
|
|
f987f4f5f3 | ||
|
|
91e21b1387 | ||
|
|
a5be1f6072 | ||
|
|
d5f30be06d | ||
|
|
d607f6c7f7 | ||
|
|
d9df0328c5 | ||
|
|
aff059b98b | ||
|
|
cf53e8df6a | ||
|
|
d1d4324e57 | ||
|
|
c86abd3dc6 | ||
|
|
281ed0c5d1 | ||
|
|
edf0396619 | ||
|
|
2a5b2e8d12 | ||
|
|
9385014799 | ||
|
|
cfce1c6037 | ||
|
|
480679aa32 | ||
|
|
e2141a81e2 | ||
|
|
0f3b58b610 | ||
|
|
7ef8e56c25 | ||
|
|
9ffdf768b1 | ||
|
|
23fcdd3e44 | ||
|
|
7ce7490bc3 | ||
|
|
3e7b0a4907 | ||
|
|
a095e7ecae | ||
|
|
fe0d63e16c | ||
|
|
3c76554bec | ||
|
|
ce2a2dd2b6 | ||
|
|
d6c304690c | ||
|
|
e1bc2708d3 | ||
|
|
95dc6ff5de | ||
|
|
4b83ee3918 | ||
|
|
078df413b5 | ||
|
|
3fc85f1fb6 | ||
|
|
ce14c1c071 | ||
|
|
0d31dc5b4c | ||
|
|
42c1e4b5e1 | ||
|
|
5490ccf4f0 | ||
|
|
a5f0d2b201 | ||
|
|
02d7f91e9e | ||
|
|
eb1c66c90a | ||
|
|
6b1776450b | ||
|
|
7804b25d5f | ||
|
|
3249bbf4ab | ||
|
|
baa1e54fc0 | ||
|
|
968a1da227 | ||
|
|
a013d83fc0 | ||
|
|
564ec887f2 | ||
|
|
79469bd2b1 | ||
|
|
6fe2297cc1 | ||
|
|
e162d0007c | ||
|
|
5419865740 | ||
|
|
b90b1194d6 | ||
|
|
1eb45ddc55 | ||
|
|
2f1df2c6ce | ||
|
|
3269b54aae | ||
|
|
50c4c87e62 | ||
|
|
1b96c16cca | ||
|
|
31ca016055 | ||
|
|
cf9d95aa97 | ||
|
|
7a980f46c9 | ||
|
|
c48f68e53e | ||
|
|
0210c7f438 | ||
|
|
0384cf96c4 | ||
|
|
4bebcb408e | ||
|
|
b5a919e8b2 | ||
|
|
7e669b0f68 | ||
|
|
244cd3c920 | ||
|
|
0cf5e647af | ||
|
|
f0f867481d | ||
|
|
d02b074fa3 | ||
|
|
e14d84526a | ||
|
|
2aa9166079 | ||
|
|
3baa07e0a9 | ||
|
|
c494cd211e | ||
|
|
c00e2fd208 | ||
|
|
d77343da83 | ||
|
|
c611c15d40 | ||
|
|
269686eebb | ||
|
|
0e3590d017 | ||
|
|
7d2d6df57b | ||
|
|
4462dabc63 | ||
|
|
53bfc0c95a | ||
|
|
b78682689e | ||
|
|
6d1dce8255 | ||
|
|
73f4e5143d | ||
|
|
7d75cc99ba | ||
|
|
0aa7d0b99a | ||
|
|
66b690e5c8 | ||
|
|
6e27b884fc | ||
|
|
561162fb3e | ||
|
|
7c6bd7ed71 | ||
|
|
fc5bdf80b3 | ||
|
|
033f242e43 | ||
|
|
304429b967 | ||
|
|
6dbdb9baa6 | ||
|
|
3e3e909e36 |
@@ -13,4 +13,7 @@ DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
|
||||
DEPLOY_COMFYUI_DIR=/home/ComfyUI/web
|
||||
|
||||
# The directory containing the ComfyUI_examples repo used to extract test workflows.
|
||||
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples
|
||||
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples
|
||||
|
||||
# Whether to enable minification of the frontend code.
|
||||
ENABLE_MINIFY=true
|
||||
72
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Bug Report
|
||||
description: "Something is not behaving as expected."
|
||||
title: "[Bug]: "
|
||||
labels: ['Potential Bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting a **Bug Report**, please ensure the following:
|
||||
|
||||
- **1:** You are running the latest version of ComfyUI.
|
||||
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
|
||||
- **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
|
||||
`--disable-all-custom-nodes` command line argument.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Frontend Version
|
||||
description: 'What is the frontend version you are using? You can check this in the settings dialog'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: 'What you expected to happen.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: 'What actually happened. Please include a screenshot / video clip of the issue if possible.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Debug Logs
|
||||
description: 'Please copy the output from your terminal logs here.'
|
||||
render: powershell
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Browser Logs
|
||||
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: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browsers do you use to access the UI ?
|
||||
multiple: true
|
||||
options:
|
||||
- Mozilla Firefox
|
||||
- Google Chrome
|
||||
- Brave
|
||||
- Apple Safari
|
||||
- Microsoft Edge
|
||||
- Android
|
||||
- iOS
|
||||
- Other
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Other
|
||||
description: 'Any other additional information you think might be helpful.'
|
||||
validations:
|
||||
required: false
|
||||
40
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature Request]: "
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the feature you want, and that it's not implemented in a recent build/commit.
|
||||
options:
|
||||
- label: I have searched the existing issues and checked the recent builds/commits
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: What would your feature do ?
|
||||
description: Tell us about your feature in a very clear and simple way, and what problem it would solve
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: workflow
|
||||
attributes:
|
||||
label: Proposed workflow
|
||||
description: Please provide us with step by step information on how you'd like the feature to be accessed and used
|
||||
value: |
|
||||
1. Go to ....
|
||||
2. Press ....
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: misc
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
25
.github/workflows/eslint.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: ESLint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- 'dev*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- 'dev*'
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
2
.github/workflows/test-browser-exp.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "huchenlei/ComfyUI_frontend"
|
||||
repository: "Comfy-Org/ComfyUI_frontend"
|
||||
path: "ComfyUI_frontend"
|
||||
ref: ${{ github.head_ref }}
|
||||
- uses: actions/setup-node@v3
|
||||
|
||||
53
.github/workflows/update-main.yaml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Update Main Repo from PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
update-main-repo:
|
||||
if: github.event.label.name == 'Update Main Repo'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout frontend repo PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "comfyanonymous/ComfyUI"
|
||||
path: ComfyUI
|
||||
ref: master
|
||||
|
||||
- name: Copy compiled assets
|
||||
run: |
|
||||
rm -rf ./ComfyUI/web/*
|
||||
cp -R dist/* ./ComfyUI/web/
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
|
||||
title: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
|
||||
body: |
|
||||
This PR updates the compiled frontend assets from PR #${{ github.event.pull_request.number }} in the frontend repo.
|
||||
|
||||
Frontend PR: ${{ github.event.pull_request.html_url }}
|
||||
branch: update-frontend-assets-pr-${{ github.event.pull_request.number }}
|
||||
base: main
|
||||
path: ComfyUI
|
||||
1
.gitignore
vendored
@@ -34,6 +34,7 @@ tests-ui/workflows/examples
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
browser_tests/*/*-win32.png
|
||||
|
||||
.env
|
||||
|
||||
|
||||
170
README.md
@@ -1,34 +1,175 @@
|
||||
<div align="center">
|
||||
|
||||
# ComfyUI_frontend
|
||||
|
||||
Front-end of [ComfyUI](https://github.com/comfyanonymous/ComfyUI) modernized. This repo is fully compatible with the existing extension system.
|
||||
**Official front-end implementation of [ComfyUI](https://github.com/comfyanonymous/ComfyUI).**
|
||||
|
||||
## How To Use
|
||||
[![Website][website-shield]][website-url]
|
||||
[![Discord][discord-shield]][discord-url]
|
||||
[![Matrix][matrix-shield]][matrix-url]
|
||||
<br>
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-release-date-shield]][github-release-link]
|
||||
[![][github-downloads-shield]][github-downloads-link]
|
||||
[![][github-downloads-latest-shield]][github-downloads-link]
|
||||
|
||||
Add command line argument `--front-end-version Comfy-Org/ComfyUI_frontend@latest` to your
|
||||
ComfyUI launch script.
|
||||
|
||||
For Windows stand-alone build users, please edit the `run_cpu.bat` / `run_nvidia_gpu.bat` file as following
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/Comfy-Org/ComfyUI_frontend?style=flat&sort=semver
|
||||
[github-release-link]: https://github.com/Comfy-Org/ComfyUI_frontend/releases
|
||||
[github-release-date-shield]: https://img.shields.io/github/release-date/Comfy-Org/ComfyUI_frontend?style=flat
|
||||
[github-downloads-shield]: https://img.shields.io/github/downloads/Comfy-Org/ComfyUI_frontend/total?style=flat
|
||||
[github-downloads-latest-shield]: https://img.shields.io/github/downloads/Comfy-Org/ComfyUI_frontend/latest/total?style=flat&label=downloads%40latest
|
||||
[github-downloads-link]: https://github.com/Comfy-Org/ComfyUI_frontend/releases
|
||||
[matrix-shield]: https://img.shields.io/badge/Matrix-000000?style=flat&logo=matrix&logoColor=white
|
||||
[matrix-url]: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org
|
||||
[website-shield]: https://img.shields.io/badge/ComfyOrg-4285F4?style=flat
|
||||
[website-url]: https://www.comfy.org/
|
||||
[discord-shield]: https://img.shields.io/discord/1218270712402415686?style=flat&logo=discord&logoColor=white&label=Discord
|
||||
[discord-url]: https://www.comfy.org/discord
|
||||
|
||||
</div>
|
||||
|
||||
## Release Schedule
|
||||
|
||||
### Nightly Release
|
||||
|
||||
Nightly releases are published daily at [https://github.com/Comfy-Org/ComfyUI_frontend/releases](https://github.com/Comfy-Org/ComfyUI_frontend/releases).
|
||||
|
||||
To use the latest nightly release, add the following command line argument to your ComfyUI launch script:
|
||||
|
||||
```
|
||||
--front-end-version Comfy-Org/ComfyUI_frontend@latest
|
||||
```
|
||||
|
||||
#### For Windows Stand-alone Build Users
|
||||
|
||||
Edit your `run_cpu.bat` or `run_nvidia_gpu.bat` file as follows:
|
||||
|
||||
```bat
|
||||
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --front-end-version Comfy-Org/ComfyUI_frontend@latest
|
||||
pause
|
||||
```
|
||||
|
||||
### Stable Release
|
||||
|
||||
Stable releases are published weekly in the ComfyUI main repository, aligned with ComfyUI backend's stable release schedule.
|
||||
|
||||
#### Feature Freeze
|
||||
|
||||
There will be a 2-day feature freeze before each stable release. During this period, no new major features will be merged.
|
||||
|
||||
## Release Summary
|
||||
|
||||
### Major features
|
||||
|
||||
<details>
|
||||
<summary>v1.2.4: Node library sidebar tab</summary>
|
||||
|
||||
#### Drag & Drop
|
||||
https://github.com/user-attachments/assets/853e20b7-bc0e-49c9-bbce-a2ba7566f92f
|
||||
|
||||
#### Filter
|
||||
https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.0: Queue/History sidebar tab</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.1.0: Node search box</summary>
|
||||
|
||||
#### Fuzzy search & Node preview
|
||||

|
||||
|
||||
#### Release link with shift
|
||||
https://github.com/user-attachments/assets/a1b2b5c3-10d1-4256-b620-345de6858f25
|
||||
</details>
|
||||
|
||||
### QoL changes
|
||||
|
||||
<details>
|
||||
<summary>v1.2.7: **Litegraph** drags multiple links with shift pressed</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/68826715-bb55-4b2a-be6e-675cfc424afe
|
||||
|
||||
https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.2: **Litegraph** auto connects to correct slot</summary>
|
||||
|
||||
#### Before
|
||||
https://github.com/user-attachments/assets/c253f778-82d5-4e6f-aec0-ea2ccf421651
|
||||
|
||||
#### After
|
||||
https://github.com/user-attachments/assets/b6360ac0-f0d2-447c-9daa-8a2e20c0dc1d
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
|
||||
</details>
|
||||
|
||||
### Node developers API
|
||||
<details>
|
||||
<summary>v1.2.4: Extension API to register custom sidebar tab</summary>
|
||||
|
||||
Extensions now can call the following API to register a sidebar tab.
|
||||
|
||||
```js
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "search",
|
||||
icon: "pi pi-search",
|
||||
title: "search",
|
||||
tooltip: "search",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
el.innerHTML = "<div>Custom search tab</div>";
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The list of supported icons can be found here: <https://primevue.org/icons/#list>
|
||||
|
||||
We will support custom icons later.
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.27: Extension API to add toast message</summary>
|
||||
|
||||
Extensions can call the following API to add toast messages.
|
||||
|
||||
```js
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Loaded!',
|
||||
detail: 'Extension loaded!'
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
## Road Map
|
||||
|
||||
### What has been done
|
||||
|
||||
- Migrate all code to TypeScript with minimal change modification to the original logic.
|
||||
- Bundle all code with vite's rollup build.
|
||||
- Bundle all code with Vite's rollup build.
|
||||
- Added a shim layer to be backward compatible with the existing extension system. <https://github.com/huchenlei/ComfyUI_frontend/pull/15>
|
||||
- Front-end dev server.
|
||||
- Zod schema for input validation on ComfyUI workflow.
|
||||
- Make litegraph a npm dependency. <https://github.com/Comfy-Org/ComfyUI_frontend/pull/89>
|
||||
- Introduce Vue to start managing part of the UI.
|
||||
|
||||
- Starting with node search box revamp 
|
||||
|
||||
- Easy install and version management (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
|
||||
- Better node management. Sherlock <https://github.com/Nuked88/ComfyUI-N-Sidebar>.
|
||||
|
||||
|
||||
### What to be done
|
||||
@@ -36,10 +177,9 @@ pause
|
||||
- Replace the existing ComfyUI front-end impl
|
||||
- Remove `@ts-ignore`s.
|
||||
- Turn on `strict` on `tsconfig.json`.
|
||||
- Introduce a UI library to add more widget types for node developers.
|
||||
- Add more widget types for node developers.
|
||||
- LLM streaming node.
|
||||
- Linear mode (Similar to InvokeAI's linear mode).
|
||||
- Better node management. Sherlock https://github.com/Nuked88/ComfyUI-N-Sidebar.
|
||||
- Keybinding settings management. Register keybindings API for custom nodes.
|
||||
- New extensions API for adding UI-related features.
|
||||
|
||||
@@ -67,11 +207,11 @@ core extensions will be loaded.
|
||||
- `npm run test:generate:examples` to extract the example workflows
|
||||
- `npm run test` to execute all unit tests.
|
||||
|
||||
### 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.
|
||||
|
||||
## Deploy
|
||||
|
||||
- Option 1: Set `DEPLOY_COMFYUI_DIR` in `.env` and run `npm run deploy`.
|
||||
- Option 2: Copy everything under `dist/` to `ComfyUI/web/` in your ComfyUI checkout manually.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
- api.api_url now adds a prefix `api/` to every url going through the method. If the custom node registers a new api endpoint but does not offer the `api/` prefixed alt endpoint, it will have issue. Luckily there aren't many extensions that do that. We can perform an audit before launching to resolve this issue.
|
||||
|
||||
@@ -26,18 +26,24 @@ class ComfyNodeSearchBox {
|
||||
)
|
||||
}
|
||||
|
||||
async fillAndSelectFirstNode(nodeName: string) {
|
||||
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(0).click()
|
||||
await this.dropdown
|
||||
.locator('li')
|
||||
.nth(options?.suggestionIndex || 0)
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
class NodeLibrarySideBarTab {
|
||||
class NodeLibrarySidebarTab {
|
||||
public readonly tabId: string = 'node-library'
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
@@ -74,16 +80,16 @@ class NodeLibrarySideBarTab {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
public readonly sideToolBar: Locator
|
||||
public readonly sideToolbar: Locator
|
||||
public readonly themeToggleButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.sideToolBar = page.locator('.side-tool-bar-container')
|
||||
this.sideToolbar = page.locator('.side-tool-bar-container')
|
||||
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
|
||||
}
|
||||
|
||||
get nodeLibraryTab() {
|
||||
return new NodeLibrarySideBarTab(this.page)
|
||||
return new NodeLibrarySidebarTab(this.page)
|
||||
}
|
||||
|
||||
async toggleTheme() {
|
||||
@@ -170,7 +176,16 @@ export class ComfyPage {
|
||||
await this.resetView()
|
||||
}
|
||||
|
||||
async realod() {
|
||||
async setSetting(settingId: string, settingValue: any) {
|
||||
return await this.page.evaluate(
|
||||
async ({ id, value }) => {
|
||||
await window['app'].ui.settings.setSettingValueAsync(id, value)
|
||||
},
|
||||
{ id: settingId, value: settingValue }
|
||||
)
|
||||
}
|
||||
|
||||
async reload() {
|
||||
await this.page.reload({ timeout: 15000 })
|
||||
await this.setup()
|
||||
}
|
||||
@@ -185,6 +200,10 @@ export class ComfyPage {
|
||||
})
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.workflowUploadInput.setInputFiles(
|
||||
`./browser_tests/assets/${workflowName}.json`
|
||||
@@ -211,6 +230,16 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickTextEncodeNodeToggler() {
|
||||
await this.canvas.click({
|
||||
position: {
|
||||
x: 430,
|
||||
y: 171
|
||||
}
|
||||
})
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickTextEncodeNode2() {
|
||||
await this.canvas.click({
|
||||
position: {
|
||||
@@ -280,16 +309,22 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async zoom(deltaY: number) {
|
||||
async zoom(deltaY: number, steps: number = 1) {
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.wheel(0, deltaY)
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await this.page.mouse.wheel(0, deltaY)
|
||||
}
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async pan(offset: Position) {
|
||||
await this.page.mouse.move(10, 10)
|
||||
async pan(offset: Position, safeSpot?: Position) {
|
||||
safeSpot = safeSpot || { x: 10, y: 10 }
|
||||
await this.page.mouse.move(safeSpot.x, safeSpot.y)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(offset.x, offset.y)
|
||||
// TEMPORARY HACK: Multiple pans open the search menu, so cheat and keep it closed.
|
||||
// TODO: Fix that (double-click at not-the-same-coordinations should not open the menu)
|
||||
await this.page.keyboard.press('Escape')
|
||||
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
|
||||
await this.page.mouse.up()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
41
browser_tests/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Playwright Testing for ComfyUI_frontend
|
||||
|
||||
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
|
||||
|
||||
## Setup
|
||||
|
||||
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium --with-deps
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
There are two ways to run the tests:
|
||||
|
||||
1. **Headless mode with report generation:**
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
This runs all tests without a visible browser and generates a comprehensive test report.
|
||||
|
||||
2. **UI mode for interactive testing:**
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
This opens a user interface where you can select specific tests to run and inspect the test execution timeline.
|
||||
|
||||

|
||||
|
||||
## Screenshot Expectations
|
||||
|
||||
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
|
||||
|
||||
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment.
|
||||
- To set new test expectations:
|
||||
1. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch.
|
||||
2. Add the `New Browser Test Expectation` tag to your pull request.
|
||||
3. This will trigger a GitHub action to update the screenshot expectations automatically.
|
||||
|
||||
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.
|
||||
193
browser_tests/assets/batch_move_links.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
0,
|
||||
92
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 98
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"3Guofeng3_v32Light.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
460,
|
||||
92
|
||||
],
|
||||
"size": {
|
||||
"0": 422.84503173828125,
|
||||
"1": 164.31304931640625
|
||||
},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
460,
|
||||
368
|
||||
],
|
||||
"size": {
|
||||
"0": 425.27801513671875,
|
||||
"1": 180.6060791015625
|
||||
},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"text, watermark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
0,
|
||||
276
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 98
|
||||
},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"3Guofeng3_v32Light.safetensors"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -22,6 +22,28 @@ test.describe('Copy Paste', () => {
|
||||
expect(resultString).toBe(originalString + originalString)
|
||||
})
|
||||
|
||||
test('Can copy and paste widget value', async ({ comfyPage }) => {
|
||||
// Copy width value (512) from empty latent node to KSampler's seed.
|
||||
// Empty latent node's width
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 718,
|
||||
y: 643
|
||||
}
|
||||
})
|
||||
await comfyPage.ctrlC()
|
||||
// KSampler's seed
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 1005,
|
||||
y: 281
|
||||
}
|
||||
})
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
|
||||
})
|
||||
|
||||
/**
|
||||
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/98
|
||||
*/
|
||||
@@ -53,4 +75,15 @@ test.describe('Copy Paste', () => {
|
||||
await comfyPage.ctrlV()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
|
||||
})
|
||||
|
||||
test('Copy node by dragging + alt', async ({ comfyPage }) => {
|
||||
// TextEncodeNode1
|
||||
await comfyPage.page.mouse.move(618, 191)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(100, 100)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('drag-copy-copied-node.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 95 KiB |
@@ -27,6 +27,9 @@ test.describe('Node Interaction', () => {
|
||||
|
||||
test('Can disconnect/connect edge', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
// Close search menu popped up.
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'disconnected-edge-with-menu.png'
|
||||
)
|
||||
@@ -58,6 +61,54 @@ test.describe('Node Interaction', () => {
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
|
||||
})
|
||||
|
||||
test('Can batch move links by drag with shift', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('batch_move_links')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
|
||||
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
}
|
||||
const outputSlot2Pos = {
|
||||
x: 307,
|
||||
y: 310
|
||||
}
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch_move_links_moved.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can batch disconnect links with ctrl+alt+click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointClipSlotPos = {
|
||||
x: 332,
|
||||
y: 508
|
||||
}
|
||||
await comfyPage.canvas.click({
|
||||
modifiers: ['Control', 'Alt'],
|
||||
position: loadCheckpointClipSlotPos
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch-disconnect-links-disconnected.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can toggle dom widget node open/closed', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
|
||||
await comfyPage.clickTextEncodeNodeToggler()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('text-encode-toggled-off.png')
|
||||
await comfyPage.delay(1000)
|
||||
await comfyPage.clickTextEncodeNodeToggler()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('text-encode-toggled-back-open.png')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Canvas Interaction', () => {
|
||||
@@ -68,8 +119,48 @@ test.describe('Canvas Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png')
|
||||
})
|
||||
|
||||
test('Can zoom very far out', async ({ comfyPage }) => {
|
||||
await comfyPage.zoom(100, 12)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-very-far-out.png')
|
||||
await comfyPage.zoom(-100, 12)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-back-in.png')
|
||||
})
|
||||
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can pan', async ({ comfyPage }) => {
|
||||
await comfyPage.pan({ x: 200, y: 200 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
|
||||
})
|
||||
|
||||
test('Can pan very far and back', async ({ comfyPage }) => {
|
||||
// intentionally slice the edge of where the clip text encode dom widgets are
|
||||
await comfyPage.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-one.png')
|
||||
await comfyPage.pan({ x: -200, y: 0 }, { x: 1000, y: 10 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-two.png')
|
||||
await comfyPage.pan({ x: -2200, y: -2200 }, { x: 1000, y: 10 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('panned-far-away.png')
|
||||
await comfyPage.pan({ x: 2200, y: 2200 }, { x: 1000, y: 10 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-from-far.png')
|
||||
await comfyPage.pan({ x: 200, y: 0 }, { x: 1000, y: 10 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-two.png')
|
||||
await comfyPage.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-one.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -44,7 +44,7 @@ test.describe('Menu', () => {
|
||||
})
|
||||
|
||||
test('Can register sidebar tab', async ({ comfyPage }) => {
|
||||
const initialChildrenCount = await comfyPage.menu.sideToolBar.evaluate(
|
||||
const initialChildrenCount = await comfyPage.menu.sideToolbar.evaluate(
|
||||
(el) => el.children.length
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ test.describe('Menu', () => {
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newChildrenCount = await comfyPage.menu.sideToolBar.evaluate(
|
||||
const newChildrenCount = await comfyPage.menu.sideToolbar.evaluate(
|
||||
(el) => el.children.length
|
||||
)
|
||||
expect(newChildrenCount).toBe(initialChildrenCount + 1)
|
||||
|
||||
@@ -2,22 +2,30 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Node search box', () => {
|
||||
test('Can trigger on empty canvas double click', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.LinkReleaseTrigger',
|
||||
'always'
|
||||
)
|
||||
})
|
||||
;['always', 'hold shift', 'NOT hold shift'].forEach((triggerMode) => {
|
||||
test(`Can trigger on empty canvas double click (${triggerMode})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.LinkReleaseTrigger',
|
||||
triggerMode
|
||||
)
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Can trigger on link release', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.disconnectEdge()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Does not trigger on link release (no shift)', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
@@ -26,10 +34,35 @@ test.describe('Node search box', () => {
|
||||
})
|
||||
|
||||
test('Can auto link node', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.disconnectEdge()
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode')
|
||||
// Select the second item as the first item is always reroute
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode', {
|
||||
suggestionIndex: 0
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
|
||||
})
|
||||
|
||||
test('Can auto link batch moved node', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('batch_move_links')
|
||||
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
}
|
||||
const emptySpacePos = {
|
||||
x: 5,
|
||||
y: 5
|
||||
}
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
// Select the second item as the first item is always reroute
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
|
||||
suggestionIndex: 0
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'auto-linked-node-batch.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 91 KiB |
@@ -85,4 +85,36 @@ test.describe('Node Right Click Menu', () => {
|
||||
'right-click-node-widget-converted.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can pin and unpin', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.dragAndDrop({ x: 621, y: 617 }, { x: 16, y: 16 })
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-pinned-node.png'
|
||||
)
|
||||
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-unpinned-node.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can move after unpin', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.waitForTimeout(256)
|
||||
await comfyPage.dragAndDrop({ x: 496, y: 618 }, { x: 200, y: 590 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-unpinned-node-moved.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
32
eslint.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import globals from 'globals'
|
||||
import pluginJs from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['src/**/*.{js,mjs,cjs,ts,vue}']
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'src/scripts/*',
|
||||
'src/extensions/core/*',
|
||||
'src/types/vue-shim.d.ts'
|
||||
]
|
||||
},
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
{
|
||||
files: ['src/**/*.vue'],
|
||||
languageOptions: { parserOptions: { parser: tseslint.parser } }
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/prefer-as-const': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -12,15 +12,15 @@
|
||||
font-family: 'Roboto Mono', 'Noto Color Emoji';
|
||||
}
|
||||
</style> -->
|
||||
<script src="node_modules/reflect-metadata/Reflect.js"></script>
|
||||
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
|
||||
<script type="module">
|
||||
import 'reflect-metadata';
|
||||
window["__COMFYUI_FRONTEND_VERSION__"] = __COMFYUI_FRONTEND_VERSION__;
|
||||
console.log("ComfyUI Front-end version:", __COMFYUI_FRONTEND_VERSION__);
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<link rel="stylesheet" type="text/css" href="/user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/materialdesignicons.min.css" />
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
<link rel="stylesheet" type="text/css" href="user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
|
||||
</head>
|
||||
<body class="litegraph">
|
||||
<div id="vue-app"></div>
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { JestConfigWithTsJest } from 'ts-jest'
|
||||
const jestConfig: JestConfigWithTsJest = {
|
||||
testMatch: ['**/tests-ui/**/*.test.ts'],
|
||||
testEnvironment: 'jsdom',
|
||||
moduleFileExtensions: ['js', 'jsx', 'json', 'vue', 'ts', 'tsx'],
|
||||
transform: {
|
||||
'^.+\\.vue$': '@vue/vue3-jest',
|
||||
'^.+\\.m?[tj]sx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
|
||||
1787
package-lock.json
generated
18
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.30",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,20 +15,28 @@
|
||||
"test:generate": "npx tsx tests-ui/setup",
|
||||
"test:browser": "npx playwright test",
|
||||
"prepare": "husky || true",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.8",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/vue3-jest": "^29.2.6",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||
"babel-plugin-transform-rename-import": "^2.3.0",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "^15.9.0",
|
||||
"husky": "^9.0.11",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
@@ -41,22 +49,24 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.15.6",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-static-copy": "^1.0.5",
|
||||
"zip-dir": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
|
||||
"@comfyorg/litegraph": "^0.7.29",
|
||||
"@comfyorg/litegraph": "^0.7.47",
|
||||
"@primevue/themes": "^4.0.0-rc.2",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fuse.js": "^7.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.0.0-rc.2",
|
||||
"primevue": "^4.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"vue": "^3.4.31",
|
||||
"vue-i18n": "^9.13.1",
|
||||
|
||||
@@ -36,7 +36,7 @@ export default defineConfig({
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 5000
|
||||
timeout: 15000
|
||||
}
|
||||
|
||||
// {
|
||||
|
||||
117
src/App.vue
@@ -1,36 +1,38 @@
|
||||
<template>
|
||||
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
|
||||
<div v-else>
|
||||
<NodeSearchboxPopover v-if="nodeSearchEnabled" />
|
||||
<teleport to=".graph-canvas-container">
|
||||
<LiteGraphCanvasSplitterOverlay v-if="betaMenuEnabled">
|
||||
<template #side-bar-panel>
|
||||
<SideToolBar />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
</teleport>
|
||||
</div>
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<GlobalDialog />
|
||||
<GlobalToast />
|
||||
<GraphCanvas />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import NodeSearchboxPopover from '@/components/NodeSearchBoxPopover.vue'
|
||||
import SideToolBar from '@/components/sidebar/SideToolBar.vue'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import QueueSideBarTab from '@/components/sidebar/tabs/QueueSideBarTab.vue'
|
||||
import {
|
||||
computed,
|
||||
markRaw,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
|
||||
import { app } from './scripts/app'
|
||||
import { useSettingStore } from './stores/settingStore'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWorkspaceStore } from './stores/workspaceStateStore'
|
||||
import NodeLibrarySideBarTab from './components/sidebar/tabs/NodeLibrarySideBarTab.vue'
|
||||
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { useNodeDefStore } from './stores/nodeDefStore'
|
||||
import NodeLibrarySidebarTab from './components/sidebar/tabs/NodeLibrarySidebarTab.vue'
|
||||
import GlobalDialog from './components/dialog/GlobalDialog.vue'
|
||||
import GlobalToast from './components/toast/GlobalToast.vue'
|
||||
import { api } from './scripts/api'
|
||||
import { StatusWsMessageStatus } from './types/apiTypes'
|
||||
import { useQueuePendingTaskCountStore } from './stores/queueStore'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const nodeSearchEnabled = computed<boolean>(
|
||||
() => useSettingStore().get('Comfy.NodeSearchBoxImpl') === 'default'
|
||||
)
|
||||
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
|
||||
const theme = computed<string>(() =>
|
||||
useSettingStore().get('Comfy.ColorPalette')
|
||||
)
|
||||
@@ -47,61 +49,78 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const betaMenuEnabled = computed(
|
||||
() => useSettingStore().get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
const fontSize = useSettingStore().get('Comfy.TextareaWidget.FontSize')
|
||||
document.documentElement.style.setProperty(
|
||||
'--comfy-textarea-font-size',
|
||||
`${fontSize}px`
|
||||
)
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
let dropTargetCleanup = () => {}
|
||||
const init = () => {
|
||||
useSettingStore().addSettings(app.ui.settings)
|
||||
app.vueAppReady = true
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: 'queue',
|
||||
icon: 'pi pi-history',
|
||||
title: t('sideToolBar.queue'),
|
||||
tooltip: t('sideToolBar.queue'),
|
||||
component: markRaw(QueueSideBarTab),
|
||||
iconBadge: () => {
|
||||
const value = useQueuePendingTaskCountStore().count.toString()
|
||||
return value === '0' ? null : value
|
||||
},
|
||||
title: t('sideToolbar.queue'),
|
||||
tooltip: t('sideToolbar.queue'),
|
||||
component: markRaw(QueueSidebarTab),
|
||||
type: 'vue'
|
||||
})
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: 'node-library',
|
||||
icon: 'pi pi-book',
|
||||
title: t('sideToolBar.nodeLibrary'),
|
||||
tooltip: t('sideToolBar.nodeLibrary'),
|
||||
component: markRaw(NodeLibrarySideBarTab),
|
||||
title: t('sideToolbar.nodeLibrary'),
|
||||
tooltip: t('sideToolbar.nodeLibrary'),
|
||||
component: markRaw(NodeLibrarySidebarTab),
|
||||
type: 'vue'
|
||||
})
|
||||
}
|
||||
|
||||
dropTargetCleanup = dropTargetForElements({
|
||||
element: document.querySelector('.graph-canvas-container'),
|
||||
onDrop: (event) => {
|
||||
const loc = event.location.current.input
|
||||
// Add an offset on x to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
const pos = app.clientPosToCanvasPos([loc.clientX - 20, loc.clientY])
|
||||
const comfyNodeName = event.source.element.getAttribute(
|
||||
'data-comfy-node-name'
|
||||
)
|
||||
const nodeDef = useNodeDefStore().nodeDefsByName[comfyNodeName]
|
||||
app.addNodeOnGraph(nodeDef, { pos })
|
||||
}
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const onStatus = (e: CustomEvent<StatusWsMessageStatus>) =>
|
||||
queuePendingTaskCountStore.update(e)
|
||||
|
||||
const toast = useToast()
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('reconnecting')
|
||||
}
|
||||
const onReconnecting = () => {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add(reconnectingMessage)
|
||||
}
|
||||
const onReconnected = () => {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
api.addEventListener('status', onStatus)
|
||||
api.addEventListener('reconnecting', onReconnecting)
|
||||
api.addEventListener('reconnected', onReconnected)
|
||||
try {
|
||||
init()
|
||||
} catch (e) {
|
||||
console.error('Failed to init Vue app', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
dropTargetCleanup()
|
||||
api.removeEventListener('status', onStatus)
|
||||
api.removeEventListener('reconnecting', onReconnecting)
|
||||
api.removeEventListener('reconnected', onReconnected)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ body {
|
||||
#graph-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.comfyui-body-right {
|
||||
@@ -131,7 +132,7 @@ body {
|
||||
resize: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
}
|
||||
|
||||
.comfy-modal {
|
||||
|
||||
@@ -4,27 +4,43 @@
|
||||
class="side-bar-panel"
|
||||
:minSize="10"
|
||||
:size="20"
|
||||
v-show="sideBarPanelVisible"
|
||||
v-show="sidebarPanelVisible"
|
||||
v-if="sidebarLocation === 'left'"
|
||||
>
|
||||
<slot name="side-bar-panel"></slot>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel class="graph-canvas-panel" :size="100">
|
||||
<div></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
class="side-bar-panel"
|
||||
:minSize="10"
|
||||
:size="20"
|
||||
v-show="sidebarPanelVisible"
|
||||
v-if="sidebarLocation === 'right'"
|
||||
>
|
||||
<slot name="side-bar-panel"></slot>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const sideBarPanelVisible = computed(
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(
|
||||
() => useWorkspaceStore().activeSidebarTab !== null
|
||||
)
|
||||
const gutterClass = computed(() => {
|
||||
return sideBarPanelVisible.value ? '' : 'gutter-hidden'
|
||||
return sidebarPanelVisible.value ? '' : 'gutter-hidden'
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
58
src/components/common/ComfyImage.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<!-- A image with placeholder fallback on error -->
|
||||
<template>
|
||||
<img
|
||||
:src="src"
|
||||
@error="handleImageError"
|
||||
:class="[{ 'broken-image': imageBroken }, ...classArray]"
|
||||
/>
|
||||
<div v-if="imageBroken" class="broken-image-placeholder">
|
||||
<i class="pi pi-image"></i>
|
||||
<span>{{ $t('imageFailedToLoad') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
class?: string | string[] | object
|
||||
}>()
|
||||
|
||||
const imageBroken = ref(false)
|
||||
const handleImageError = (e: Event) => {
|
||||
imageBroken.value = true
|
||||
}
|
||||
|
||||
const classArray = computed(() => {
|
||||
if (Array.isArray(props.class)) {
|
||||
return props.class
|
||||
} else if (typeof props.class === 'string') {
|
||||
return props.class.split(' ')
|
||||
} else if (typeof props.class === 'object') {
|
||||
return Object.keys(props.class).filter((key) => props.class[key])
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.broken-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.broken-image-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.broken-image-placeholder i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
58
src/components/common/NoResultsPlaceholder.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="no-results-placeholder">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-column align-items-center">
|
||||
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem"></i>
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ message }}</p>
|
||||
<Button
|
||||
v-if="buttonLabel"
|
||||
:label="buttonLabel"
|
||||
@click="$emit('action')"
|
||||
class="p-button-text"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
message: string
|
||||
buttonLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-results-placeholder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.no-results-placeholder :deep(.p-card) {
|
||||
background-color: var(--surface-ground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-results-placeholder h3 {
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.no-results-placeholder p {
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
50
src/components/common/SearchBox.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<IconField :class="props.class">
|
||||
<InputIcon :class="props.icon" />
|
||||
<InputText
|
||||
class="search-box-input"
|
||||
@input="handleInput"
|
||||
:modelValue="props.modelValue"
|
||||
:placeholder="props.placeholder"
|
||||
/>
|
||||
</IconField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Search...',
|
||||
icon: 'pi pi-search',
|
||||
debounceTime: 300
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
|
||||
const emitSearch = debounce((value: string) => {
|
||||
emit('search', value)
|
||||
}, props.debounceTime)
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
emitSearch(target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-box-input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
38
src/components/dialog/GlobalDialog.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="dialogStore.isVisible"
|
||||
modal
|
||||
closable
|
||||
closeOnEscape
|
||||
dismissableMask
|
||||
:maximizable="dialogStore.props.maximizable ?? false"
|
||||
@hide="dialogStore.closeDialog"
|
||||
@maximize="maximized = true"
|
||||
@unmaximize="maximized = false"
|
||||
>
|
||||
<template #header>
|
||||
<component
|
||||
v-if="dialogStore.headerComponent"
|
||||
:is="dialogStore.headerComponent"
|
||||
/>
|
||||
<h3 v-else>{{ dialogStore.title || ' ' }}</h3>
|
||||
</template>
|
||||
|
||||
<component
|
||||
:is="dialogStore.component"
|
||||
v-bind="dialogStore.props"
|
||||
:maximized="maximized"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const maximized = ref(false)
|
||||
</script>
|
||||
138
src/components/dialog/content/LoadWorkflowWarning.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<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>
|
||||
</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
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: (string | NodeType)[]
|
||||
hasAddedNodes: boolean
|
||||
maximized: boolean
|
||||
}>()
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
seenTypes.add(type)
|
||||
return true
|
||||
})
|
||||
.map((node) => {
|
||||
if (typeof node === 'object') {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action
|
||||
}
|
||||
}
|
||||
return { label: node }
|
||||
})
|
||||
})
|
||||
</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;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
:deep(.p-button) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.added-nodes-warning {
|
||||
margin-top: 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||