Compare commits

...

122 Commits

Author SHA1 Message Date
github-actions
fdeb438455 Update locales [skip ci] 2025-05-20 20:43:39 +00:00
bymyself
8ca7f7e589 swap link release defaults between normal and shift 2025-05-20 13:39:58 -07:00
bymyself
13f03fed65 set search box as default 2025-04-26 14:23:35 -07:00
Comfy Org PR Bot
8f540e6603 [chore] Update litegraph to 0.14.2 (#3650)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-27 05:41:32 +10:00
Christian Byrne
99cc587abf Load workflows from mp4/mov files (#3543)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-26 12:31:29 -07:00
Benjamin Lu
82c5f02c3d Add contributing guidelines to README.md (#3637)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-26 08:39:35 -04:00
Chenlei Hu
04f447c2a3 [Cleanup] Remove tooltip on current user button (#3636) 2025-04-25 19:27:47 -04:00
Benjamin Lu
84c14ddd92 Remove automatic nightly pytorch for blackwell users on desktop (#3613)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-25 18:08:37 -04:00
Comfy Org PR Bot
c7e6d66d47 [chore] Update electron-types to 0.4.42 (#3634)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-26 07:45:10 +10:00
filtered
5d4be8dc63 Fix electron type workflow (#3633) 2025-04-26 07:43:35 +10:00
Chenlei Hu
0bec26ca4b [Cleanup] Remove api.listUserData (#3632) 2025-04-25 15:55:42 -04:00
Chenlei Hu
45eb4701d2 Implement top menu user popover (#3631)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-25 15:26:41 -04:00
Chenlei Hu
25359575db [Refactor] Extract user credit component (#3630) 2025-04-25 15:02:36 -04:00
Comfy Org PR Bot
d0b99b95c6 1.18.1 (#3629)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-25 13:34:35 -04:00
filtered
ddff592561 Fix new link follows pointer until menu closed (#3628)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-26 03:26:03 +10:00
Benjamin Lu
8491ca91b7 Move component tests into the same directory as their component counterparts (#3625)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-25 13:13:30 -04:00
Yiximail
630fa04882 Fix dynamic screen height (#3626) 2025-04-25 13:12:30 -04:00
Comfy Org PR Bot
bf80ae7295 [chore] Update litegraph to 0.14.1 (#3627)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-26 02:38:46 +10:00
Yiximail
44348180f5 Fix long-press to open contextmenu on touch devices (#3624) 2025-04-25 11:30:13 -04:00
Benjamin Lu
4e12800336 Expand component test coverage to BrowserTabTitle (#3623)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-25 11:03:46 -04:00
Yiximail
d883448b86 Fix incorrect touch event handling by accurately tracking touch points (#3622) 2025-04-25 10:54:34 -04:00
Benjamin Lu
1c59e3b51b [nit] Use TorchMirrorUrl (#3617)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-25 10:52:52 -04:00
Chenlei Hu
b79cbf69af Use PrimeVue Avatar component (#3615) 2025-04-24 21:49:03 -04:00
Chenlei Hu
b05407ffdd [Refactor] Split authStore into authStore and authService (#3612)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-24 20:45:30 -04:00
Benjamin Lu
2a62f7ec7f ignore Vite timestamped config modules in .gitignore (#3614)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-24 20:40:18 -04:00
Benjamin Lu
bf757c11ef Reland "Add unified sidebar width setting" (#3611)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
2025-04-24 17:45:25 -04:00
Chenlei Hu
0ed29a198d [Cleanup] Remove unnecessary null check on auth (#3610) 2025-04-24 17:36:16 -04:00
Chenlei Hu
31d5671f24 Revert "Add unified sidebar width setting" (#3609) 2025-04-24 17:14:01 -04:00
Benjamin Lu
ba3e2edb8a Add unified sidebar width setting (#3605)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
2025-04-24 16:41:46 -04:00
Benjamin Lu
9c2300d780 Update README with Recommended Code Editor Configuration (#3606)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-24 16:34:38 -04:00
Benjamin Lu
3f85ff751c Remove Release Summary section in README (#3607)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-24 16:34:07 -04:00
Chenlei Hu
0caf1686c3 Add Comfy.User.SignOut command (#3608)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-24 16:33:26 -04:00
Chenlei Hu
26f98d24fb [Refactor] Async load all setting panels (#3604) 2025-04-24 13:00:42 -04:00
Comfy Org PR Bot
fcbdee54ec 1.18.0 (#3552)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-04-24 12:43:44 -04:00
filtered
a944372f39 Improve default node sizes (#3596)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-24 12:42:16 -04:00
Comfy Org PR Bot
df51e89311 [chore] Update litegraph to 0.14.0 (#3602)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-04-25 01:00:15 +10:00
Terry Jia
b314435f81 add importmap for vue-i18n (#3595) 2025-04-23 22:34:19 -04:00
Chenlei Hu
ba367c0214 [nit] Rename credit history to invoice history (#3598)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-23 22:33:52 -04:00
Chenlei Hu
64ad6a9bb0 Re-style TopUpCreditDialog to match design (#3597) 2025-04-23 22:28:35 -04:00
AustinMroz
3819db5ec4 Introduce widgetType option to inputSpec (#3550) 2025-04-23 20:36:01 -04:00
filtered
80517e8204 [Test] Add test expectation (#3593) 2025-04-24 08:48:06 +10:00
Comfy Org PR Bot
40034e77f9 1.17.11 (#3592)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-24 08:04:24 +10:00
filtered
2ef8b7cfd7 Fix opening search from context menu (#3591)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-24 07:54:27 +10:00
Comfy Org PR Bot
9cf3a0e568 1.17.10 (#3590)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-23 15:19:22 -04:00
Chenlei Hu
f562cf27cd [API Node] Workaround custom node hijack on api.queuePrompt (#3589)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-04-23 15:18:37 -04:00
Chenlei Hu
4244a0a258 [Cleanup] Remove unused dialog functions (#3588) 2025-04-23 11:46:50 -04:00
Comfy Org PR Bot
ad3d2fe2e9 1.17.9 (#3585)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-23 11:16:46 -04:00
Chenlei Hu
4c23cfbd4d [API Node] Better execution error handling (#3587)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-23 11:15:38 -04:00
Chenlei Hu
9e10e55633 [Schema] Add api_node flag to node def schema (#3586) 2025-04-23 10:47:08 -04:00
Christian Byrne
59cbe90fd3 [API Node] Reset password (#3578)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-23 10:13:17 -04:00
Christian Byrne
16bd9abccd Translate user and credits settings categories (#3580)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-23 10:12:14 -04:00
Christian Byrne
e84bdc96cf Update FAQ link (#3579) 2025-04-23 10:12:02 -04:00
Comfy Org PR Bot
a57be36d4d 1.17.8 (#3573)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-22 22:26:48 -04:00
Christian Byrne
3bd508c001 Use generalized prod config var (#3574) 2025-04-22 22:26:31 -04:00
Chenlei Hu
612500a4dc [nit] Fix typo (#3577) 2025-04-22 21:35:26 -04:00
Christian Byrne
e9723407d8 [API Node] Fix credits fetch condition (#3575) 2025-04-22 21:33:44 -04:00
Christian Byrne
a01aa39423 [API Node] Show error toast (#3576) 2025-04-22 21:32:52 -04:00
Chenlei Hu
ab94a55858 Show user avatar on top menu if available (#3572) 2025-04-22 19:47:34 -04:00
Christian Byrne
1bcf5e28d4 [API Node] Contact support button (#3571)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-22 19:47:23 -04:00
Chenlei Hu
9e247063aa [nit] Adjust font size of User Panel header (#3570) 2025-04-22 19:03:40 -04:00
Comfy Org PR Bot
cdddf359a8 1.17.7 (#3569)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-22 18:52:13 -04:00
Christian Byrne
8558f87547 [API Node] User management (#3567)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <hcl@comfy.org>
2025-04-22 18:48:45 -04:00
Chenlei Hu
262991db6b [Bug] Prevent node pasting in signin dialog (#3568) 2025-04-22 16:18:25 -04:00
Chenlei Hu
585d52e24e [Bug] Fix DOM widgets position when canvas bounding box change (#3565)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-22 15:11:54 -04:00
Chenlei Hu
b7535755f0 Revert "[nit] Remove tab index on canvas element" (#3566) 2025-04-22 12:58:37 -04:00
Chenlei Hu
6b7b0f6ec1 [nit] Remove tab index on canvas element (#3563) 2025-04-22 11:40:22 -04:00
filtered
c7318bcf0a Update node search to use litegraph LinkConnector (#3546)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-22 10:35:49 -04:00
Christian Byrne
11f909436c [Manager] Fix reactivity of node pack version options dropdown (#3557) 2025-04-22 09:45:46 -04:00
Comfy Org PR Bot
d8f4dc95bb 1.17.6 (#3558)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-04-21 22:50:43 -07:00
Christian Byrne
c1bc664edd Add command to open signin dialog (#3556)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-21 22:46:31 -07:00
Christian Byrne
e7fe2046ba Checkout on credit add (#3555) 2025-04-21 22:46:05 -07:00
Comfy Org PR Bot
bf4ad38e9b 1.17.5 (#3551)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-21 20:08:30 -04:00
Chenlei Hu
2b024bb186 [Cleanup] Remove LGraphNode.isValidWidgetLink (#3549) 2025-04-21 15:48:55 -04:00
filtered
6e5930c355 [API] Add sockets to custom widgets by default (#3548) 2025-04-21 15:24:48 -04:00
Comfy Org PR Bot
6151d487c6 [chore] Update litegraph to 0.13.8 (#3547)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-22 02:17:12 +10:00
Comfy Org PR Bot
e027a9bf44 [chore] Update litegraph to 0.13.7 (#3544)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-04-21 11:00:10 -04:00
filtered
53ee5904e8 [TS] Fix serialisation type (#3541) 2025-04-21 21:13:42 +10:00
Christian Byrne
f82bb71b1e Fix client => canvas position conversion (#3540) 2025-04-21 20:21:25 +10:00
Terry Jia
40d08a890d [3d] move default values of backgroundColor, LightIntensity, LightMaximum, LightMinimum, LightStep to settings panel (#3536)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-20 23:08:23 -04:00
Chenlei Hu
ebf3c0c049 [API Nodes] Add credit management panel UI (#3535)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-20 22:11:43 -04:00
Comfy Org PR Bot
e77d5c1f57 1.17.4 (#3533)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-20 21:04:14 -04:00
Chenlei Hu
b5c1da22db [API Nodes] Remove cost from signin required dialog (#3532) 2025-04-20 17:02:42 -04:00
Chenlei Hu
0006dd3855 [Refactor] Split custom vite plugins to files under build/plugins (#3531) 2025-04-20 14:07:45 -04:00
Terry Jia
7355209c12 build vue and primevue separately and generate importmap (#3473)
Co-authored-by: Chenlei Hu <hcl@comfy.org>
2025-04-20 13:43:22 -04:00
Chenlei Hu
2aef0a9af8 [Bug] Fix model name (#3530) 2025-04-20 13:16:13 -04:00
Chenlei Hu
b74887d543 [i18n] Use gpt-o4-mini for translation (#3529) 2025-04-20 13:13:00 -04:00
Benjamin Lu
bf4ae227b3 Add manual update check (#3504)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-04-20 12:28:59 -04:00
Christian Byrne
184bb582da [Manager] Check if node is core node when inferring node pack (#3521) 2025-04-20 12:13:50 -04:00
Christian Byrne
3bc3179763 Apply filename text replacements (e.g., %date:hh:mm:ss%) in all save nodes (#3523) 2025-04-20 12:12:28 -04:00
Comfy Org PR Bot
eb100894ce [chore] Update Comfy Registry API types from comfy-api@7bc8051 (#3524)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-04-20 12:12:14 -04:00
Comfy Org PR Bot
9a992cb14d [chore] Update litegraph to 0.13.6 (#3528)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-21 01:18:27 +10:00
Comfy Org PR Bot
133aa9bc87 [chore] Update litegraph to 0.13.5 (#3527)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-20 21:31:02 +10:00
Dr.Lt.Data
3204637e5a refine locales/ko (#3526) 2025-04-20 21:14:33 +10:00
Comfy Org PR Bot
b2cb719026 1.17.3 (#3520)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-19 22:21:18 -04:00
Christian Byrne
add805460c Fix drag and drop image with embedded workflow on Firefox (#3519)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-19 22:19:48 -04:00
Benjamin Lu
9621b8f339 [Desktop] Support Nvidia Blackwell (#3480)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-19 21:42:30 -04:00
Christian Byrne
6be381b15d Allow git describe formatted versions of node packs in workflows (#3518) 2025-04-19 21:42:01 -04:00
Christian Byrne
8afe99f48c Fix node.widgets undefined on refresh (#3515) 2025-04-20 10:21:31 +10:00
Christian Byrne
9cd11261f9 [API Node] Set auth persistence in local stoarge (#3514) 2025-04-18 23:15:07 -04:00
Comfy Org PR Bot
fbc6665ff4 1.17.2 (#3513)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-18 22:26:07 -04:00
Christian Byrne
2daa51421c Remove fetch-templates script (#3500) 2025-04-18 20:34:03 -04:00
Christian Byrne
0f175c3dc1 [Api Node] Add ToS and privacy policy links (#3511) 2025-04-18 20:21:20 -04:00
Christian Byrne
8d4263c94e Use dev firebase and switch to prod in release workflow (#3499) 2025-04-18 17:23:10 -04:00
Chenlei Hu
04580ac031 [SettingUI] Group setting menu items (#3510) 2025-04-18 16:47:32 -04:00
Chenlei Hu
cd35f1d86d [Refactor] Generate DOM widget id in constructor (#3508) 2025-04-18 13:47:16 -04:00
Chenlei Hu
5d584577fe [Bug] Fix uuid generation in insecure context (#3505) 2025-04-18 11:43:25 -04:00
filtered
10a96d1af6 [TMP] Temporarily disable hidream template test (#3502) 2025-04-18 21:34:02 +10:00
Comfy Org PR Bot
03392a3cc7 1.17.1 (#3486)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-17 22:57:57 -04:00
Christian Byrne
12576243ad Remove unused tailwind classes (#3495) 2025-04-17 22:57:47 -04:00
Christian Byrne
e2a6dc2ec8 [Templates] Use fallbacks when translating template titles and category names (#3494) 2025-04-17 22:57:03 -04:00
Chenlei Hu
2f77d74891 [Refactor] Move tree logic to setting composable (#3491) 2025-04-17 16:34:42 -04:00
Chenlei Hu
dacb59f5d3 [Refactor] Extract setting dialog logic into composables (#3490) 2025-04-17 15:55:16 -04:00
Christian Byrne
74f991ec1b Translate Wan2.1-Fun template titles (#3489)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-17 15:55:08 -04:00
Christian Byrne
6bc03a624e Add HiDream templates translations (#3485)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-17 13:46:11 -04:00
Christian Byrne
1fb015e046 Remove templates build from release process (#3481) 2025-04-17 10:41:36 -04:00
Christian Byrne
87bf2310b6 Support previewing animated image uploads (#3479)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-17 10:20:09 -04:00
filtered
f1a25989d7 Fix corrupt workflow permanently corrupts session (#3484) 2025-04-17 10:18:14 -04:00
filtered
236e3fb3e9 Log errors in generic error handler (#3482) 2025-04-17 15:16:05 +10:00
Chenlei Hu
50382827bc [API Nodes] Add auth_token_comfy_org to queue prompt request body (#3477) 2025-04-16 15:26:25 -04:00
Christian Byrne
41675805b6 [Test] Add LGraphBadge light theme browser test (#3475)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-16 15:10:14 -04:00
Makki Shizu
6321fae6f3 fix 2_pass_pose_worship zh translation (#3474) 2025-04-16 11:04:28 -04:00
Chenlei Hu
06caa21a4d [API Nodes] Setup Google/Github login (#3471) 2025-04-15 20:56:18 -04:00
172 changed files with 9202 additions and 1823 deletions

View File

@@ -29,9 +29,9 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
USE_PROD_CONFIG: 'true'
run: |
npm ci
npm run fetch-templates
npm run build
npm run zipdist
- name: Upload dist artifact

View File

@@ -30,7 +30,7 @@ jobs:
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: '49c8220be49120dbaff85f32813d854d6dff2d05'
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
- uses: actions/setup-node@v4
with:
@@ -39,7 +39,6 @@ jobs:
- name: Build ComfyUI_frontend
run: |
npm ci
npm run fetch-templates
npm run build
working-directory: ComfyUI_frontend

View File

@@ -32,7 +32,7 @@ jobs:
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'
title: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'
body: |

3
.gitignore vendored
View File

@@ -55,3 +55,6 @@ dist.zip
# Temporary repository directory
templates_repo/
# Vites timestamped config modules
vite.config.mts.timestamp-*.mjs

View File

@@ -4,13 +4,13 @@
const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
modelName: 'gpt-4',
modelName: 'gpt-4.1',
splitToken: 1024,
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora.
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
`

453
README.md
View File

@@ -67,448 +67,19 @@ The development of successive minor versions overlaps. For example, while versio
| 3 | Mar 15-21 | Released | Feature Freeze | Development | 1.1.7 through 1.1.13 (daily)<br>1.2.0 through 1.2.6 (daily) |
| 4 | Mar 22-28 | - | Released | Feature Freeze | 1.2.7 through 1.2.13 (daily)<br>1.3.0 through 1.3.6 (daily) |
## Release Summary
## Contributing
### Major features
We're building this frontend together and would love your help — no matter how you'd like to pitch in! You don't need to write code to make a difference.
<details id='feature-native-translation'>
<summary>v1.5: Native translation (i18n)</summary>
Here are some ways to get involved:
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
- **Pull Requests:** Add features, fix bugs, or improve code health. Browse [issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues) for inspiration.
- **Vote on Features:** Give a 👍 to the feature requests you care about to help us prioritize.
- **Verify Bugs:** Try reproducing reported issues and share your results (even if the bug doesn't occur!).
- **Community Support:** Hop into our [Discord](https://www.comfy.org/discord) to answer questions or get help.
- **Share & Advocate:** Tell your friends, tweet about us, or share tips to support the project.
More details available here: https://blog.comfy.org/p/native-localization-support-i18n
</details>
<details id='feature-mask-editor'>
<summary>v1.4: New mask editor</summary>
https://github.com/Comfy-Org/ComfyUI_frontend/pull/1284 implements a new mask editor.
![image](https://github.com/user-attachments/assets/f0ea6ee5-00ee-4e5d-a09c-6938e86a1f17)
</details>
<details id='feature-integrated-server-terminal'>
<summary>v1.3.22: Integrated server terminal</summary>
Press Ctrl + ` to toggle integrated terminal.
https://github.com/user-attachments/assets/eddedc6a-07a3-4a83-9475-63b3977f6d94
</details>
<details id='feature-keybinding-customization'>
<summary>v1.3.7: Keybinding customization</summary>
## Basic UI
![image](https://github.com/user-attachments/assets/c84a1609-3880-48e0-a746-011f36beda68)
## Reset button
![image](https://github.com/user-attachments/assets/4d2922da-bb4f-4f90-8017-a8e4a0db07c7)
## Edit Keybinding
![image](https://github.com/user-attachments/assets/77626b7a-cb46-48f8-9465-e03120aac66a)
![image](https://github.com/user-attachments/assets/79131a4e-75c6-4715-bd11-c6aaed887779)
[rec.webm](https://github.com/user-attachments/assets/a3984ed9-eb28-4d47-86c0-7fc3efc2b5d0)
</details>
<details id='feature-node-library-sidebar'>
<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 id='feature-queue-sidebar'>
<summary>v1.2.0: Queue/History sidebar tab</summary>
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
</details>
<details id='feature-node-search'>
<summary>v1.1.0: Node search box</summary>
#### Fuzzy search & Node preview
![image](https://github.com/user-attachments/assets/94733e32-ea4e-4a9c-b321-c1a05db48709)
#### Release link with shift
https://github.com/user-attachments/assets/a1b2b5c3-10d1-4256-b620-345de6858f25
</details>
### QoL changes
<details id='feature-nested-group'>
<summary>v1.3.32: **Litegraph** Nested group</summary>
https://github.com/user-attachments/assets/f51adeb1-028e-40af-81e4-0ac13075198a
</details>
<details id='feature-group-selection'>
<summary>v1.3.24: **Litegraph** Group selection</summary>
https://github.com/user-attachments/assets/e6230a94-411e-4fba-90cb-6c694200adaa
</details>
<details id='feature-toggle-link-visibility'>
<summary>v1.3.6: **Litegraph** Toggle link visibility</summary>
[rec.webm](https://github.com/user-attachments/assets/34e460ac-fbbc-44ef-bfbb-99a84c2ae2be)
</details>
<details id='feature-auto-widget-conversion'>
<summary>v1.3.4: **Litegraph** Auto widget to input conversion</summary>
Dropping a link of correct type on node widget will automatically convert the widget to input.
[rec.webm](https://github.com/user-attachments/assets/15cea0b0-b225-4bec-af50-2cdb16dc46bf)
</details>
<details id='feature-pan-mode'>
<summary>v1.3.4: **Litegraph** Canvas pan mode</summary>
The canvas becomes readonly in pan mode. Pan mode is activated by clicking the pan mode button on the canvas menu
or by holding the space key.
[rec.webm](https://github.com/user-attachments/assets/c7872532-a2ac-44c1-9e7d-9e03b5d1a80b)
</details>
<details id='feature-shift-drag-link-creation'>
<summary>v1.3.1: **Litegraph** Shift drag link to create a new link</summary>
[rec.webm](https://github.com/user-attachments/assets/7e73aaf9-79e2-4c3c-a26a-911cba3b85e4)
</details>
<details id='feature-optional-input-donuts'>
<summary>v1.2.62: **Litegraph** Show optional input slots as donuts</summary>
![GYEIRidb0AYGO-v](https://github.com/user-attachments/assets/e6cde0b6-654b-4afd-a117-133657a410b1)
</details>
<details id='feature-group-title-edit'>
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
https://github.com/user-attachments/assets/5bf0e2b6-8b3a-40a7-b44f-f0879e9ad26f
</details>
<details id='feature-group-selection-shortcut'>
<summary>v1.2.39: **Litegraph** Group selected nodes with Ctrl + G</summary>
https://github.com/user-attachments/assets/7805dc54-0854-4a28-8bcd-4b007fa01151
</details>
<details id='feature-node-title-edit'>
<summary>v1.2.38: **Litegraph** Double click node title to edit</summary>
https://github.com/user-attachments/assets/d61d5d0e-f200-4153-b293-3e3f6a212b30
</details>
<details id='feature-drag-multi-link'>
<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 id='feature-auto-connect-link'>
<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 id='feature-hide-text-overflow'>
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
</details>
### Developer APIs
<details>
<summary>v1.6.13: prompt/confirm/alert replacements for ComfyUI desktop</summary>
Several browser-only APIs are not available in ComfyUI desktop's electron environment.
- `window.prompt`
- `window.confirm`
- `window.alert`
Please use the following APIs as replacements.
```js
// window.prompt
window['app'].extensionManager.dialog
.prompt({
title: 'Test Prompt',
message: 'Test Prompt Message'
})
.then((value: string) => {
// Do something with the value user entered
})
```
![image](https://github.com/user-attachments/assets/c73f74d0-9bb4-4555-8d56-83f1be4a1d7e)
```js
// window.confirm
window['app'].extensionManager.dialog
.confirm({
title: 'Test Confirm',
message: 'Test Confirm Message'
})
.then((value: boolean) => {
// Do something with the value user entered
})
```
![image](https://github.com/user-attachments/assets/8dec7a42-7443-4245-85be-ceefb1116e96)
```js
// window.alert
window['app'].extensionManager.toast
.addAlert("Test Alert")
```
![image](https://github.com/user-attachments/assets/9b18bdca-76ef-4432-95de-5cd2369684f2)
</details>
<details>
<summary>v1.3.34: Register about panel badges</summary>
```js
app.registerExtension({
name: 'TestExtension1',
aboutPageBadges: [
{
label: 'Test Badge',
url: 'https://example.com',
icon: 'pi pi-box'
}
]
})
```
![image](https://github.com/user-attachments/assets/099e77ee-16ad-4141-b2fc-5e9d5075188b)
</details>
<details id='extension-api-bottom-panel-tabs'>
<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 id='extension-api-settings'>
<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 id='extension-api-commands-keybindings'>
<summary>v1.3.7: Register commands and keybindings</summary>
Extensions can call the following API to register commands and keybindings. Do
note that keybindings defined in core cannot be overwritten, and some keybindings
are reserved by the browser.
```js
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'TestCommand',
function: () => {
alert('TestCommand')
}
}
],
keybindings: [
{
combo: { key: 'k' },
commandId: 'TestCommand'
}
]
})
```
</details>
<details id='extension-api-topbar-menu'>
<summary>v1.3.1: Extension API to register custom topbar menu items</summary>
Extensions can call the following API to register custom topbar menu items.
```js
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo-id',
label: 'foo',
function: () => {
alert(1)
}
}
],
menuCommands: [
{
path: ['ext', 'ext2'],
commands: ['foo-id']
}
]
})
```
![image](https://github.com/user-attachments/assets/ae7b082f-7ce9-4549-a446-4563567102fe)
</details>
<details id='extension-api-toast'>
<summary>v1.2.27: Extension API to add toast message</summary>i
Extensions can call the following API to add toast messages.
```js
app.extensionManager.toast.add({
severity: 'info',
summary: 'Loaded!',
detail: 'Extension loaded!',
life: 3000
})
```
Documentation of all supported options can be found here: <https://primevue.org/toast/#api.toast.interfaces.ToastMessageOptions>
![image](https://github.com/user-attachments/assets/de02cd7e-cd81-43d1-a0b0-bccef92ff487)
</details>
<details id='extension-api-sidebar-tab'>
<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.
![image](https://github.com/user-attachments/assets/7bff028a-bf91-4cab-bf97-55c243b3f5e0)
</details>
<details id='extension-api-selection-toolbox'>
<summary>v1.10.9: Selection Toolbox API</summary>
Extensions can register commands that appear in the selection toolbox when specific items are selected on the canvas.
```js
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'test.selection.command',
label: 'Test Command',
icon: 'pi pi-star',
function: () => {
// Command logic here
}
}
],
// Return an array of command IDs to show in the selection toolbox
// when an item is selected
getSelectionToolboxCommands: (selectedItem) => ['test.selection.command']
})
```
The selection toolbox will display the command button when items are selected:
![Image](https://github.com/user-attachments/assets/28d91267-c0a9-4bd5-a7c4-36e8ec44c9bd)
</details>
Have another idea? Drop into Discord or open an issue, and let's chat!
## Development
@@ -557,6 +128,12 @@ After you start the dev server, you should see following logs:
Make sure your desktop machine and touch device are on the same network. On your touch device,
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
### Recommended Code Editor Configuration
This project includes `.vscode/launch.json.default` and `.vscode/settings.json.default` files with recommended launch and workspace settings for editors that use the `.vscode` directory (e.g., VS Code, Cursor, etc.).
Weve also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
### Unit Test
- `npm i` to install all dependencies

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,11 @@
{
"8": {
"inputs": {
"image": "animated_web.webp"
},
"class_type": "DevToolsLoadAnimatedImageTest",
"_meta": {
"title": "Load Animated Image"
}
}
}

View File

@@ -0,0 +1,60 @@
{
"id": "3f1fcbf9-f9de-4935-8fad-401813f61b13",
"revision": 0,
"last_node_id": 10,
"last_link_id": 4,
"nodes": [
{
"id": 9,
"type": "SaveAnimatedWEBP",
"pos": [336, 104],
"size": [210, 368],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", 6, true, 80, "default"]
},
{
"id": 10,
"type": "DevToolsLoadAnimatedImageTest",
"pos": [64, 104],
"size": [210, 316],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
},
"widgets_values": ["animated_web.webp", "image"]
}
],
"links": [[4, 10, 0, 9, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0"
},
"version": 0.4
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -463,87 +463,128 @@ export class ComfyPage {
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
async dragAndDropExternalResource(
options: {
fileName?: string
url?: string
dropPosition?: Position
} = {}
) {
const { dropPosition = { x: 100, y: 100 } } = options
const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
const filePath = this.assetPath(fileName)
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
// Read the file content
const buffer = fs.readFileSync(filePath)
const evaluateParams: {
dropPosition: Position
fileName?: string
fileType?: string
buffer?: Uint8Array | number[]
url?: string
} = { dropPosition }
// Get file type
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
// Dropping a file from the filesystem
if (fileName) {
const filePath = this.assetPath(fileName)
const buffer = fs.readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
const fileType = getFileType(fileName)
// Dropping a URL (e.g., dropping image across browser tabs in Firefox)
if (url) evaluateParams.url = url
await this.page.evaluate(
async ({ buffer, fileName, fileType, dropPosition }) => {
const file = new File([new Uint8Array(buffer)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
// Execute the drag and drop in the browser
await this.page.evaluate(async (params) => {
const dataTransfer = new DataTransfer()
const targetElement = document.elementFromPoint(
dropPosition.x,
dropPosition.y
)
if (!targetElement) {
console.error('No element found at drop position:', dropPosition)
return { success: false, error: 'No element at position' }
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: dropPosition.x,
clientY: dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
// Add file if provided
if (params.buffer && params.fileName && params.fileType) {
const file = new File(
[new Uint8Array(params.buffer)],
params.fileName,
{
type: params.fileType
}
)
dataTransfer.items.add(file)
}
// Add URL data if provided
if (params.url) {
dataTransfer.setData('text/uri-list', params.url)
dataTransfer.setData('text/x-moz-url', params.url)
}
const targetElement = document.elementFromPoint(
params.dropPosition.x,
params.dropPosition.y
)
if (!targetElement) {
console.error('No element found at drop position:', params.dropPosition)
return { success: false, error: 'No element at position' }
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: params.dropPosition.x,
clientY: params.dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
}
},
{ buffer: [...new Uint8Array(buffer)], fileName, fileType, dropPosition }
)
}
}, evaluateParams)
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position } = {}
) {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropURL(url: string, options: { dropPosition?: Position } = {}) {
return this.dragAndDropExternalResource({ url, ...options })
}
async dragNode2() {
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
await this.nextFrame()
@@ -885,7 +926,7 @@ export class ComfyPage {
async getNodeRefById(id: NodeId) {
return new NodeReference(id, this)
}
async getNodes() {
async getNodes(): Promise<LGraphNode[]> {
return await this.page.evaluate(() => {
return window['app'].graph.nodes
})

View File

@@ -341,3 +341,30 @@ test.describe('Error dialog', () => {
await expect(errorDialog).toBeVisible()
})
})
test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage
}) => {
const nodeNum = (await comfyPage.getNodes()).length
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('test_password')
await textBox.press('Control+a')
await textBox.press('Control+c')
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.dialog.showSignInDialog()
})
const input = comfyPage.page.locator('#comfy-org-sign-in-password')
await input.waitFor({ state: 'visible' })
await input.press('Control+v')
await expect(input).toHaveValue('test_password')
expect(await comfyPage.getNodes()).toHaveLength(nodeNum)
})
})

View File

@@ -24,4 +24,11 @@ test.describe('DOM Widget', () => {
await expect(firstMultiline).not.toBeVisible()
await expect(lastMultiline).not.toBeVisible()
})
test('Position update when entering focus mode', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -3,17 +3,38 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Load Workflow in Media', () => {
;[
const fileNames = [
'workflow.webp',
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow.webm',
'workflow.glb'
].forEach(async (fileName) => {
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
'workflow.glb',
'workflow.mp4',
'workflow.mov',
'workflow.m4v'
]
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
await comfyPage.dragAndDropFile(fileName)
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})
const urls = [
'https://comfyanonymous.github.io/ComfyUI_examples/hidream/hidream_dev_example.png'
]
urls.forEach(async (url) => {
test(`Load workflow from URL${url} (drop from different browser tabs)`, async ({
comfyPage
}) => {
await comfyPage.dragAndDropURL(url)
const readableName = url.split('/').pop()
await expect(comfyPage.canvas).toHaveScreenshot(
`dropped_workflow_url_${readableName}.png`
)
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -92,4 +92,20 @@ test.describe('Node badge color', () => {
'node-badge-unknown-color-palette.png'
)
})
test('Can show node badge with light color palette', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-light-color-palette.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -251,4 +251,17 @@ test.describe('Release context menu', () => {
'link-release-context-menu.png'
)
})
test('Can search and add node from context menu', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.disconnectEdge()
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.clickContextMenuItem('Search')
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 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: 91 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -1,8 +1,17 @@
import { expect } from '@playwright/test'
import fs from 'fs'
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function checkTemplateFileExists(
page: Page,
filename: string
): Promise<boolean> {
const response = await page.request.head(
new URL(`/templates/${filename}`, page.url()).toString()
)
return response.ok()
}
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
@@ -12,32 +21,32 @@ test.describe('Templates', () => {
test('should have a JSON workflow file for each template', async ({
comfyPage
}) => {
test.slow()
const templates = await comfyPage.templates.getAllTemplates()
for (const template of templates) {
const workflowPath = comfyPage.templates.getTemplatePath(
const exists = await checkTemplateFileExists(
comfyPage.page,
`${template.name}.json`
)
expect(
fs.existsSync(workflowPath),
`Missing workflow: ${template.name}`
).toBe(true)
expect(exists, `Missing workflow: ${template.name}`).toBe(true)
}
})
test('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()
const templates = await comfyPage.templates.getAllTemplates()
for (const template of templates) {
const { name, mediaSubtype, thumbnailVariant } = template
const baseMedia = `${name}-1.${mediaSubtype}`
const basePath = comfyPage.templates.getTemplatePath(baseMedia)
// Check base thumbnail
expect(
fs.existsSync(basePath),
`Missing base thumbnail: ${baseMedia}`
).toBe(true)
const baseExists = await checkTemplateFileExists(
comfyPage.page,
baseMedia
)
expect(baseExists, `Missing base thumbnail: ${baseMedia}`).toBe(true)
// Check second thumbnail for variants that need it
if (
@@ -45,9 +54,12 @@ test.describe('Templates', () => {
thumbnailVariant === 'hoverDissolve'
) {
const secondMedia = `${name}-2.${mediaSubtype}`
const secondPath = comfyPage.templates.getTemplatePath(secondMedia)
const secondExists = await checkTemplateFileExists(
comfyPage.page,
secondMedia
)
expect(
fs.existsSync(secondPath),
secondExists,
`Missing second thumbnail: ${secondMedia} required for ${thumbnailVariant}`
).toBe(true)
}
@@ -86,4 +98,48 @@ test.describe('Templates', () => {
// Expect the templates dialog to be shown
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses title field as fallback when the key is not found in locales', async ({
comfyPage
}) => {
// Capture request for the index.json
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
// Add a new template that won't have a translation pre-generated
const response = [
{
moduleName: 'default',
title: 'FALLBACK CATEGORY',
type: 'image',
templates: [
{
name: 'unknown_key_has_no_translation_available',
title: 'FALLBACK TEMPLATE NAME',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'No translations found'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
// Load the templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
// Expect the title to be used as fallback for template cards
await expect(
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
).toBeVisible()
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
})

View File

@@ -186,6 +186,105 @@ test.describe('Image widget', () => {
})
})
test.describe('Animated image widget', () => {
test('Shows preview of uploaded animated image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped.png'
)
// Wait for animation to go to next frame
await comfyPage.page.waitForTimeout(512)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the image preview to change to the next frame of the animation
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped_next_frame.png'
)
})
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node
const loadNodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = loadNodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
await comfyPage.nextFrame()
// Get the SaveAnimatedWEBP node
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
const saveAnimatedWebpNode = saveNodes[0]
if (!saveAnimatedWebpNode)
throw new Error('SaveAnimatedWEBP node not found')
// Simulate the graph executing
await comfyPage.page.evaluate(
([loadId, saveId]) => {
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await comfyPage.nextFrame()
// Wait for animation to go to next frame
await comfyPage.page.waitForTimeout(512)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the SaveAnimatedWEBP node to have an output preview
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_saved_webp.png'
)
})
})
test.describe('Load audio widget', () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_audio_widget')

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -0,0 +1,59 @@
import { Plugin } from 'vite'
/**
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
*
* This plugin addresses compatibility issues where some components or libraries
* might be using the older createElementVNode function name instead of createBaseVNode.
* It modifies the Vue vendor chunk during build to add the alias export.
*
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
*/
export function addElementVnodeExportPlugin(): Plugin {
return {
name: 'add-element-vnode-export-plugin',
renderChunk(code, chunk, _options) {
if (chunk.name.startsWith('vendor-vue')) {
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
const match = code.match(exportRegex)
if (match) {
const existingExports = match[2].trim()
const exportsArray = existingExports
.split(',')
.map((e) => e.trim())
.filter(Boolean)
const hasCreateBaseVNode = exportsArray.some((e) =>
e.startsWith('createBaseVNode')
)
const hasCreateElementVNode = exportsArray.some((e) =>
e.includes('createElementVNode')
)
if (hasCreateBaseVNode && !hasCreateElementVNode) {
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
const newCode = code.replace(exportRegex, newExportStatement)
console.log(
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
)
return { code: newCode, map: null }
} else if (!hasCreateBaseVNode) {
console.warn(
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
)
}
} else {
console.warn(
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
)
}
}
return null
}
}
}

View File

@@ -0,0 +1,82 @@
import path from 'path'
import { Plugin } from 'vite'
interface ShimResult {
code: string
exports: string[]
}
function isLegacyFile(id: string): boolean {
return (
id.endsWith('.ts') &&
(id.includes('src/extensions/core') || id.includes('src/scripts'))
)
}
function transformExports(code: string, id: string): ShimResult {
const moduleName = getModuleName(id)
const exports: string[] = []
let newCode = code
// Regex to match different types of exports
const regex =
/export\s+(const|let|var|function|class|async function)\s+([a-zA-Z$_][a-zA-Z\d$_]*)(\s|\()/g
let match
while ((match = regex.exec(code)) !== null) {
const name = match[2]
// All exports should be bind to the window object as new API endpoint.
if (exports.length == 0) {
newCode += `\nwindow.comfyAPI = window.comfyAPI || {};`
newCode += `\nwindow.comfyAPI.${moduleName} = window.comfyAPI.${moduleName} || {};`
}
newCode += `\nwindow.comfyAPI.${moduleName}.${name} = ${name};`
exports.push(
`export const ${name} = window.comfyAPI.${moduleName}.${name};\n`
)
}
return {
code: newCode,
exports
}
}
function getModuleName(id: string): string {
// Simple example to derive a module name from the file path
const parts = id.split('/')
const fileName = parts[parts.length - 1]
return fileName.replace(/\.\w+$/, '') // Remove file extension
}
export function comfyAPIPlugin(isDev: boolean): Plugin {
return {
name: 'comfy-api-plugin',
transform(code: string, id: string) {
if (isDev) return null
if (isLegacyFile(id)) {
const result = transformExports(code, id)
if (result.exports.length > 0) {
const projectRoot = process.cwd()
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
const shimFileName = relativePath.replace(/\.ts$/, '.js')
const shimComment = `// Shim for ${relativePath}\n`
this.emitFile({
type: 'asset',
fileName: shimFileName,
source: shimComment + result.exports.join('')
})
}
return {
code: result.code,
map: null // If you're not modifying the source map, return null
}
}
}
}
}

View File

@@ -0,0 +1,103 @@
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
interface VendorLibrary {
name: string
pattern: RegExp
}
/**
* Vite plugin that generates an import map for vendor chunks.
*
* This plugin creates a browser-compatible import map that maps module specifiers
* (like 'vue' or 'primevue') to their actual file locations in the build output.
* This improves module loading in modern browsers and enables better caching.
*
* The plugin:
* 1. Tracks vendor chunks during bundle generation
* 2. Creates mappings between module names and their file paths
* 3. Injects an import map script tag into the HTML head
* 4. Configures manual chunk splitting for vendor libraries
*
* @param vendorLibraries - An array of vendor libraries to split into separate chunks
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
vendorLibraries: VendorLibrary[]
): Plugin {
const importMapEntries: Record<string, string> = {}
return {
name: 'generate-import-map-plugin',
// Configure manual chunks during the build process
configResolved(config) {
if (config.build) {
// Ensure rollupOptions exists
if (!config.build.rollupOptions) {
config.build.rollupOptions = {}
}
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
config.build.rollupOptions.output = outputOptions
}
},
generateBundle(_options, bundle) {
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && !chunk.isEntry) {
// Find matching vendor library by chunk name
const vendorLib = vendorLibraries.find(
(lib) => chunk.name === `vendor-${lib.name}`
)
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
)
}
}
}
},
transformIndexHtml(html) {
if (Object.keys(importMapEntries).length === 0) {
console.warn(
'[ImportMap Plugin] No vendor chunks found to create import map.'
)
return html
}
const importMap = {
imports: importMapEntries
}
const importMapTag: HtmlTagDescriptor = {
tag: 'script',
attrs: { type: 'importmap' },
children: JSON.stringify(importMap, null, 2),
injectTo: 'head'
}
return {
html,
tags: [importMapTag]
}
}
}
}

3
build/plugins/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

1
global.d.ts vendored
View File

@@ -3,6 +3,7 @@ declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface Navigator {
/**

20
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.17.0",
"version": "1.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.17.0",
"version": "1.18.1",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.13.3",
"@comfyorg/comfyui-electron-types": "^0.4.42",
"@comfyorg/litegraph": "^0.14.2",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -476,15 +476,15 @@
}
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.4.31",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.31.tgz",
"integrity": "sha512-6tdUfrRyJ9mLlGhNxKqao0kdO+nKRLzQIbENmTK1EtJ1zhMmCp43a+pG7+kecjgp0pbfzxWKhTdCarS9A9fkqw==",
"version": "0.4.42",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.42.tgz",
"integrity": "sha512-sp3VIbpfRTcGdT3238aN7r4spRa2TtGewHlDrvT5GA2CdMOJ7Xn0Zig4rOja3MIogPhDVWGykIzbB5J08ViUSQ==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.13.3.tgz",
"integrity": "sha512-vkuVyA4RFDEXNHILGN7JlldZqtCqxfTi/T6O+Jv3KVSMSDbwkR8i7/BqAb2y6yaaxK2XktzwX0T7Q0ToNJ8G1A==",
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.14.2.tgz",
"integrity": "sha512-IGi1EyWPSRXkmNaFm4WWx0SnfLPc2DDJNVbiTfubDBwGWDRZqsA2vlCYN/1gFgw7evy02XrvcSYYuSUC4+gc+g==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.17.0",
"version": "1.18.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -25,8 +25,7 @@
"lint:fix": "eslint src --fix",
"locale": "lobe-i18n locale",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
"fetch-templates": "tsx scripts/fetch-templates.ts"
"json-schema": "tsx scripts/generate-json-schema.ts"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
@@ -72,8 +71,8 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.13.3",
"@comfyorg/comfyui-electron-types": "^0.4.42",
"@comfyorg/litegraph": "^0.14.2",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -1,24 +0,0 @@
import fs from 'fs-extra'
import { execSync } from 'node:child_process'
import path from 'node:path'
const workflowTemplatesRepo = 'https://github.com/Comfy-Org/workflow_templates'
const tempRepoDir = './templates_repo'
// Clone the repository
execSync(`git clone ${workflowTemplatesRepo} --depth 1 ${tempRepoDir}`)
// Create public/templates directory if it doesn't exist
fs.ensureDirSync('public/templates')
// Copy templates from repo to public/templates
const sourceDir = path.join(tempRepoDir, 'templates')
const targetDir = 'public/templates'
// Copy entire directory at once
fs.copySync(sourceDir, targetDir)
// Remove the temporary repository directory
fs.removeSync(tempRepoDir)
console.log('Templates fetched successfully')

View File

@@ -2,7 +2,7 @@
<router-view />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex justify-center items-center h-screen"
class="absolute inset-0 flex justify-center items-center h-[unset]"
/>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />

View File

@@ -0,0 +1,115 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
// Mock the execution store
const executionStore = reactive({
isIdle: true,
executionProgress: 0,
executingNode: null as any,
executingNodeProgress: 0
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
}))
// Mock the setting store
const settingStore = reactive({
get: vi.fn(() => 'Enabled')
})
vi.mock('@/stores/settingStore', () => ({
useSettingStore: () => settingStore
}))
// Mock the workflow store
const workflowStore = reactive({
activeWorkflow: null as any
})
vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
}))
describe('BrowserTabTitle.vue', () => {
let wrapper: ReturnType<typeof mount> | null
beforeEach(() => {
wrapper = null
// reset execution store
executionStore.isIdle = true
executionStore.executionProgress = 0
executionStore.executingNode = null as any
executionStore.executingNodeProgress = 0
// reset setting and workflow stores
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = null
// reset document title
document.title = ''
})
afterEach(() => {
wrapper?.unmount()
})
it('sets default title when idle and no workflow', () => {
wrapper = mount(BrowserTabTitle)
expect(document.title).toBe('ComfyUI')
})
it('sets workflow name as title when workflow exists and menu enabled', async () => {
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
isPersisted: true
}
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
it('adds asterisk for unsaved workflow', async () => {
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('*myFlow - ComfyUI')
})
it('disables workflow title when menu disabled', async () => {
;(settingStore.get as any).mockReturnValue('Disabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
isPersisted: true
}
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('ComfyUI')
})
it('shows execution progress when not idle without workflow', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.3
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('[30%]ComfyUI')
})
it('shows node execution title when executing a node', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.executingNodeProgress = 0.5
executionStore.executingNode = { type: 'Foo' }
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
})
})

View File

@@ -23,7 +23,7 @@ const executionText = computed(() =>
)
const settingStore = useSettingStore()
const betaMenuEnabled = computed(
const newMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -50,7 +50,7 @@ const nodeExecutionTitle = computed(() =>
const workflowTitle = computed(
() =>
executionText.value +
(betaMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
)
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)

View File

@@ -1,9 +1,9 @@
<template>
<Splitter
:key="activeSidebarTabId ?? undefined"
:key="sidebarStateKey"
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:state-key="activeSidebarTabId ?? undefined"
:state-key="sidebarStateKey"
state-storage="local"
>
<SplitterPanel
@@ -59,6 +59,10 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const unifiedWidth = computed(() =>
settingStore.get('Comfy.Sidebar.UnifiedWidth')
)
const sidebarPanelVisible = computed(
() => useSidebarTabStore().activeSidebarTab !== null
)
@@ -68,6 +72,10 @@ const bottomPanelVisible = computed(
const activeSidebarTabId = computed(
() => useSidebarTabStore().activeSidebarTabId
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value ? 'unified-sidebar' : activeSidebarTabId.value ?? ''
})
</script>
<style scoped>

View File

@@ -0,0 +1,31 @@
<template>
<div class="flex flex-col gap-3 h-full">
<div class="flex text-xs">
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
</div>
<ScrollPanel class="flex-grow h-0">
<div class="flex flex-col gap-2">
<div
v-for="nodeName in nodeNames"
:key="nodeName"
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
>
<div class="flex items-center gap-2">
<span class="text-base font-medium leading-tight">{{
nodeName
}}</span>
</div>
</div>
</div>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { nodeNames } = defineProps<{ nodeNames: string[] }>()
</script>

View File

@@ -5,7 +5,7 @@ import SelectButton from 'primevue/selectbutton'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from '../ColorCustomizationSelector.vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
describe('ColorCustomizationSelector', () => {
const colorOptions = [

View File

@@ -4,7 +4,7 @@ import InputText from 'primevue/inputtext'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import EditableText from '../EditableText.vue'
import EditableText from './EditableText.vue'
describe('EditableText', () => {
beforeAll(() => {

View File

@@ -6,7 +6,7 @@ import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from '../UrlInput.vue'
import UrlInput from './UrlInput.vue'
describe('UrlInput', () => {
beforeEach(() => {

View File

@@ -0,0 +1,39 @@
<template>
<div v-if="balanceLoading" class="flex items-center gap-1">
<div class="flex items-center gap-2">
<Skeleton shape="circle" width="1.5rem" height="1.5rem" />
</div>
<div class="flex-1"></div>
<Skeleton width="8rem" height="2rem" />
</div>
<div v-else class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<div :class="textClass">{{ formattedBalance }}</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const { textClass } = defineProps<{
textClass?: string
}>()
const authStore = useFirebaseAuthStore()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
</script>

View File

@@ -9,10 +9,10 @@
{{ t('apiNodesSignInDialog.message') }}
</div>
<ApiNodesCostBreakdown :nodes="apiNodes" :show-total="true" />
<ApiNodesList :node-names="apiNodeNames" />
<div class="flex justify-between items-center">
<Button :label="t('g.learnMore')" link />
<Button :label="t('g.learnMore')" link @click="handleLearnMoreClick" />
<div class="flex gap-2">
<Button
:label="t('g.cancel')"
@@ -30,14 +30,15 @@
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import ApiNodesCostBreakdown from '@/components/common/ApiNodesCostBreakdown.vue'
import type { ApiNodeCost } from '@/types/apiNodeTypes'
const { t } = useI18n()
const { apiNodes, onLogin, onCancel } = defineProps<{
apiNodes: ApiNodeCost[]
const { apiNodeNames, onLogin, onCancel } = defineProps<{
apiNodeNames: string[]
onLogin?: () => void
onCancel?: () => void
}>()
const handleLearnMoreClick = () => {
window.open('https://www.comfy.org/faq', '_blank')
}
</script>

View File

@@ -25,6 +25,13 @@
:label="$t('issueReport.helpFix')"
@click="showSendReport"
/>
<Button
v-if="authStore.currentUser"
v-show="!reportOpen"
text
:label="$t('issueReport.contactSupportTitle')"
@click="showContactSupport"
/>
</div>
<template v-if="reportOpen">
<Divider />
@@ -72,6 +79,8 @@ import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.v
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ReportField } from '@/types/issueReportTypes'
import {
@@ -81,6 +90,8 @@ import {
import ReportIssuePanel from './error/ReportIssuePanel.vue'
const authStore = useFirebaseAuthStore()
const { error } = defineProps<{
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
/**
@@ -123,6 +134,10 @@ const stackTraceField = computed<ReportField>(() => {
}
})
const showContactSupport = async () => {
await useCommandStore().execute('Comfy.ContactSupport')
}
onMounted(async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()

View File

@@ -9,7 +9,7 @@ import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ManagerProgressDialogContent from '../ManagerProgressDialogContent.vue'
import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue'
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
lastPanelRef: HTMLElement | null

View File

@@ -10,15 +10,21 @@
/>
<Listbox
v-model="activeCategory"
:options="categories"
:options="groupedMenuTreeNodes"
option-label="translatedLabel"
option-group-label="label"
option-group-children="children"
scroll-height="100%"
:option-disabled="
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="border-none w-full"
/>
>
<template #optiongroup>
<Divider class="my-0" />
</template>
</Listbox>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 2xl:mx-4 hidden md:flex" />
<Divider layout="horizontal" class="flex md:hidden" />
@@ -41,25 +47,10 @@
<SettingsPanel :setting-groups="sortedGroups(category)" />
</PanelTemplate>
<AboutPanel />
<Suspense>
<KeybindingPanel />
<Suspense v-for="panel in panels" :key="panel.node.key">
<component :is="panel.component" />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</template>
</Suspense>
<Suspense>
<ServerConfigPanel />
<template #fallback>
<div>Loading server config panel...</div>
<div>Loading {{ panel.node.label }} panel...</div>
</template>
</Suspense>
</TabPanels>
@@ -73,107 +64,52 @@ import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { st } from '@/i18n'
import {
SettingTreeNode,
getSettingInfo,
useSettingStore
} from '@/stores/settingStore'
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
import { useSettingUI } from '@/composables/setting/useSettingUI'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { SettingTreeNode } from '@/stores/settingStore'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
import ColorPaletteMessage from './setting/ColorPaletteMessage.vue'
import CurrentUserMessage from './setting/CurrentUserMessage.vue'
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import PanelTemplate from './setting/PanelTemplate.vue'
import SettingsPanel from './setting/SettingsPanel.vue'
const props = defineProps<{
defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config'
const { defaultPanel } = defineProps<{
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
}>()
const KeybindingPanel = defineAsyncComponent(
() => import('./setting/KeybindingPanel.vue')
)
const ExtensionPanel = defineAsyncComponent(
() => import('./setting/ExtensionPanel.vue')
)
const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
const {
activeCategory,
defaultCategory,
settingCategories,
groupedMenuTreeNodes,
panels
} = useSettingUI(defaultPanel)
const aboutPanelNode: SettingTreeNode = {
key: 'about',
label: 'About',
children: []
}
const {
searchQuery,
searchResultsCategories,
queryIsEmpty,
inSearch,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
const keybindingPanelNode: SettingTreeNode = {
key: 'keybinding',
label: 'Keybinding',
children: []
}
const extensionPanelNode: SettingTreeNode = {
key: 'extension',
label: 'Extension',
children: []
}
const serverConfigPanelNode: SettingTreeNode = {
key: 'server-config',
label: 'Server-Config',
children: []
}
/**
* Server config panel is only available in Electron. We might want to support
* it in the web version in the future.
*/
const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
return isElectron() ? [serverConfigPanelNode] : []
})
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
const { t } = useI18n()
const categories = computed<SettingTreeNode[]>(() =>
[
...settingCategories.value,
keybindingPanelNode,
extensionPanelNode,
...serverConfigPanelNodeList.value,
aboutPanelNode
].map((node) => ({
...node,
translatedLabel: t(
`settingsCategories.${normalizeI18nKey(node.label)}`,
node.label
)
}))
)
const activeCategory = ref<SettingTreeNode | null>(null)
const getDefaultCategory = () => {
return props.defaultPanel
? categories.value.find((x) => x.key === props.defaultPanel) ??
categories.value[0]
: categories.value[0]
}
onMounted(() => {
activeCategory.value = getDefaultCategory()
})
const authService = useFirebaseAuthService()
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => a.label.localeCompare(b.label))
@@ -183,98 +119,29 @@ const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
}))
}
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true))
const searchResults = computed<ISettingGroup[]>(() => {
const groupedSettings: { [key: string]: SettingParams[] } = {}
filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting)
const groupLabel = info.subCategory
if (
activeCategory.value === null ||
activeCategory.value.label === info.category
) {
if (!groupedSettings[groupLabel]) {
groupedSettings[groupLabel] = []
}
groupedSettings[groupLabel].push(setting)
}
})
return Object.entries(groupedSettings).map(([label, settings]) => ({
label,
settings
}))
})
/**
* Settings categories that contains at least one setting in search results.
*/
const searchResultsCategories = computed<Set<string>>(() => {
return new Set(
filteredSettingIds.value.map(
(id) => getSettingInfo(settingStore.settingsById[id]).category
)
)
})
const handleSearch = (query: string) => {
if (!query) {
filteredSettingIds.value = []
activeCategory.value ??= getDefaultCategory()
return
}
const queryLower = query.toLocaleLowerCase()
const allSettings = flattenTree<SettingParams>(settingRoot.value)
const filteredSettings = allSettings.filter((setting) => {
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(
`settings.${normalizeI18nKey(setting.id)}.name`,
setting.name
).toLocaleLowerCase()
const info = getSettingInfo(setting)
const translatedCategory = st(
`settingsCategories.${normalizeI18nKey(info.category)}`,
info.category
).toLocaleLowerCase()
const translatedSubCategory = st(
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
info.subCategory
).toLocaleLowerCase()
return (
idLower.includes(queryLower) ||
nameLower.includes(queryLower) ||
translatedName.includes(queryLower) ||
translatedCategory.includes(queryLower) ||
translatedSubCategory.includes(queryLower)
)
})
filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false
activeCategory.value = null
handleSearchBase(query)
activeCategory.value = query ? null : defaultCategory.value
}
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
const inSearch = computed(() => !queryIsEmpty.value && !searchInProgress.value)
// Get search results
const searchResults = computed<ISettingGroup[]>(() =>
getSearchResults(activeCategory.value)
)
const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : activeCategory.value?.label ?? ''
)
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.
watch(activeCategory, (_, oldValue) => {
if (!tabValue.value) {
activeCategory.value = oldValue
}
if (activeCategory.value?.key === 'credits') {
void authService.fetchBalance()
}
})
</script>
@@ -313,14 +180,8 @@ watch(activeCategory, (_, oldValue) => {
}
}
/* Show a separator line above the Keybinding tab */
/* This indicates the start of custom setting panels */
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding']) {
position: relative;
}
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding'])::before {
@apply content-[''] top-0 left-0 absolute w-full;
border-top: 1px solid var(--p-divider-border-color);
/* Hide the first group separator */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
}
</style>

View File

@@ -61,14 +61,22 @@
<!-- Terms -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.termsLink')
}}</span>
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.privacyLink')
}}</span
>.
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }}
</a>
.
</p>
</div>
</template>
@@ -79,6 +87,7 @@ import Divider from 'primevue/divider'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -92,33 +101,32 @@ const { onSuccess } = defineProps<{
}>()
const firebaseAuthStore = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isSignIn = ref(true)
const toggleState = () => {
isSignIn.value = !isSignIn.value
}
const signInWithGoogle = () => {
// Implement Google login
console.log(isSignIn.value)
console.log('Google login clicked')
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGoogle()
onSuccess()
}
})
const signInWithGithub = () => {
// Implement Github login
console.log(isSignIn.value)
console.log('Github login clicked')
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGithub()
onSuccess()
}
})
const signInWithEmail = async (values: SignInData | SignUpData) => {
const { email, password } = values
if (isSignIn.value) {
await firebaseAuthStore.login(email, password)
} else {
await firebaseAuthStore.register(email, password)
const signInWithEmail = wrapWithErrorHandlingAsync(
async (values: SignInData | SignUpData) => {
const { email, password } = values
if (isSignIn.value) {
await firebaseAuthStore.login(email, password)
} else {
await firebaseAuthStore.register(email, password)
}
onSuccess()
}
onSuccess()
}
)
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div class="flex flex-col w-96 p-2 gap-10">
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ $t('credits.topUp.insufficientTitle') }}
</h1>
<p class="text-base my-0">
{{ $t('credits.topUp.insufficientMessage') }}
</p>
</div>
<!-- Balance Section -->
<div class="flex justify-between items-center">
<div class="flex flex-col gap-2 w-full">
<div class="text-muted text-base">
{{ $t('credits.yourCreditBalance') }}
</div>
<div class="flex items-center justify-between w-full">
<UserCredit text-class="text-2xl" />
<Button
outlined
severity="secondary"
:label="$t('credits.topUp.seeDetails')"
icon="pi pi-arrow-up-right"
@click="handleSeeDetails"
/>
</div>
</div>
</div>
<!-- Amount Input Section -->
<div class="flex flex-col gap-2">
<span class="text-muted text-sm"
>{{ $t('credits.topUp.quickPurchase') }}:</span
>
<div class="grid grid-cols-[2fr_1fr] gap-2">
<template v-for="amount in amountOptions" :key="amount">
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<span class="text-xl">{{ amount }}</span>
</div>
<Button
:severity="
preselectedAmountOption === amount ? 'primary' : 'secondary'
"
:outlined="preselectedAmountOption !== amount"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow(amount)"
/>
</template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<InputNumber
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
pt:pc-input-text:root="w-24"
@blur="
(e: InputNumberBlurEvent) => (customAmount = Number(e.value))
"
@input="
(e: InputNumberInputEvent) => (customAmount = Number(e.value))
"
/>
</div>
<ProgressSpinner v-if="loading" class="w-8 h-8" />
<Button
v-else
:label="$t('credits.topUp.buyNow')"
severity="secondary"
outlined
@click="handleBuyNow(customAmount)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber, {
type InputNumberBlurEvent,
type InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const {
isInsufficientCredits = false,
amountOptions = [5, 10, 20, 50],
preselectedAmountOption = 10
} = defineProps<{
isInsufficientCredits?: boolean
amountOptions?: number[]
preselectedAmountOption?: number
}>()
const authStore = useFirebaseAuthStore()
const authService = useFirebaseAuthService()
const customAmount = ref<number>(100)
const didClickBuyNow = ref(false)
const loading = computed(() => authStore.loading)
const handleSeeDetails = async () => {
await authService.accessBillingPortal()
}
const handleBuyNow = async (amount: number) => {
await authService.purchaseCredits(amount)
didClickBuyNow.value = true
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authService.fetchBalance()
}
})
</script>

View File

@@ -1,5 +1,6 @@
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Checkbox from 'primevue/checkbox'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
@@ -11,7 +12,7 @@ import { createI18n } from 'vue-i18n'
import enMesages from '@/locales/en/main.json'
import { IssueReportPanelProps } from '@/types/issueReportTypes'
import ReportIssuePanel from '../ReportIssuePanel.vue'
import ReportIssuePanel from './ReportIssuePanel.vue'
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
const CUSTOM_FIELDS = [
@@ -65,7 +66,12 @@ vi.mock('@/scripts/api', () => ({
api: {
getLogs: vi.fn().mockResolvedValue('mock logs'),
getSystemStats: vi.fn().mockResolvedValue('mock stats'),
getSettings: vi.fn().mockResolvedValue('mock settings')
getSettings: vi.fn().mockResolvedValue('mock settings'),
fetchApi: vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue('')
}),
apiURL: vi.fn().mockReturnValue('https://test.com')
}
}))
@@ -136,15 +142,25 @@ vi.mock('@primevue/forms', () => ({
}
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
currentUser: {
email: 'test@example.com'
}
})
}))
describe('ReportIssuePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
const pinia = createPinia()
setActivePinia(pinia)
})
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
return mount(ReportIssuePanel, {
global: {
plugins: [PrimeVue, i18n],
plugins: [PrimeVue, i18n, createPinia()],
directives: { tooltip: Tooltip }
},
props,

View File

@@ -23,75 +23,136 @@
</div>
</template>
<div class="p-4 mt-2 border border-round surface-border shadow-1">
<div class="flex flex-row gap-3 mb-2">
<div v-for="field in fields" :key="field.value">
<FormField
v-if="field.optIn"
v-slot="$field"
:name="field.value"
class="flex space-x-1"
<div class="flex flex-col gap-6">
<FormField
v-slot="$field"
name="contactInfo"
:initial-value="authStore.currentUser?.email"
>
<div class="self-stretch inline-flex justify-start items-center">
<label for="contactInfo" class="pb-2 pt-0 opacity-80">{{
$t('issueReport.email')
}}</label>
</div>
<InputText
id="contactInfo"
v-bind="$field"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value !== ''"
severity="error"
size="small"
variant="simple"
>
<Checkbox
{{ t('issueReport.validation.invalidEmail') }}
</Message>
</FormField>
<FormField v-slot="$field" name="helpType">
<div class="flex flex-col gap-2">
<div
class="self-stretch inline-flex justify-start items-center gap-2.5"
>
<label for="helpType" class="pb-2 pt-0 opacity-80">{{
$t('issueReport.whatDoYouNeedHelpWith')
}}</label>
</div>
<Dropdown
v-bind="$field"
v-model="selection"
:input-id="field.value"
:value="field.value"
v-model="$field.value"
:options="helpTypes"
option-label="label"
option-value="value"
:placeholder="$t('issueReport.selectIssue')"
class="w-full"
/>
<label :for="field.value">{{ field.label }}</label>
<Message
v-if="$field?.error"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.selectIssueType') }}
</Message>
</div>
</FormField>
<div class="flex flex-col gap-2">
<div
class="self-stretch inline-flex justify-start items-center gap-2.5"
>
<span class="pb-2 pt-0 opacity-80">{{
$t('issueReport.whatCanWeInclude')
}}</span>
</div>
<div class="flex flex-row gap-3">
<div v-for="field in fields" :key="field.value">
<FormField
v-if="field.optIn"
v-slot="$field"
:name="field.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
v-model="selection"
:input-id="field.value"
:value="field.value"
/>
<label :for="field.value">{{ field.label }}</label>
</FormField>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<FormField v-slot="$field" name="details">
<div
class="self-stretch inline-flex justify-start items-center gap-2.5"
>
<label for="details" class="pb-2 pt-0 opacity-80">{{
$t('issueReport.describeTheProblem')
}}</label>
</div>
<Textarea
v-bind="$field"
id="details"
class="w-full"
rows="5"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
</Message>
</FormField>
</div>
</div>
<FormField v-slot="$field" class="mb-4" name="details">
<Textarea
v-bind="$field"
class="w-full"
rows="5"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
</Message>
</FormField>
<FormField v-slot="$field" name="contactInfo">
<InputText
v-bind="$field"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value !== ''"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.invalidEmail') }}
</Message>
</FormField>
<div class="flex flex-row gap-3 mt-2">
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
<FormField
v-slot="$field"
:name="checkbox.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
v-model="contactPrefs"
:input-id="checkbox.value"
:value="checkbox.value"
:disabled="
$form.contactInfo?.error || !$form.contactInfo?.value
"
/>
<label :for="checkbox.value">{{ checkbox.label }}</label>
</FormField>
<div class="flex flex-col gap-3 mt-2">
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
<FormField
v-slot="$field"
:name="checkbox.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
v-model="contactPrefs"
:input-id="checkbox.value"
:value="checkbox.value"
:disabled="
$form.contactInfo?.error || !$form.contactInfo?.value
"
/>
<label :for="checkbox.value">{{ checkbox.label }}</label>
</FormField>
</div>
</div>
</div>
</div>
@@ -108,6 +169,7 @@ import _ from 'lodash'
import cloneDeep from 'lodash/cloneDeep'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Panel from 'primevue/panel'
@@ -122,14 +184,16 @@ import {
} from '@/schemas/issueReportSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
DefaultField,
IssueReportPanelProps,
ReportField
} from '@/types/issueReportTypes'
import { isElectron } from '@/utils/envUtil'
import { generateUUID } from '@/utils/formatUtil'
const ISSUE_NAME = 'User reported issue'
const DEFAULT_ISSUE_NAME = 'User reported issue'
const props = defineProps<IssueReportPanelProps>()
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
@@ -137,6 +201,7 @@ const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
const { t } = useI18n()
const toast = useToast()
const authStore = useFirebaseAuthStore()
const selection = ref<string[]>([])
const contactPrefs = ref<string[]>([])
@@ -147,6 +212,20 @@ const contactCheckboxes = [
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
]
const helpTypes = [
{
label: t('issueReport.helpTypes.billingPayments'),
value: 'billingPayments'
},
{
label: t('issueReport.helpTypes.loginAccessIssues'),
value: 'loginAccessIssues'
},
{ label: t('issueReport.helpTypes.giveFeedback'), value: 'giveFeedback' },
{ label: t('issueReport.helpTypes.bugReport'), value: 'bugReport' },
{ label: t('issueReport.helpTypes.somethingElse'), value: 'somethingElse' }
]
const defaultFieldsConfig: ReportField[] = [
{
label: t('issueReport.systemStats'),
@@ -213,6 +292,7 @@ const createCaptureContext = async (
level: 'error',
tags: {
errorType: props.errorType,
helpType: formData.helpType,
followUp: formData.contactInfo ? formData.followUp : false,
notifyOnResolution: formData.contactInfo
? formData.notifyOnResolution
@@ -227,11 +307,24 @@ const createCaptureContext = async (
}
}
const generateUniqueTicketId = (type: string) => `${type}-${generateUUID()}`
const submit = async (event: FormSubmitEvent) => {
if (event.valid) {
try {
const captureContext = await createCaptureContext(event.values)
captureMessage(ISSUE_NAME, captureContext)
// If it's billing or access issue, generate unique id to be used by customer service ticketing
const isValidContactInfo = event.values.contactInfo?.length
const isCustomerServiceIssue =
isValidContactInfo &&
['billingPayments', 'loginAccessIssues'].includes(
event.values.helpType || ''
)
const issueName = isCustomerServiceIssue
? `ticket-${generateUniqueTicketId(event.values.helpType || '')}`
: DEFAULT_ISSUE_NAME
captureMessage(issueName, captureContext)
submitted.value = true
toast.add({
severity: 'success',

View File

@@ -74,9 +74,7 @@
</div>
</div>
</div>
<div
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
>
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel

View File

@@ -9,8 +9,8 @@ import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from '../PackVersionBadge.vue'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
import PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
const mockNodePack = {
id: 'test-pack',

View File

@@ -10,7 +10,7 @@ import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [

View File

@@ -10,7 +10,7 @@
<ProgressSpinner class="w-8 h-8 mb-2" />
{{ $t('manager.loadingVersions') }}
</div>
<div v-else-if="allVersionOptions.length === 0" class="py-2">
<div v-else-if="versionOptions.length === 0" class="py-2">
<NoResultsPlaceholder
:title="$t('g.noResultsFound')"
:message="$t('manager.tryAgainLater')"
@@ -23,7 +23,7 @@
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="allVersionOptions"
:options="versionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
>
@@ -58,11 +58,11 @@
</template>
<script setup lang="ts">
import { useAsyncState, whenever } from '@vueuse/core'
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -119,47 +119,53 @@ const fetchVersions = async () => {
return (await registryService.getPackVersions(nodePack.id)) || []
}
const {
isLoading: isLoadingVersions,
state: versions,
execute: startFetchVersions
} = useAsyncState(fetchVersions, [])
const versionOptions = ref<
{
value: string
label: string
}[]
>([])
const specialOptions = computed(() => {
const options = [
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
isLoadingVersions.value = true
// Fetch versions from the registry
const versions = await fetchVersions()
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value)
// Add Latest option
const defaultVersions = [
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
}
]
// Only include nightly option if there is a repo
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
options.push({
defaultVersions.push({
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
})
}
return options
})
const versionOptions = computed(() =>
versions.value.map((version) => ({
value: version.version,
label: version.version
}))
)
const allVersionOptions = computed(() => [
...specialOptions.value,
...versionOptions.value
])
versionOptions.value = [...defaultVersions, ...availableVersionOptions]
isLoadingVersions.value = false
}
whenever(
() => nodePack.id,
() => startFetchVersions(),
{ deep: true }
() => nodePack,
() => {
void onNodePackChange()
},
{ deep: true, immediate: true }
)
const handleSubmit = async () => {

View File

@@ -9,7 +9,7 @@ import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from '../PackEnableToggle.vue'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('lodash', () => ({

View File

@@ -16,7 +16,7 @@
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="bg-surface-100 px-1 py-0.5 rounded text-xs"
class="px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>

View File

@@ -5,7 +5,7 @@ import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from '../DescriptionTabPanel.vue'
import DescriptionTabPanel from './DescriptionTabPanel.vue'
const i18n = createI18n({
legacy: false,

View File

@@ -4,7 +4,7 @@
<div
v-for="nodeDef in mappedNodeDefs"
:key="createNodeDefKey(nodeDef)"
class="border border-surface-border rounded-lg p-4"
class="border rounded-lg p-4"
>
<NodePreview :node-def="nodeDef" class="!text-[.625rem] !min-w-full" />
</div>

View File

@@ -10,9 +10,7 @@
zIndex: maxVisible - index
}"
>
<div
class="border border-surface-border bg-surface-card rounded-lg p-0.5"
>
<div class="border rounded-lg p-0.5">
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</div>
</div>

View File

@@ -7,8 +7,8 @@ import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import GridSkeleton from '../GridSkeleton.vue'
import PackCardSkeleton from '../PackCardSkeleton.vue'
import GridSkeleton from './GridSkeleton.vue'
import PackCardSkeleton from './PackCardSkeleton.vue'
describe('GridSkeleton', () => {
const mountComponent = ({

View File

@@ -0,0 +1,164 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<div class="flex flex-col h-full">
<h2 class="text-2xl font-bold mb-2">
{{ $t('credits.credits') }}
</h2>
<Divider />
<div class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-muted">
{{ $t('credits.yourCreditBalance') }}
</h3>
<div class="flex justify-between items-center">
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else
:label="$t('credits.purchaseCredits')"
:loading="loading"
@click="handlePurchaseCreditsClick"
/>
</div>
<div class="flex flex-row items-center">
<Skeleton
v-if="balanceLoading"
width="12rem"
height="1rem"
class="text-xs"
/>
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
</div>
<Button
icon="pi pi-refresh"
text
size="small"
severity="secondary"
@click="() => authService.fetchBalance()"
/>
</div>
</div>
<div class="flex justify-between items-center mt-8">
<Button
:label="$t('credits.invoiceHistory')"
text
severity="secondary"
icon="pi pi-arrow-up-right"
:loading="loading"
@click="handleCreditsHistoryClick"
/>
</div>
<template v-if="creditHistory.length > 0">
<div class="flex-grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-base font-medium text-center',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}${{
formatMetronomeCurrency(data.amount, 'usd')
}}
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider />
<div class="flex flex-row gap-2">
<Button
:label="$t('credits.faqs')"
text
severity="secondary"
icon="pi pi-question-circle"
@click="handleFaqClick"
/>
<Button
:label="$t('credits.messageSupport')"
text
severity="secondary"
icon="pi pi-comments"
@click="handleMessageSupport"
/>
</div>
</div>
</TabPanel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserCredit from '@/components/common/UserCredit.vue'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string
timestamp: string
amount: number
isPositive: boolean
}
const { t } = useI18n()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authService = useFirebaseAuthService()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
const handlePurchaseCreditsClick = () => {
dialogService.showTopUpCreditsDialog()
}
const handleCreditsHistoryClick = async () => {
await authService.accessBillingPortal()
}
const handleMessageSupport = () => {
dialogService.showIssueReportDialog({
title: t('issueReport.contactSupportTitle'),
subtitle: t('issueReport.contactSupportDescription'),
panelProps: {
errorType: 'BillingSupport',
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
}
})
}
const handleFaqClick = () => {
window.open('https://www.comfy.org/faq', '_blank')
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -4,7 +4,7 @@ import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SettingItem from '../SettingItem.vue'
import SettingItem from './SettingItem.vue'
const i18n = createI18n({
legacy: false,

View File

@@ -0,0 +1,123 @@
<template>
<TabPanel value="User" class="user-settings-container h-full">
<div class="flex flex-col h-full">
<h2 class="text-2xl font-bold mb-2">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
<div v-if="user" class="flex flex-col gap-2">
<Avatar
v-if="user.photoURL"
:image="user.photoURL"
shape="circle"
size="large"
aria-label="User Avatar"
/>
<div class="flex flex-col gap-0.5">
<h3 class="font-medium">
{{ $t('userSettings.name') }}
</h3>
<div class="text-muted">
{{ user.displayName || $t('userSettings.notSet') }}
</div>
</div>
<div class="flex flex-col gap-0.5">
<h3 class="font-medium">
{{ $t('userSettings.email') }}
</h3>
<a :href="'mailto:' + user.email" class="hover:underline">
{{ user.email }}
</a>
</div>
<div class="flex flex-col gap-0.5">
<h3 class="font-medium">
{{ $t('userSettings.provider') }}
</h3>
<div class="text-muted flex items-center gap-1">
<i :class="providerIcon" />
{{ providerName }}
</div>
</div>
<ProgressSpinner
v-if="loading"
class="w-8 h-8 mt-4"
style="--pc-spinner-color: #000"
/>
<Button
v-else
class="mt-4 w-32"
severity="secondary"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
@click="handleSignOut"
/>
</div>
<!-- Login Section -->
<div v-else class="flex flex-col gap-4">
<p class="text-gray-600">
{{ $t('auth.login.title') }}
</p>
<Button
class="w-52"
severity="primary"
:loading="loading"
:label="$t('auth.login.signInOrSignUp')"
icon="pi pi-user"
@click="handleSignIn"
/>
</div>
</div>
</TabPanel>
</template>
<script setup lang="ts">
import Avatar from 'primevue/avatar'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const user = computed(() => authStore.currentUser)
const loading = computed(() => authStore.loading)
const providerName = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'Google'
}
if (providerId?.includes('github')) {
return 'GitHub'
}
return providerId
})
const providerIcon = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'pi pi-google'
}
if (providerId?.includes('github')) {
return 'pi pi-github'
}
return 'pi pi-user'
})
const handleSignOut = async () => {
await commandStore.execute('Comfy.User.SignOut')
}
const handleSignIn = async () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
</script>

View File

@@ -36,7 +36,10 @@
>
{{ t('auth.login.passwordLabel') }}
</label>
<span class="text-muted text-base font-medium cursor-pointer">
<span
class="text-muted text-base font-medium cursor-pointer"
@click="handleForgotPassword($form.email?.value)"
>
{{ t('auth.login.forgotPassword') }}
</span>
</div>
@@ -57,7 +60,9 @@
</div>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="w-8 h-8" />
<Button
v-else
type="submit"
:label="t('auth.login.loginButton')"
class="h-10 font-medium mt-4"
@@ -71,9 +76,17 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthService = useFirebaseAuthService()
const loading = computed(() => authStore.loading)
const { t } = useI18n()
@@ -86,4 +99,9 @@ const onSubmit = (event: FormSubmitEvent) => {
emit('submit', event.values as SignInData)
}
}
const handleForgotPassword = async (email: string) => {
if (!email) return
await firebaseAuthService.sendPasswordReset(email)
}
</script>

View File

@@ -73,6 +73,7 @@ watch(
updateWidgets()
}
)
}
},
{ immediate: true }
)
</script>

View File

@@ -19,7 +19,6 @@
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
</template>
</LiteGraphCanvasSplitterOverlay>
<TitleEditor />
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
<canvas
id="graph-canvas"
@@ -27,13 +26,20 @@
tabindex="1"
class="w-full h-full touch-none"
/>
<NodeSearchboxPopover />
<SelectionOverlay v-if="selectionToolboxEnabled">
<SelectionToolbox />
</SelectionOverlay>
<NodeTooltip v-if="tooltipEnabled" />
<NodeBadge />
<DomWidgets />
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
canvasStore.canvas to be initialized. -->
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionOverlay v-if="selectionToolboxEnabled">
<SelectionToolbox />
</SelectionOverlay>
<DomWidgets />
</template>
</template>
<script setup lang="ts">
@@ -78,7 +84,9 @@ import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const emit = defineEmits(['ready'])
const emit = defineEmits<{
ready: []
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()

View File

@@ -16,7 +16,7 @@
import { LGraphGroup, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { LiteGraphCanvasEvent } from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { ref, watch } from 'vue'
import { type CSSProperties, computed, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
@@ -28,7 +28,12 @@ const settingStore = useSettingStore()
const showInput = ref(false)
const editedTitle = ref('')
const { style: inputStyle, updatePosition } = useAbsolutePosition()
const { style: inputPositionStyle, updatePosition } = useAbsolutePosition()
const inputFontStyle = ref<CSSProperties>({})
const inputStyle = computed<CSSProperties>(() => ({
...inputPositionStyle.value,
...inputFontStyle.value
}))
const titleEditorStore = useTitleEditorStore()
const canvasStore = useCanvasStore()
@@ -59,23 +64,19 @@ watch(
if (target instanceof LGraphGroup) {
const group = target
updatePosition(
{
pos: group.pos,
size: [group.size[0], group.titleHeight]
},
{ fontSize: `${group.font_size * scale}px` }
)
updatePosition({
pos: group.pos,
size: [group.size[0], group.titleHeight]
})
inputFontStyle.value = { fontSize: `${group.font_size * scale}px` }
} else if (target instanceof LGraphNode) {
const node = target
const [x, y] = node.getBounding()
updatePosition(
{
pos: [x, y],
size: [node.width, LiteGraph.NODE_TITLE_HEIGHT]
},
{ fontSize: `${12 * scale}px` }
)
updatePosition({
pos: [x, y],
size: [node.width, LiteGraph.NODE_TITLE_HEIGHT]
})
inputFontStyle.value = { fontSize: `${12 * scale}px` }
}
}
)

View File

@@ -37,13 +37,14 @@ const { widget, widgetState } = defineProps<{
}>()
const emit = defineEmits<{
(e: 'update:widgetValue', value: string | object): void
'update:widgetValue': [value: string | object]
}>()
const widgetElement = ref<HTMLElement | undefined>()
const { style: positionStyle, updatePositionWithTransform } =
useAbsolutePosition()
const { style: positionStyle, updatePosition } = useAbsolutePosition({
useTransform: true
})
const { style: clippingStyle, updateClipPath } = useDomClipping()
const style = computed<CSSProperties>(() => ({
...positionStyle.value,
@@ -94,7 +95,7 @@ const updateDomClipping = () => {
watch(
() => widgetState,
(newState) => {
updatePositionWithTransform(newState)
updatePosition(newState)
if (enableDomClipping.value) {
updateDomClipping()
}

View File

@@ -35,9 +35,8 @@
<script setup lang="ts">
import {
CUDA_TORCH_URL,
NIGHTLY_CPU_TORCH_URL,
TorchDeviceType
TorchDeviceType,
TorchMirrorUrl
} from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
@@ -61,14 +60,14 @@ const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
case 'mps':
return {
settingId,
mirror: NIGHTLY_CPU_TORCH_URL,
fallbackMirror: NIGHTLY_CPU_TORCH_URL
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: CUDA_TORCH_URL,
fallbackMirror: CUDA_TORCH_URL
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:

View File

@@ -21,9 +21,9 @@
<Slider
v-model="lightIntensity"
class="w-full"
:min="1"
:max="20"
:step="1"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
@change="updateLightIntensity"
/>
</div>
@@ -38,6 +38,7 @@ import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { t } from '@/i18n'
import { useSettingStore } from '@/stores/settingStore'
const vTooltip = Tooltip
@@ -54,6 +55,16 @@ const lightIntensity = ref(props.lightIntensity)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const showLightIntensity = ref(false)
const lightIntensityMaximum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMaximum'
)
const lightIntensityMinimum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMinimum'
)
const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
watch(
() => props.lightIntensity,
(newValue) => {

View File

@@ -33,43 +33,41 @@
</template>
<script setup lang="ts">
import { LiteGraph } from '@comfyorg/litegraph'
import type {
ConnectingLink,
LiteGraphCanvasEvent,
Vector2
import {
LGraphNode,
LiteGraph,
LiteGraphCanvasEvent
} from '@comfyorg/litegraph'
import type { OriginalEvent } from '@comfyorg/litegraph/dist/types/events'
import { Point } from '@comfyorg/litegraph/dist/interfaces'
import type { CanvasPointerEvent } from '@comfyorg/litegraph/dist/types/events'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Dialog from 'primevue/dialog'
import { computed, ref, toRaw, watchEffect } from 'vue'
import { computed, ref, toRaw, watch, watchEffect } from 'vue'
import { useLitegraphService } from '@/services/litegraphService'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { ConnectingLinkImpl } from '@/types/litegraphTypes'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBox from './NodeSearchBox.vue'
let triggerEvent: CanvasPointerEvent | null = null
let listenerController: AbortController | null = null
let disconnectOnReset = false
const settingStore = useSettingStore()
const litegraphService = useLitegraphService()
const { visible } = storeToRefs(useSearchBoxStore())
const dismissable = ref(true)
const triggerEvent = ref<LiteGraphCanvasEvent | null>(null)
const getNewNodeLocation = (): Vector2 => {
if (!triggerEvent.value) {
return litegraphService.getCanvasCenter()
}
const originalEvent = (triggerEvent.value.detail as OriginalEvent)
.originalEvent
return [originalEvent.canvasX, originalEvent.canvasY]
const getNewNodeLocation = (): Point => {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
: litegraphService.getCanvasCenter()
}
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
const addFilter = (filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) => {
@@ -88,35 +86,30 @@ const clearFilters = () => {
const closeDialog = () => {
visible.value = false
}
const canvasStore = useCanvasStore()
const addNode = (nodeDef: ComfyNodeDefImpl) => {
if (!triggerEvent) {
console.warn('The trigger event was undefined when addNode was called.')
return
}
disconnectOnReset = false
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
const eventDetail = triggerEvent.value?.detail
if (eventDetail && eventDetail.subType === 'empty-release') {
// @ts-expect-error fixme ts strict error
eventDetail.linkReleaseContext.links.forEach((link: ConnectingLink) => {
ConnectingLinkImpl.createFromPlainObject(link).connectTo(node)
})
}
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
// TODO: This is not robust timing-wise.
// PrimeVue complains about the dialog being closed before the event selecting
// item is fully processed.
window.setTimeout(() => {
closeDialog()
}, 100)
window.requestAnimationFrame(closeDialog)
}
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)
const showSearchBox = (e: LiteGraphCanvasEvent) => {
const detail = e.detail as OriginalEvent
const showSearchBox = (e: CanvasPointerEvent) => {
if (newSearchBoxEnabled.value) {
if (detail.originalEvent?.pointerType === 'touch') {
if (e.pointerType === 'touch') {
setTimeout(() => {
showNewSearchBox(e)
}, 128)
@@ -124,26 +117,23 @@ const showSearchBox = (e: LiteGraphCanvasEvent) => {
showNewSearchBox(e)
}
} else {
// @ts-expect-error fixme ts strict error
canvasStore.canvas.showSearchBox(detail.originalEvent)
canvasStore.getCanvas().showSearchBox(e)
}
}
const getFirstLink = () =>
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
const nodeDefStore = useNodeDefStore()
const showNewSearchBox = (e: LiteGraphCanvasEvent) => {
if (e.detail.subType === 'empty-release') {
const links = e.detail.linkReleaseContext.links
if (links.length === 0) {
console.warn('Empty release with no links! This should never happen')
return
}
const firstLink = ConnectingLinkImpl.createFromPlainObject(links[0])
const showNewSearchBox = (e: CanvasPointerEvent) => {
const firstLink = getFirstLink()
if (firstLink) {
const filter =
firstLink.releaseSlotType === 'input'
firstLink.toType === 'input'
? nodeDefStore.nodeSearchService.inputTypeFilter
: nodeDefStore.nodeSearchService.outputTypeFilter
const dataType = firstLink.type?.toString() ?? ''
const dataType = firstLink.fromSlot.type?.toString() ?? ''
addFilter({
filterDef: filter,
value: dataType
@@ -151,7 +141,7 @@ const showNewSearchBox = (e: LiteGraphCanvasEvent) => {
}
visible.value = true
triggerEvent.value = e
triggerEvent = e
// Prevent the dialog from being dismissed immediately
dismissable.value = false
@@ -160,88 +150,129 @@ const showNewSearchBox = (e: LiteGraphCanvasEvent) => {
}, 300)
}
const showContextMenu = (e: LiteGraphCanvasEvent) => {
if (e.detail.subType !== 'empty-release') {
return
}
const showContextMenu = (e: CanvasPointerEvent) => {
const firstLink = getFirstLink()
if (!firstLink) return
const links = e.detail.linkReleaseContext.links
if (links.length === 0) {
console.warn('Empty release with no links! This should never happen')
return
}
const firstLink = ConnectingLinkImpl.createFromPlainObject(links[0])
const mouseEvent = e.detail.originalEvent
const { node, fromSlot, toType } = firstLink
const commonOptions = {
e: mouseEvent,
e,
allow_searchbox: true,
showSearchBox: () => showSearchBox(e)
showSearchBox: () => {
cancelResetOnContextClose()
showSearchBox(e)
}
}
const connectionOptions = firstLink.output
? {
nodeFrom: firstLink.node,
slotFrom: firstLink.output,
afterRerouteId: firstLink.afterRerouteId
}
: {
nodeTo: firstLink.node,
slotTo: firstLink.input,
afterRerouteId: firstLink.afterRerouteId
}
// @ts-expect-error fixme ts strict error
canvasStore.canvas.showConnectionMenu({
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot }
: { nodeTo: node, slotTo: fromSlot }
const canvas = canvasStore.getCanvas()
const menu = canvas.showConnectionMenu({
...connectionOptions,
...commonOptions
})
if (!menu) {
console.warn('No menu was returned from showConnectionMenu')
return
}
triggerEvent = e
listenerController = new AbortController()
const { signal } = listenerController
const options = { once: true, signal }
// Connect the node after it is created via context menu
useEventListener(
canvas.canvas,
'connect-new-default-node',
(createEvent) => {
if (!(createEvent instanceof CustomEvent))
throw new Error('Invalid event')
const node: unknown = createEvent.detail?.node
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
disconnectOnReset = false
createEvent.preventDefault()
canvas.linkConnector.connectToNode(node, e)
},
options
)
// Reset when the context menu is closed
const cancelResetOnContextClose = useEventListener(
menu.controller.signal,
'abort',
reset,
options
)
}
// Disable litegraph's default behavior of release link and search box.
const canvasStore = useCanvasStore()
watchEffect(() => {
if (canvasStore.canvas) {
LiteGraph.release_link_on_empty_shows_menu = false
canvasStore.canvas.allow_searchbox = false
}
const { canvas } = canvasStore
if (!canvas) return
LiteGraph.release_link_on_empty_shows_menu = false
canvas.allow_searchbox = false
useEventListener(
canvas.linkConnector.events,
'dropped-on-canvas',
handleDroppedOnCanvas
)
})
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
if (e.detail.subType === 'empty-double-click') {
showSearchBox(e)
} else if (e.detail.subType === 'empty-release') {
handleCanvasEmptyRelease(e)
showSearchBox(e.detail.originalEvent)
} else if (e.detail.subType === 'group-double-click') {
const group = e.detail.group
const [_, y] = group.pos
const relativeY = e.detail.originalEvent.canvasY - y
// Show search box if the click is NOT on the title bar
if (relativeY > group.titleHeight) {
showSearchBox(e)
showSearchBox(e.detail.originalEvent)
}
}
}
const linkReleaseAction = computed(() => {
return settingStore.get('Comfy.LinkRelease.Action')
})
const linkReleaseAction = computed(() =>
settingStore.get('Comfy.LinkRelease.Action')
)
const linkReleaseActionShift = computed(() => {
return settingStore.get('Comfy.LinkRelease.ActionShift')
})
const linkReleaseActionShift = computed(() =>
settingStore.get('Comfy.LinkRelease.ActionShift')
)
const handleCanvasEmptyRelease = (e: LiteGraphCanvasEvent) => {
const detail = e.detail as OriginalEvent
const shiftPressed = detail.originalEvent.shiftKey
// Prevent normal LinkConnector reset (called by CanvasPointer.finally)
const preventDefault = (e: Event) => e.preventDefault()
const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => {
e.preventDefault()
const action = shiftPressed
const canvas = canvasStore.getCanvas()
canvas.linkConnector.state.snapLinksPos = [e.detail.canvasX, e.detail.canvasY]
useEventListener(canvas.linkConnector.events, 'reset', preventDefault, {
once: true
})
}
const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => {
disconnectOnReset = true
const action = e.detail.shiftKey
? linkReleaseActionShift.value
: linkReleaseAction.value
switch (action) {
case LinkReleaseTriggerAction.SEARCH_BOX:
showSearchBox(e)
cancelNextReset(e)
showSearchBox(e.detail)
break
case LinkReleaseTriggerAction.CONTEXT_MENU:
showContextMenu(e)
cancelNextReset(e)
showContextMenu(e.detail)
break
case LinkReleaseTriggerAction.NO_ACTION:
default:
@@ -249,6 +280,25 @@ const handleCanvasEmptyRelease = (e: LiteGraphCanvasEvent) => {
}
}
// Resets litegraph state
const reset = () => {
listenerController?.abort()
listenerController = null
triggerEvent = null
const canvas = canvasStore.getCanvas()
canvas.linkConnector.events.removeEventListener('reset', preventDefault)
if (disconnectOnReset) canvas.linkConnector.disconnectLinks()
canvas.linkConnector.reset()
canvas.setDirty(true, true)
}
// Reset connecting links when the search box is closed
watch(visible, () => {
if (!visible.value) reset()
})
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
</script>

View File

@@ -5,7 +5,7 @@ import OverlayBadge from 'primevue/overlaybadge'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import SidebarIcon from '../SidebarIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
type SidebarIconProps = {
icon: string

View File

@@ -52,21 +52,15 @@
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1">
<h3
class="line-clamp-1 text-lg font-normal text-surface-900 dark:text-surface-100"
:title="title"
>
<h3 class="line-clamp-1 text-lg font-normal" :title="title">
{{ title }}
</h3>
<p
class="line-clamp-2 text-sm text-surface-600 dark:text text-muted"
:title="description"
>
<p class="line-clamp-2 text-sm text-muted" :title="description">
{{ description }}
</p>
</div>
<div
class="flex md:hidden xl:flex items-center justify-center ml-4 w-10 h-10 rounded-full bg-surface-100"
class="flex md:hidden xl:flex items-center justify-center ml-4 w-10 h-10 rounded-full"
>
<i class="pi pi-angle-right text-2xl" />
</div>
@@ -123,12 +117,13 @@ const overlayThumbnailSrc = computed(() =>
)
const title = computed(() => {
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? st(
`templateWorkflows.template.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
template.name
fallback
)
: template.name ?? `${sourceModule} Template`
: fallback
})
const description = computed(() => template.description.replace(/[-_]/g, ' '))

View File

@@ -10,11 +10,8 @@
>
<slot />
</div>
<div
v-else
class="w-full h-full flex items-center justify-center bg-surface-card"
>
<i class="pi pi-file text-4xl text-surface-600" />
<div v-else class="w-full h-full flex items-center justify-center">
<i class="pi pi-file text-4xl" />
</div>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<!-- A button that shows current authenticated user's avatar -->
<template>
<div>
<Button
v-if="isAuthenticated"
class="user-profile-button p-1"
severity="secondary"
text
aria-label="user profile"
@click="popover?.toggle($event)"
>
<div
class="flex items-center rounded-full bg-[var(--p-content-background)]"
>
<Avatar
:image="photoURL"
:icon="photoURL ? undefined : 'pi pi-user'"
shape="circle"
aria-label="User Avatar"
/>
<i class="pi pi-chevron-down px-1" :style="{ fontSize: '0.5rem' }" />
</div>
</Button>
<Popover ref="popover" :show-arrow="false">
<CurrentUserPopover />
</Popover>
</div>
</template>
<script setup lang="ts">
import Avatar from 'primevue/avatar'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import CurrentUserPopover from './CurrentUserPopover.vue'
const authStore = useFirebaseAuthStore()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const photoURL = computed<string | undefined>(
() => authStore.currentUser?.photoURL ?? undefined
)
</script>

View File

@@ -0,0 +1,80 @@
<!-- A popover that shows current user information and actions -->
<template>
<div class="current-user-popover w-72">
<!-- User Info Section -->
<div class="p-3">
<div class="flex flex-col items-center">
<Avatar
class="mb-3"
:image="user?.photoURL ?? undefined"
:icon="user?.photoURL ? undefined : 'pi pi-user'"
shape="circle"
size="large"
aria-label="User Avatar"
/>
<!-- User Details -->
<h3 class="text-lg font-semibold truncate my-0 mb-1">
{{ user?.displayName || $t('g.user') }}
</h3>
<p v-if="user?.email" class="text-sm text-muted truncate my-0">
{{ user.email }}
</p>
</div>
</div>
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('userSettings.title')"
icon="pi pi-cog"
text
fluid
severity="secondary"
@click="handleOpenUserSettings"
/>
<Divider class="my-2" />
<div class="w-full flex flex-col gap-2 p-2">
<div class="text-muted text-sm">
{{ $t('credits.yourCreditBalance') }}
</div>
<div class="flex justify-between items-center">
<UserCredit text-class="text-2xl" />
<Button :label="$t('credits.topUp.topUp')" @click="handleTopUp" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Avatar from 'primevue/avatar'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { computed, onMounted } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const authService = useFirebaseAuthService()
const dialogService = useDialogService()
const user = computed(() => authStore.currentUser)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
}
const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
}
onMounted(() => {
void authService.fetchBalance()
})
</script>

View File

@@ -12,6 +12,7 @@
</div>
<div ref="menuRight" class="comfyui-menu-right flex-shrink-0" />
<Actionbar />
<CurrentUserButton class="flex-shrink-0" />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
@@ -44,6 +45,7 @@ import { computed, onMounted, provide, ref } from 'vue'
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
@@ -57,6 +59,7 @@ import {
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)

View File

@@ -1,7 +1,7 @@
import type { Size, Vector2 } from '@comfyorg/litegraph'
import { CSSProperties, ref } from 'vue'
import { CSSProperties, computed, ref } from 'vue'
import { app } from '@/scripts/app'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { useCanvasStore } from '@/stores/graphStore'
export interface PositionConfig {
@@ -13,70 +13,56 @@ export interface PositionConfig {
scale?: number
}
export function useAbsolutePosition() {
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
const { useTransform = false } = options
const canvasStore = useCanvasStore()
const style = ref<CSSProperties>({
position: 'fixed',
left: '0px',
top: '0px',
width: '0px',
height: '0px'
const lgCanvas = canvasStore.getCanvas()
const { canvasPosToClientPos } = useCanvasPositionConversion(
lgCanvas.canvas,
lgCanvas
)
const position = ref<PositionConfig>({
pos: [0, 0],
size: [0, 0]
})
const style = computed<CSSProperties>(() => {
const { pos, size, scale = lgCanvas.ds.scale } = position.value
const [left, top] = canvasPosToClientPos(pos)
const [width, height] = size
return useTransform
? {
position: 'fixed',
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
}
: {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${width * scale}px`,
height: `${height * scale}px`
}
})
/**
* Update the position of the element on the litegraph canvas.
*
* @param config
* @param extraStyle
*/
const updatePosition = (
config: PositionConfig,
extraStyle?: CSSProperties
) => {
const { pos, size, scale = canvasStore.canvas?.ds?.scale ?? 1 } = config
const [left, top] = app.canvasPosToClientPos(pos)
const [width, height] = size
style.value = {
...style.value,
left: `${left}px`,
top: `${top}px`,
width: `${width * scale}px`,
height: `${height * scale}px`,
...extraStyle
}
}
/**
* Update the position and size of the element on the litegraph canvas,
* with CSS transform scaling applied.
*
* @param config
* @param extraStyle
*/
const updatePositionWithTransform = (
config: PositionConfig,
extraStyle?: CSSProperties
) => {
const { pos, size, scale = canvasStore.canvas?.ds?.scale ?? 1 } = config
const [left, top] = app.canvasPosToClientPos(pos)
const [width, height] = size
style.value = {
...style.value,
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
...extraStyle
}
const updatePosition = (config: PositionConfig) => {
position.value = config
}
return {
style,
updatePosition,
updatePositionWithTransform
updatePosition
}
}

View File

@@ -16,8 +16,8 @@ export const useCanvasPositionConversion = (
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
const { offset, scale } = lgCanvas.ds
return [
(pos[0] - left.value) / scale + offset[0],
(pos[1] - top.value) / scale + offset[1]
(pos[0] - left.value) / scale - offset[0],
(pos[1] - top.value) / scale - offset[1]
]
}

View File

@@ -3,6 +3,7 @@ import type { IWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
import { createImageHost } from '@/scripts/ui/imagePreview'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
/**
* Composable for handling animated image previews in nodes
@@ -42,6 +43,16 @@ export function useNodeAnimatedImage() {
widget.serialize = false
widget.serializeValue = () => undefined
widget.options.host.updateImages(node.imgs)
widget.computeLayoutSize = () => {
const img = widget.options.host.getCurrentImage()
if (!img) return { minHeight: 0, minWidth: 0 }
return fitDimensionsToNodeWidth(
img.naturalWidth,
img.naturalHeight,
node.size?.[0] || 0
)
}
}
}

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
const VIDEO_WIDGET_NAME = 'video-preview'
const VIDEO_DEFAULT_OPTIONS = {
@@ -131,12 +132,15 @@ export const useNodeVideo = (node: LGraphNode) => {
let minWidth = DEFAULT_VIDEO_SIZE
const setMinDimensions = (video: HTMLVideoElement) => {
const intrinsicAspectRatio = video.videoWidth / video.videoHeight
if (!intrinsicAspectRatio || isNaN(intrinsicAspectRatio)) return
const { minHeight: calculatedHeight, minWidth: calculatedWidth } =
fitDimensionsToNodeWidth(
video.videoWidth,
video.videoHeight,
node.size?.[0] || DEFAULT_VIDEO_SIZE
)
// Set min. height s.t. video spans node's x-axis while maintaining aspect ratio
minWidth = node.size?.[0] || DEFAULT_VIDEO_SIZE
minHeight = Math.max(minWidth / intrinsicAspectRatio, 64)
minWidth = calculatedWidth
minHeight = calculatedHeight
}
const loadElement = (url: string): Promise<HTMLVideoElement | null> =>

View File

@@ -5,6 +5,8 @@ import { useNodePacks } from '@/composables/nodePack/useNodePacks'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
@@ -22,6 +24,8 @@ const CORE_NODES_PACK_NAME = 'comfy-core'
* associated node packs from the registry
*/
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { search } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
@@ -36,6 +40,13 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
return undefined
}
/**
* Clean the version string to be used in the registry search.
* Removes the leading 'v' and trims whitespace and line terminators.
*/
const cleanVersionString = (version: string) =>
version.replace(/^v/, '').trim()
/**
* Infer the pack for a node by searching the registry for packs that have nodes
* with the same name.
@@ -44,6 +55,22 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
node: LGraphNode
): Promise<WorkflowPack | undefined> => {
const nodeName = node.type
// Check if node is a core node
const nodeDef = nodeDefStore.nodeDefsByName[nodeName]
if (nodeDef?.nodeSource.type === 'core') {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
return {
id: CORE_NODES_PACK_NAME,
version:
systemStatsStore.systemStats?.system?.comfyui_version ??
SelectedVersion.NIGHTLY
}
}
// Search the registry for non-core nodes
const searchResult = await search.call({
comfy_node_search: nodeName,
limit: 1
@@ -70,7 +97,9 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
if (packId === CORE_NODES_PACK_NAME) return undefined
const version =
typeof node.properties.ver === 'string' ? node.properties.ver : undefined
typeof node.properties.ver === 'string'
? cleanVersionString(node.properties.ver)
: undefined
return {
id: packId,

View File

@@ -0,0 +1,122 @@
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import {
SettingTreeNode,
getSettingInfo,
useSettingStore
} from '@/stores/settingStore'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
export function useSettingSearch() {
const settingStore = useSettingStore()
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true))
/**
* Settings categories that contains at least one setting in search results.
*/
const searchResultsCategories = computed<Set<string>>(() => {
return new Set(
filteredSettingIds.value.map(
(id) => getSettingInfo(settingStore.settingsById[id]).category
)
)
})
/**
* Check if the search query is empty
*/
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
/**
* Check if we're in search mode
*/
const inSearch = computed(
() => !queryIsEmpty.value && !searchInProgress.value
)
/**
* Handle search functionality
*/
const handleSearch = (query: string) => {
if (!query) {
filteredSettingIds.value = []
return
}
const queryLower = query.toLocaleLowerCase()
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(
`settings.${normalizeI18nKey(setting.id)}.name`,
setting.name
).toLocaleLowerCase()
const info = getSettingInfo(setting)
const translatedCategory = st(
`settingsCategories.${normalizeI18nKey(info.category)}`,
info.category
).toLocaleLowerCase()
const translatedSubCategory = st(
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
info.subCategory
).toLocaleLowerCase()
return (
idLower.includes(queryLower) ||
nameLower.includes(queryLower) ||
translatedName.includes(queryLower) ||
translatedCategory.includes(queryLower) ||
translatedSubCategory.includes(queryLower)
)
})
filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false
}
/**
* Get search results grouped by category
*/
const getSearchResults = (
activeCategory: SettingTreeNode | null
): ISettingGroup[] => {
const groupedSettings: { [key: string]: SettingParams[] } = {}
filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting)
const groupLabel = info.subCategory
if (activeCategory === null || activeCategory.label === info.category) {
if (!groupedSettings[groupLabel]) {
groupedSettings[groupLabel] = []
}
groupedSettings[groupLabel].push(setting)
}
})
return Object.entries(groupedSettings).map(([label, settings]) => ({
label,
settings
}))
}
return {
searchQuery,
filteredSettingIds,
searchInProgress,
searchResultsCategories,
queryIsEmpty,
inSearch,
handleSearch,
getSearchResults
}
}

View File

@@ -0,0 +1,200 @@
import {
type Component,
computed,
defineAsyncComponent,
onMounted,
ref
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import type { SettingParams } from '@/types/settingTypes'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
interface SettingPanelItem {
node: SettingTreeNode
component: Component
}
export function useSettingUI(
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
) {
const { t } = useI18n()
const firebaseAuthStore = useFirebaseAuthStore()
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
(setting: SettingParams) => setting.type !== 'hidden'
),
(setting: SettingParams) => setting.category || setting.id.split('.')
)
const floatingSettings = (root.children ?? []).filter((node) => node.leaf)
if (floatingSettings.length) {
root.children = (root.children ?? []).filter((node) => !node.leaf)
root.children.push({
key: 'Other',
label: 'Other',
leaf: false,
children: floatingSettings
})
}
return root
})
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
// Define panel items
const aboutPanel: SettingPanelItem = {
node: {
key: 'about',
label: 'About',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/AboutPanel.vue')
)
}
const creditsPanel: SettingPanelItem = {
node: {
key: 'credits',
label: 'Credits',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
)
}
const userPanel: SettingPanelItem = {
node: {
key: 'user',
label: 'User',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/UserPanel.vue')
)
}
const keybindingPanel: SettingPanelItem = {
node: {
key: 'keybinding',
label: 'Keybinding',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/KeybindingPanel.vue')
)
}
const extensionPanel: SettingPanelItem = {
node: {
key: 'extension',
label: 'Extension',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/ExtensionPanel.vue')
)
}
const serverConfigPanel: SettingPanelItem = {
node: {
key: 'server-config',
label: 'Server-Config',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/ServerConfigPanel.vue')
)
}
const panels = computed<SettingPanelItem[]>(() =>
[
aboutPanel,
creditsPanel,
userPanel,
keybindingPanel,
extensionPanel
].filter((panel) => panel.component)
)
/**
* The default category to show when the dialog is opened.
*/
const defaultCategory = computed<SettingTreeNode>(() => {
if (!defaultPanel) return settingCategories.value[0]
// Search through all groups in groupedMenuTreeNodes
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === defaultPanel)
if (found) return found
}
return settingCategories.value[0]
})
const translateCategory = (node: SettingTreeNode) => ({
...node,
translatedLabel: t(
`settingsCategories.${normalizeI18nKey(node.label)}`,
node.label
)
})
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Account settings - only show credits when user is authenticated
{
key: 'account',
label: 'Account',
children: [
userPanel.node,
...(firebaseAuthStore.isAuthenticated ? [creditsPanel.node] : [])
].map(translateCategory)
},
// Normal settings stored in the settingStore
{
key: 'settings',
label: 'Application Settings',
children: settingCategories.value.map(translateCategory)
},
// Special settings such as about, keybinding, extension, server-config
{
key: 'specialSettings',
label: 'Special Settings',
children: [
keybindingPanel.node,
extensionPanel.node,
aboutPanel.node,
...(isElectron() ? [serverConfigPanel.node] : [])
].map(translateCategory)
}
])
onMounted(() => {
activeCategory.value = defaultCategory.value
})
return {
panels,
activeCategory,
defaultCategory,
groupedMenuTreeNodes,
settingCategories
}
}

View File

@@ -29,12 +29,10 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
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
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX,
loc.clientY + LiteGraph.NODE_TITLE_HEIGHT
])
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data

View File

@@ -13,6 +13,7 @@ import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
@@ -31,6 +32,8 @@ export function useCoreCommands(): ComfyCommand[] {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthService = useFirebaseAuthService()
const toastStore = useToastStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const getSelectedNodes = (): LGraphNode[] => {
@@ -55,8 +58,6 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
const commonProps = { source: 'System' }
const commands = [
{
id: 'Comfy.NewBlankWorkflow',
@@ -184,7 +185,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Interrupt',
function: async () => {
await api.interrupt()
useToastStore().add({
toastStore.add({
severity: 'info',
summary: t('g.interrupted'),
detail: t('toastMessages.interrupted'),
@@ -198,7 +199,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Clear Pending Tasks',
function: async () => {
await useQueueStore().clear(['queue'])
useToastStore().add({
toastStore.add({
severity: 'info',
summary: t('g.confirmed'),
detail: t('toastMessages.pendingTasksDeleted'),
@@ -246,7 +247,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Fit view to selected nodes',
function: () => {
if (app.canvas.empty) {
useToastStore().add({
toastStore.add({
severity: 'error',
summary: t('toastMessages.emptyCanvas'),
life: 3000
@@ -328,7 +329,7 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
const { canvas } = app
if (!canvas.selectedItems?.size) {
useToastStore().add({
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToGroup'),
detail: t('toastMessages.pleaseSelectNodesToGroup'),
@@ -579,6 +580,22 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
},
{
id: 'Comfy.ContactSupport',
icon: 'pi pi-question',
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
dialogService.showIssueReportDialog({
title: t('issueReport.contactSupportTitle'),
subtitle: t('issueReport.contactSupportDescription'),
panelProps: {
errorType: 'ContactSupport',
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
}
})
}
},
{
id: 'Comfy.Help.OpenComfyUIForum',
icon: 'pi pi-comments',
@@ -616,8 +633,26 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
dialogService.showManagerProgressDialog()
}
},
{
id: 'Comfy.User.OpenSignInDialog',
icon: 'pi pi-user',
label: 'Open Sign In Dialog',
versionAdded: '1.17.6',
function: async () => {
await dialogService.showSignInDialog()
}
},
{
id: 'Comfy.User.SignOut',
icon: 'pi pi-sign-out',
label: 'Sign Out',
versionAdded: '1.18.1',
function: async () => {
await firebaseAuthService.logout()
}
}
]
return commands.map((command) => ({ ...command, ...commonProps }))
return commands.map((command) => ({ ...command, source: 'System' }))
}

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