Compare commits

...

149 Commits

Author SHA1 Message Date
github-actions
892a9b6068 Update locales [skip ci] 2025-06-11 15:47:53 +00:00
Yiqun Xu
dfc38083fe Merge branch 'main' into implement-versioned 2025-06-11 08:42:43 -07:00
Christian Byrne
a937ac59ad Revert Algolia proxy changes (#4133) 2025-06-11 06:41:35 -07:00
duckcomfy
995979a4e1 feat: add keyboard shortcut to move selected nodes (unbound by default) (#4066)
Co-authored-by: duckcomfy <a@a.a>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-11 06:35:49 -07:00
Yiqun Xu
1f055686c3 test: test 2025-06-11 04:59:07 -07:00
Yiqun Xu
f145a89c66 feat: Implement versioned default settings system 2025-06-11 04:07:33 -07:00
Comfy Org PR Bot
c02ac95815 [chore] Update Comfy Registry API types from comfy-api@34a03c4 (#4123)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-06-10 05:01:45 -07:00
Christian Byrne
d01926b043 [Dev] Add note to Claude memory about common mistake with url prefixes (#4118) 2025-06-09 07:14:26 -07:00
Christian Byrne
344c6f6244 Reland Playwright MCP for Local Development (#4070) 2025-06-08 01:21:22 -07:00
Terry Jia
b2918a4cf6 Improve bg color image logic (#4095) 2025-06-08 01:20:56 -07:00
Hayden
6d4eafb07a Fix primevue overlay component z-index might be incorrect (#4074) 2025-06-08 01:20:41 -07:00
Comfy Org PR Bot
97edaade63 1.22.1 (#4104)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-08 08:17:34 +00:00
Christian Byrne
83af274339 [fix] resolve @ symbol parsing errors in extension tooltips (#4100) 2025-06-08 01:02:36 -07:00
filtered
f251af25cc Revert "[refactor] Refactor file handling" (#4103) 2025-06-08 07:20:15 +00:00
filtered
e2024c1e79 Revert "[fix] Remove dynamic import timing issue causing Playwright test flakiness" (#4102) 2025-06-07 23:57:29 -07:00
filtered
e8236e1a85 [chore] Pin third-party GitHub Actions to commit SHAs (#4076) 2025-06-07 21:06:34 -07:00
Christian Byrne
79a63de70e [docs] Remove deprecated comment from registerExtension (#4098) 2025-06-07 20:32:36 -07:00
Christian Byrne
3eee7cde0b [docs] Convert .cursorrules to standard markdown format (#4099) 2025-06-07 19:45:03 -07:00
Christian Byrne
6bbe46009b [docs] Add PrimeVue deprecated component guidelines (#4097) 2025-06-07 18:27:35 -07:00
Terry Jia
1ca71caf45 [3d] performance improvement by using threejs setViewport (#4079) 2025-06-06 17:35:16 -07:00
Benjamin Lu
65289b1927 Update to new card design (#4065)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-06 04:19:05 -07:00
filtered
9e2180dcd8 [CodeHealth] Lint script files (#4081) 2025-06-05 03:23:56 -07:00
Benjamin Lu
defea56ba5 [docs] update env example (#4078) 2025-06-05 10:39:48 +10:00
Comfy Org PR Bot
e6bca95a5f [chore] Update ComfyUI-Manager API types from ComfyUI-Manager@4cceb46 (#4077)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-06-04 10:07:16 -07:00
Christian Byrne
841e3f743a [feat] Add workflow to generate ComfyUI-Manager types from OpenAPI (#4072) 2025-06-04 04:31:26 -07:00
Christian Byrne
73be826956 [Feature] Add "All" category to template workflows (#3931)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-04 02:58:00 -07:00
Christian Byrne
398dc6d8a6 [feat] Add dynamic pricing for API nodes with real-time updates (#3963)
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-04 02:04:24 -07:00
Comfy Org PR Bot
d1f4341319 1.22.0 (#4060)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-03 06:16:28 -07:00
Comfy Org PR Bot
8c8bb1a3b7 [chore] Update litegraph to 0.15.15 (#4062)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-03 06:16:12 -07:00
ComfyUI Wiki
05ef25a7a3 Update the Compare slider start position to the middle (#4052) 2025-06-02 21:40:13 -07:00
Benjamin Lu
86aeeb87bb Change hosts accept from readWrite to read (#4058) 2025-06-03 03:16:43 +00:00
Christian Byrne
f7093f6ce0 [dev] Add claude command to provide feedback and spot issues with local changes using Playwright MCP (#4039) 2025-06-02 02:19:51 -07:00
Benjamin Lu
88817e5bc0 Use new Algolia proxy (#4030) 2025-06-02 00:20:37 -07:00
filtered
3ac8aa248c Revert "Export vue new (#3966)" (#4050) 2025-06-02 09:57:47 +10:00
filtered
75ab54ee04 Revert "[Dev] Add Playwright MCP for Local Development (#4028)" (#4048) 2025-06-02 06:21:35 +10:00
filtered
a5729c9e06 Revert "[fix] Automatically fix malformed node def translations" (#4045) 2025-06-02 05:37:30 +10:00
filtered
d1da3476da Revert "Update locales for node definitions" (#4047) 2025-06-02 05:36:41 +10:00
Comfy Org PR Bot
ac01bff67e Update locales for node definitions (#4019)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-06-01 06:46:50 -07:00
Christian Byrne
ec4ced26e7 [fix] Automatically fix malformed node def translations (#4042) 2025-06-01 06:45:40 -07:00
Benjamin Lu
40cfc43c54 Add Help Menu in NodeLibrarySidebarTab (#3922)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-01 23:24:31 +10:00
filtered
35a811c5cf Remove duplication from bug report form (#4043) 2025-06-01 22:42:50 +10:00
Christian Byrne
3d4ac07957 [DevTask] Add custom node testing checkbox to issue template (#4041) 2025-06-01 02:55:59 -07:00
Christian Byrne
54055e7707 [docs] Centralize troubleshooting documentation (#4040) 2025-06-01 01:32:21 -07:00
Christian Byrne
69f33f322f [fix] Clear CSS background variable when canvas background image is removed (#4034) 2025-06-01 13:41:17 +10:00
Christian Byrne
b81c2f7cd2 [bugfix] Filter model metadata by current widget selection (#4021)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-01 12:43:00 +10:00
Comfy Org PR Bot
6289ac9182 1.21.3 (#4035)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-01 00:31:35 +00:00
Christian Byrne
86a7dd05a3 [Dev] Add Playwright MCP for Local Development (#4028)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 13:51:37 -07:00
Christian Byrne
dee00edc5f [feat] Add node library sorting and grouping controls (#4024)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 17:39:39 +10:00
Christian Byrne
afac449f41 [fix] Remove dynamic import timing issue causing Playwright test flakiness (#4031) 2025-05-31 14:01:13 +10:00
filtered
aca1a2a194 Revert "Allow extensions to define pinia stores" (#4027) 2025-05-31 04:12:59 +10:00
filtered
4dfe75d68b Add GH types to issue templates (#3991) 2025-05-30 02:57:10 -07:00
Christian Byrne
2c37dba143 [docs] Add Claude command for adding missing i18n strings (#4023) 2025-05-30 02:22:40 -07:00
Christian Byrne
3936454ffd [feat] Add logout button to user popover (#4022) 2025-05-30 02:17:00 -07:00
Christian Byrne
30ee669f5c [refactor] Refactor file handling (#3955) 2025-05-30 02:05:41 -07:00
Terry Jia
811ddd6165 Allow extensions to define pinia stores (#4018) 2025-05-30 12:05:03 +10:00
filtered
0cdaa512c8 Allow extensions to raise their own Vue dialogs (#4008) 2025-05-29 21:05:52 +10:00
filtered
3a514ca63b Fix dragging preview image does nothing (#4009) 2025-05-29 04:50:04 +10:00
Terry Jia
405b5fc5b7 Add copy url button (#4000)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-28 17:55:57 +10:00
Comfy Org PR Bot
0eaf7d11b6 1.21.2 (#4003)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-28 17:09:41 +10:00
Robin Huang
fa58c04b3a [fix] Disable serialization for text preview widget (#4004) 2025-05-28 04:20:26 +00:00
Comfy Org PR Bot
9c84c9e250 [chore] Update litegraph to 0.15.14 (#3998)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-28 00:40:16 +00:00
Terry Jia
6f9f048b4a [3d] fix wrong hasRecording status (#3995) 2025-05-27 13:07:50 +00:00
filtered
768faeee7e [Test] Disable flaky test (#3994) 2025-05-27 21:03:49 +10:00
filtered
eba81efb4b [Test] Fix husky rejects all test file commits (#3993) 2025-05-27 20:50:15 +10:00
filtered
f9d92b8198 Fix native reroute chaining (#3989) 2025-05-27 16:57:36 +10:00
filtered
c4bbe7fee1 Update Claude rules: no @ts-expect-error (#3985) 2025-05-27 13:23:49 +10:00
Comfy Org PR Bot
8f4f5f8e5f [chore] Update litegraph to 0.15.13 (#3983)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 22:34:53 +00:00
Comfy Org PR Bot
9e137d9924 1.21.1 (#3982)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 08:31:56 +00:00
Comfy Org PR Bot
a084b55db7 [chore] Update litegraph to 0.15.12 (#3981)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 07:39:07 +00:00
filtered
835f318999 Report if Forgot Password? cannot be processed (#3979) 2025-05-26 11:10:05 +10:00
filtered
c35d44c491 [TS] Fix workflow store type assertions (#3978) 2025-05-26 05:39:30 +10:00
filtered
38d3e15103 Never restore view when setting is disabled (#3975) 2025-05-24 22:47:08 +10:00
Terry Jia
674d04c9cf Export vue new (#3966)
Co-authored-by: hayden <48267247+hayden-fr@users.noreply.github.com>
2025-05-23 18:24:33 -07:00
Terry Jia
8209765eec [3d] improve mtl support logic (#3965) 2025-05-23 18:22:13 -07:00
Terry Jia
9d48638464 [3d] fix wrong generated language translation for 3d node output (#3967)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-22 16:49:48 -07:00
Comfy Org PR Bot
0095f02f46 1.21.0 (#3962)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-21 21:35:34 -04:00
Christian Byrne
178c79e559 [fix] Make gallery navigation buttons visible on mobile devices (#3953) 2025-05-21 21:34:13 -04:00
Christian Byrne
7c0040bfec Move user.css to user data (#3952) 2025-05-21 21:33:11 -04:00
Christian Byrne
77f91dea10 [Dev Tool] Add claude directives (#3960) 2025-05-21 21:32:18 -04:00
Christian Byrne
4ad6475283 [Feature] Add audio preview support to queue sidebar (#3954)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 21:31:38 -04:00
Terry Jia
b531d34027 [3d] performance improve (#3961) 2025-05-21 21:29:52 -04:00
Christian Byrne
55ad207345 Trigger browser test expectations update (#3959)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 17:20:55 -07:00
Christian Byrne
ccc1039abb [feat] Add file upload support to canvas background image setting (#3958)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 16:06:16 -07:00
Christian Byrne
49400c69b6 Set output as background (#3079)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 12:15:15 -04:00
Comfy Org PR Bot
32605eeb8f 1.20.4 (#3951)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-20 23:03:12 -04:00
Christian Byrne
f08ec0a981 [API Node] Add cost indicators on API nodes (#3924)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-05-20 21:59:11 -04:00
Christian Byrne
356886dc29 [Feature] Add MP4 workflow file open support (#3950) 2025-05-20 17:57:20 -07:00
Christian Byrne
f96de24a66 Migrate user.css file to upstream (#3949) 2025-05-20 20:11:06 -04:00
Christian Byrne
9d48487af8 [Dev Tools] Add more claude directives (#3948)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-20 13:45:31 -07:00
Christian Byrne
69b534bf14 [UI] Improve template card spacing and responsive image display (#3930)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-20 14:14:05 -04:00
Christian Byrne
2acb2ac181 [Style] Increase template card elevation in dark mode (#3946) 2025-05-20 14:13:12 -04:00
Christian Byrne
37a583e39c [fix] Adjust API node badge colors for light theme (#3945) 2025-05-20 14:12:51 -04:00
gfejer
d8821db2be PWA support (#3639)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-05-20 02:31:59 -07:00
Yiximail
f2c4e567e4 Add a button to selection toolbox to open mask editor (#3603)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-05-20 01:23:39 -07:00
Christian Byrne
fec4c4e928 [docs] Add comprehensive documentation for browser tests (#3942)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-19 23:08:03 -07:00
Terry Jia
49d32f4809 [3d] support mtl for obj file (#3933)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-19 20:48:57 -07:00
Christian Byrne
07f0b88e30 Require description field to be non-empty when reporting issue from the UI (#3939)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-20 13:18:19 +10:00
Christian Byrne
d92ed22908 Remove leftover asset (#3938) 2025-05-20 12:46:35 +10:00
Christian Byrne
24c0c2c499 [Dev Tools] Add claude config (#3937) 2025-05-20 12:34:51 +10:00
Christian Byrne
774bff2ed6 [Refactor] Move component test next to component (#3940) 2025-05-20 11:52:26 +10:00
Comfy Org PR Bot
6d87f2b2ff 1.20.3 (#3932)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-18 21:26:18 -04:00
Christian Byrne
20911aa892 docs: improve README development section organization (#3929)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-19 09:38:59 +10:00
Christian Byrne
3a6018589e Add testing documentation guides for frontend tests (#3927) 2025-05-19 08:41:51 +10:00
Christian Byrne
4c92a7142e Fix: Close user popover on button clicks (#3928) 2025-05-19 08:41:33 +10:00
Christian Byrne
293993e7de Hide manager button in missing nodes dialog when manager is not installed (#3925) 2025-05-18 12:16:24 -04:00
Christian Byrne
a7ee3fae05 Add tests for ChatHistoryWidget and related features (#3921) 2025-05-18 12:16:06 -04:00
Christian Byrne
22dc84324e [docs] add READMEs for major folders (#3923) 2025-05-18 12:11:56 -04:00
Christian Byrne
e76e9ec61a docs: enhance README with development setup and troubleshooting guides (#3920) 2025-05-17 17:15:10 -04:00
filtered
94fde504d0 [CI] Add dev release GH Action (#3910) 2025-05-17 12:43:01 +10:00
Comfy Org PR Bot
e3ecf90bb3 1.20.2 (#3917)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-16 22:02:09 -04:00
Yoland Yan
a131f36cf3 [Fix] Fix out of bound issue when window was close and reopen at diff size (#3906) 2025-05-16 22:01:30 -04:00
Christian Byrne
4cad1a9567 Add LLM chat history widget (#3907)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-16 22:00:45 -04:00
filtered
47a6c6d595 [Dev] Allow Vue dev tools to be disabled (#3911) 2025-05-16 21:59:23 -04:00
filtered
068279ec34 Replace reactive DragAndScale proxy with callback (#3915) 2025-05-16 21:58:21 -04:00
Comfy Org PR Bot
2885ebf5e0 [chore] Update litegraph to 0.15.11 (#3914)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-17 02:03:58 +10:00
Yoland Yan
d4e76ddc45 Add vite-plugin-html and vite-plugin-vue-devtools (#3903) 2025-05-15 14:51:39 -04:00
Chenlei Hu
9a5b80a279 [Refactor] Split SelectionToolbox buttons to components (#3902) 2025-05-15 11:20:51 -04:00
filtered
985dab7e1c Allow LGraph.configure to be made recursive (#3894) 2025-05-15 10:48:56 -04:00
filtered
7f2b8a5321 [CodeHealth] Add various minor fixes - logging, missed i18n (#3895)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-15 22:04:48 +10:00
filtered
59ce169ec9 Add selection changed state watcher (#3899) 2025-05-15 21:13:54 +10:00
Comfy Org PR Bot
4294b2c13b [chore] Update litegraph to 0.15.10 (#3898)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-15 20:22:01 +10:00
Comfy Org PR Bot
242c7e2885 1.20.1 (#3891)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-14 22:40:07 -04:00
Chenlei Hu
c1442ec755 [Cleanup] Remove api nodes news dialog (#3890)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-14 21:06:08 -04:00
Terry Jia
ebd9c96a28 [3d] bug fix and add loading for background image change (#3888) 2025-05-14 16:11:36 -04:00
Chenlei Hu
e6d649b596 [Refactor] Convert NodeBadge.vue to composable (#3883) 2025-05-13 21:56:26 -04:00
Comfy Org PR Bot
b037ba84e3 1.20.0 (#3850)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-05-13 21:05:21 -04:00
杨必赞
7c5c47c105 expose user loggedin in extensionManager (#3871) 2025-05-13 21:04:27 -04:00
Chenlei Hu
b152f67d95 [Refactor] useBrowserTabTitle composable (#3881) 2025-05-13 17:02:54 -04:00
Chenlei Hu
be84d81c32 [Branding] Show execution progress in favicon (#3880) 2025-05-13 15:57:18 -04:00
Christian Byrne
a474a094f3 [Manager] Fix search results render incorrectly when scrolling pages then changing query or tab (#3879) 2025-05-13 15:29:10 -04:00
Christian Byrne
bc360eef15 [Manager] Cache Algolia searches and limit suggestions queries (#3876) 2025-05-13 15:28:42 -04:00
Christian Byrne
a52cc0ebe9 [Manager] Don't show empty suggestions dropdown (#3878) 2025-05-13 11:40:15 -07:00
Christian Byrne
b3c6513e7a Fix bug: Virtual Grid increments page size when no results left to render (#3877) 2025-05-13 11:26:00 -07:00
Christian Byrne
a9bdc70e28 [API Node] Show message tip about API-key-based login (#3851)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <hcl@comfy.org>
2025-05-13 13:03:13 -04:00
filtered
58906fa821 [CodeHealth] Remove remaining uses of global app var (#3868) 2025-05-13 12:01:02 -04:00
filtered
a17fb04f83 [Test] Add per-workflow viewport comparison test (#3867)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-13 11:48:38 +10:00
Comfy Org PR Bot
5c0ad994d8 1.19.9 (#3866)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-12 16:25:19 -04:00
filtered
31be0a04f0 Use upstreamed viewport serialisation (#3864) 2025-05-13 05:33:10 +10:00
Comfy Org PR Bot
d9ab4270d1 [chore] Update litegraph to 0.15.9 (#3863)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-13 05:11:00 +10:00
filtered
36bd1f74ca [TS] Fix weak ds.offset type breaks litegraph CI (#3861) 2025-05-13 04:34:47 +10:00
filtered
7144ec54aa Fix UI crash when selecting broken node + TS fixes (#3859) 2025-05-12 17:57:59 +10:00
Dr.Lt.Data
b2f144c27b refine locales/ko (#3853) 2025-05-12 04:25:02 +10:00
Christian Byrne
014c0022c1 [API Node] Remove mailto on own address (#3852) 2025-05-11 11:12:54 +10:00
Comfy Org PR Bot
5d556c9c94 1.19.8 (#3849)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-10 18:18:54 -04:00
Christian Byrne
992c2ba822 Show text progress messages on executing nodes (#3824) 2025-05-10 16:10:58 -04:00
Christian Byrne
4cc6a15fde Fix refresh combos command fails on nodes using V2 spec (#3848) 2025-05-10 16:06:25 -04:00
Christian Byrne
3f50b8b46d [Test] Add missing plugins in component tests (#3847) 2025-05-10 16:05:30 -04:00
Christian Byrne
bb588ff44e [API Node] Use staging platform url while in dev environment (#3846) 2025-05-10 16:04:51 -04:00
Christian Byrne
974236ce5a Fix video previews not displayed if VHS previously installed but disabled or uninstalled (#3844) 2025-05-10 16:03:55 -04:00
269 changed files with 31671 additions and 3037 deletions

View File

@@ -0,0 +1,43 @@
# Add Missing i18n Translations
## Task: Add English translations for all new localized strings
### Step 1: Identify new translation keys
Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions.
### Step 2: Add translations to English locale file
For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`.
### Key-to-JSON mapping rules:
- Translation keys use dot notation to represent nested JSON structure
- Convert dot notation to nested JSON objects when adding to the locale file
- Example: The key `g.user.name` maps to:
```json
{
"g": {
"user": {
"name": "User Name"
}
}
}
```
### Important notes:
1. **Only modify the English locale file** (`src/locales/en/main.json`)
2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow
3. **Exception for manual translations**: Only add translations to non-English locale files if:
- You have specific domain knowledge that would produce a more accurate translation than the automated system
- The automated translation would likely be incorrect due to technical terminology or context-specific meaning
### Example workflow:
1. If you added `t('settings.advanced.enable')` in a Vue component
2. Add to `src/locales/en/main.json`:
```json
{
"settings": {
"advanced": {
"enable": "Enable advanced settings"
}
}
}
```

View File

@@ -0,0 +1,57 @@
Your task is to perform visual verification of our recent changes to ensure they display correctly in the browser. This verification is critical for catching visual regressions, layout issues, and ensuring our UI changes render properly for end users.
<instructions>
Follow these steps systematically to verify our changes:
1. **Server Setup**
- Check if the dev server is running on port 5173 using browser navigation or port checking
- If not running, start it with `npm run dev` from the root directory
- If the server fails to start, provide detailed troubleshooting steps by reading package.json and README.md for accurate instructions
- Wait for the server to be fully ready before proceeding
2. **Visual Testing Process**
- Navigate to http://localhost:5173/
- For each target page (specified in arguments or recently changed files):
* Navigate to the page using direct URL or site navigation
* Take a high-quality screenshot
* Analyze the screenshot for the specific changes we implemented
* Document any visual issues or improvements needed
3. **Quality Verification**
Check each page for:
- Content accuracy and completeness
- Proper styling and layout alignment
- Responsive design elements
- Navigation functionality
- Image loading and display
- Typography and readability
- Color scheme consistency
- Interactive elements (buttons, links, forms)
</instructions>
<examples>
Common issues to watch for:
- Broken layouts or overlapping elements
- Missing images or broken image links
- Inconsistent styling or spacing
- Navigation menu problems
- Mobile responsiveness issues
- Text overflow or truncation
- Color contrast problems
</examples>
<reporting>
For each page tested, provide:
1. Page URL and screenshot
2. Confirmation that changes display correctly OR detailed description of issues found
3. Any design improvement suggestions
4. Overall assessment of visual quality
If you find issues, be specific about:
- Exact location of the problem
- Expected vs actual behavior
- Severity level (critical, important, minor)
- Suggested fix if obvious
</reporting>
Remember: Take your time with each screenshot and analysis. Visual quality directly impacts user experience and our project's professional appearance.

View File

@@ -1,26 +1,25 @@
// Vue 3 Composition API .cursorrules
# Vue 3 Composition API Project Rules
// Vue 3 Composition API best practices
const vue3CompositionApiBestPractices = [
"Use setup() function for component logic",
"Utilize ref and reactive for reactive state",
"Implement computed properties with computed()",
"Use watch and watchEffect for side effects",
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
"Utilize provide/inject for dependency injection",
"Use vue 3.5 style of default prop declaration. Example:
## Vue 3 Composition API Best Practices
- Use setup() function for component logic
- Utilize ref and reactive for reactive state
- Implement computed properties with computed()
- Use watch and watchEffect for side effects
- Implement lifecycle hooks with onMounted, onUpdated, etc.
- Utilize provide/inject for dependency injection
- Use vue 3.5 style of default prop declaration. Example:
```typescript
const { nodes, showTotal = true } = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
}>()
```
",
"Organize vue component in <template> <script> <style> order",
]
- Organize vue component in <template> <script> <style> order
// Folder structure
const folderStructure = `
## Project Structure
```
src/
components/
constants/
@@ -30,16 +29,25 @@ src/
services/
App.vue
main.ts
`;
```
// Tailwind CSS best practices
const tailwindCssBestPractices = [
"Use Tailwind CSS for styling",
"Implement responsive design with Tailwind CSS",
]
## Styling Guidelines
- Use Tailwind CSS for styling
- Implement responsive design with Tailwind CSS
// Additional instructions
const additionalInstructions = `
## PrimeVue Component Guidelines
DO NOT use deprecated PrimeVue components. Use these replacements instead:
- Dropdown → Use Select (import from 'primevue/select')
- OverlayPanel → Use Popover (import from 'primevue/popover')
- Calendar → Use DatePicker (import from 'primevue/datepicker')
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
- Sidebar → Use Drawer (import from 'primevue/drawer')
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
- TabMenu → Use Tabs without panels
- Steps → Use Stepper without panels
- InlineMessage → Use Message component
## Development Guidelines
1. Leverage VueUse functions for performance-enhancing styles
2. Use lodash for utility functions
3. Use TypeScript for type safety
@@ -49,6 +57,5 @@ const additionalInstructions = `
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation
entries in src/locales/en/main.json.
`;
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
11. Never use deprecated PrimeVue components listed above

View File

@@ -25,3 +25,11 @@ ENABLE_MINIFY=true
# templates are served via the normal method from the server's python site
# packages.
DISABLE_TEMPLATES_PROXY=false
# If playwright tests are being run via vite dev server, Vue plugins will
# invalidate screenshots. When `true`, vite plugins will not be loaded.
DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579

View File

@@ -1,7 +1,9 @@
name: Bug Report
description: "Something is not behaving as expected."
title: "[Bug]: "
description: 'Something is not behaving as expected.'
title: '[Bug]: '
labels: ['Potential Bug']
type: Bug
body:
- type: markdown
attributes:
@@ -10,8 +12,15 @@ body:
- **1:** You are running the latest version of ComfyUI.
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
- **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
`--disable-all-custom-nodes` command line argument.
- type: checkboxes
id: custom-nodes-test
attributes:
label: Custom Node Testing
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
options:
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
required: true
- type: textarea
attributes:

View File

@@ -1,7 +1,8 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature Request]: "
labels: ["enhancement"]
title: '[Feature Request]: '
labels: ['enhancement']
type: Feature
body:
- type: checkboxes

72
.github/workflows/dev-release.yaml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Create Dev PyPI Package
on:
workflow_dispatch:
inputs:
devVersion:
description: 'Dev version'
required: true
type: number
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.current_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Get current version
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Build project
env:
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 build
npm run zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist-files
path: |
dist/
dist.zip
publish_pypi:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install build dependencies
run: python -m pip install build
- name: Setup pypi package
run: |
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
- name: Build pypi package
run: python -m build
working-directory: comfyui_frontend_package
env:
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -136,7 +136,7 @@ jobs:
git commit -m "Update locales"
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@v2
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}

View File

@@ -33,7 +33,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
working-directory: ComfyUI_frontend
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: "Update locales for node definitions"

View File

@@ -54,7 +54,7 @@ jobs:
name: dist-files
- name: Create release
id: create_release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -93,7 +93,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -30,7 +30,7 @@ jobs:
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- uses: actions/setup-node@v4
with:

View File

@@ -30,7 +30,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -29,7 +29,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -0,0 +1,92 @@
name: Update ComfyUI-Manager API Types
on:
# Manual trigger
workflow_dispatch:
jobs:
update-manager-types:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager
clean: true
- name: Get Manager commit information
id: manager-info
run: |
cd ComfyUI-Manager
MANAGER_COMMIT=$(git rev-parse --short HEAD)
echo "commit=${MANAGER_COMMIT}" >> $GITHUB_OUTPUT
cd ..
- name: Generate Manager API types
run: |
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./src/types/generatedManagerTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./src/types/generatedManagerTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./src/types/generatedManagerTypes.ts) ]]; then
echo "No changes to ComfyUI-Manager API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
else
echo "Changes detected in ComfyUI-Manager API types."
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
title: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
body: |
## Automated API Type Update
This PR updates the ComfyUI-Manager API types from the latest ComfyUI-Manager OpenAPI specification.
- Manager commit: ${{ steps.manager-info.outputs.commit }}
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
base: main
labels: Manager
delete-branch: true
add-paths: |
src/types/generatedManagerTypes.ts

View File

@@ -75,7 +75,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'

View File

@@ -38,7 +38,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
}
}
}

56
CLAUDE.md Normal file
View File

@@ -0,0 +1,56 @@
- use npm run to see what commands are available
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
- Never add lines to PR descriptions that say "Generated with Claude Code"
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
- Prefer running single tests, and not the whole test suite, for performance
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- The npm script to type check is called "typecheck" NOT "type check"
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
- Never write css if you can accomplish the same thing with tailwind utility classes
- Use setup() function for component logic
- Utilize ref and reactive for reactive state
- Implement computed properties with computed()
- Use watch and watchEffect for side effects
- Implement lifecycle hooks with onMounted, onUpdated, etc.
- Utilize provide/inject for dependency injection
- Use vue 3.5 style of default prop declaration. Do not define a `props` variable; instead, destructure props. Since vue 3.5, destructuring props does not strip them of reactivity.
- Use Tailwind CSS for styling
- Leverage VueUse functions for performance-enhancing styles
- Use lodash for utility functions
- Use TypeScript for type safety
- Implement proper props and emits definitions
- Utilize Vue 3's Teleport component when needed
- Use Suspense for async components
- Implement proper error handling
- Follow Vue 3 style guide and naming conventions
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
* `Dropdown` → Use `Select` (import from 'primevue/select')
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
* `TabMenu` → Use `Tabs` without panels
* `Steps` → Use `Stepper` without panels
* `InlineMessage` → Use `Message` component
* Use `api.apiURL()` for all backend API calls and routes
- Actual API endpoints like /prompt, /queue, /view, etc.
- Image previews: `api.apiURL('/view?...')`
- Any backend-generated content or dynamic routes
* Use `api.fileURL()` for static files served from the public folder:
- Templates: `api.fileURL('/templates/default.json')`
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
- Any static assets that exist in the public directory

118
README.md
View File

@@ -526,14 +526,46 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
## Development
### Tech Stack
### Prerequisites & Technology Stack
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
- **Required Software**:
- Node.js (v16 or later) and npm
- Git for version control
- A running ComfyUI backend instance
- **Tech Stack**:
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
### Initial Setup
1. Clone the repository:
```bash
git clone https://github.com/Comfy-Org/ComfyUI_frontend.git
cd ComfyUI_frontend
```
2. Install dependencies:
```bash
npm install
```
3. Configure environment (optional):
Create a `.env` file in the project root based on the provided [.env.example](.env.example) file.
**Note about ports**: By default, the dev server expects the ComfyUI backend at `localhost:8188`. If your ComfyUI instance runs on a different port, update this in your `.env` file.
### Dev Server Configuration
To launch ComfyUI and have it connect to your development server:
```bash
python main.py --port 8188
```
### Git pre-commit hooks
@@ -575,7 +607,69 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
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.
We've 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.
### Recommended MCP Integrations
#### Playwright Browser Automation
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
##### Setup for Claude Code
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
```
> navigate to localhost:5173 and take a screenshot.
tell me the items in the menubar
● I'll navigate to localhost:5173 and take a
screenshot to see the menubar items.
● playwright:playwright_navigate (MCP)(url: …
"http://local
host:5173")
⎿ Navigated to http://localhost:5173
● playwright:playwright_screenshot (MCP)(name: "lo…
alhost_5173
_menubar")
⎿ Screenshot saved to: ../../../Downloads/localho
st_5173_menubar-2025-05-30T17-23-00-942Z.png
⎿ Screenshot also stored in memory with name:
'localhost_5173_menubar'
● Read(../../../Downloads/localhost_5173_menubar-2…
25-05-30T17-23-00-942Z.png)
⎿ Read image (94.3KB)
● Based on the screenshot, I can see the menubar at
the top of the ComfyUI interface contains the
following items:
Main Menubar Items:
- Workflow - Menu for workflow-related actions
- Edit - Menu for editing operations
- Help - Menu for help and documentation
Additional UI Elements:
- ComfyUI Logo - On the far left
- Workflow Tab - Shows "Unsaved Workflow" with a
dropdown and close button
- Layout Controls - On the far right (grid view
and hamburger menu icons)
The interface shows a typical ComfyUI workflow
graph with nodes like "Load Checkpoint", "CLIP
Text Encode (Prompt)", "KSampler", and "Empty
Latent Image" connected with colored cables.
```
### Unit Test
@@ -606,3 +700,11 @@ This will replace the litegraph package in this repo with the local litegraph re
### i18n
See [locales/README.md](src/locales/README.md) for details.
## Troubleshooting
For comprehensive troubleshooting and technical support, please refer to our official documentation:
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting

View File

@@ -1,6 +1,6 @@
# Playwright Testing for ComfyUI_frontend
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
This document outlines the setup, usage, and common patterns for Playwright browser tests in the ComfyUI_frontend project.
## WARNING
@@ -31,7 +31,7 @@ If you are running Playwright tests in parallel or running the same test multipl
## Running Tests
There are two ways to run the tests:
There are multiple ways to run the tests:
1. **Headless mode with report generation:**
```bash
@@ -47,14 +47,239 @@ There are two ways to run the tests:
![Playwright UI Mode](https://github.com/user-attachments/assets/6a1ebef0-90eb-4157-8694-f5ee94d03755)
3. **Running specific tests:**
```bash
npx playwright test widget.spec.ts
```
## Test Structure
Browser tests in this project follow a specific organization pattern:
- **Fixtures**: Located in `fixtures/` - These provide test setup and utilities
- `ComfyPage.ts` - The main fixture for interacting with ComfyUI
- `ComfyMouse.ts` - Utility for mouse interactions with the canvas
- Components fixtures in `fixtures/components/` - Page object models for UI components
- **Tests**: Located in `tests/` - The actual test specifications
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
- **Utilities**: Located in `utils/` - Common utility functions
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
## Writing Effective Tests
When writing new tests, follow these patterns:
### Test Structure
```typescript
// Import the test fixture
import { comfyPageFixture as test } from '../fixtures/ComfyPage';
test.describe('Feature Name', () => {
// Set up test environment if needed
test.beforeEach(async ({ comfyPage }) => {
// Common setup
});
test('should do something specific', async ({ comfyPage }) => {
// Test implementation
});
});
```
### Leverage Existing Fixtures and Helpers
Always check for existing helpers and fixtures before implementing new ones:
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
- **ComfyMouse**: Helper for precise mouse operations on the canvas
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
- `actionbar.ts`: Interact with the action bar
- `manageGroupNode.ts`: Group node management operations
- `templates.ts`: Template workflows operations
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
### Key Testing Patterns
1. **Focus elements explicitly**:
Canvas-based elements often need explicit focus before interaction:
```typescript
// Click the canvas first to focus it before pressing keys
await comfyPage.canvas.click();
await comfyPage.page.keyboard.press('a');
```
2. **Mark canvas as dirty if needed**:
Some interactions need explicit canvas updates:
```typescript
// After programmatically changing node state, mark canvas dirty
await comfyPage.page.evaluate(() => {
window['app'].graph.setDirtyCanvas(true, true);
});
```
3. **Use node references over coordinates**:
Node references from `fixtures/utils/litegraphUtils.ts` provide stable ways to interact with nodes:
```typescript
// Prefer this:
const node = await comfyPage.getNodeRefsByType('LoadImage')[0];
await node.click('title');
// Over this:
await comfyPage.canvas.click({ position: { x: 100, y: 100 } });
```
4. **Wait for canvas to render after UI interactions**:
```typescript
await comfyPage.nextFrame();
```
5. **Clean up persistent server state**:
While most state is reset between tests, anything stored on the server persists:
```typescript
// Reset settings that affect other tests (these are stored on server)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark');
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None');
// Clean up uploaded files if needed
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`);
```
6. **Prefer functional assertions over screenshots**:
Use screenshots only when visual verification is necessary:
```typescript
// Prefer this:
expect(await node.isPinned()).toBe(true);
expect(await node.getProperty('title')).toBe('Expected Title');
// Over this - only use when needed:
await expect(comfyPage.canvas).toHaveScreenshot('state.png');
```
7. **Use minimal test workflows**:
When creating test workflows, keep them as minimal as possible:
```typescript
// Include only the components needed for the test
await comfyPage.loadWorkflow('single_ksampler');
```
## Common Patterns and Utilities
### Page Object Pattern
Tests use the Page Object pattern to create abstractions over the UI:
```typescript
// Using the ComfyPage fixture
test('Can toggle boolean widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/boolean_widget')
const node = (await comfyPage.getFirstNodeRef())!
const widget = await node.getWidget(0)
await widget.click()
});
```
### Node References
The `NodeReference` class provides helpers for interacting with LiteGraph nodes:
```typescript
// Getting node by type and interacting with it
const nodes = await comfyPage.getNodeRefsByType('LoadImage')
const loadImageNode = nodes[0]
const widget = await loadImageNode.getWidget(0)
await widget.click()
```
### Visual Regression Testing
Tests use screenshot comparisons to verify UI state:
```typescript
// Take a screenshot and compare to reference
await expect(comfyPage.canvas).toHaveScreenshot('boolean_widget_toggled.png')
```
### Waiting for Animations
Always call `nextFrame()` after actions that trigger animations:
```typescript
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame() // Wait for canvas to redraw
```
### Mouse Interactions
Canvas operations use special helpers to ensure proper timing:
```typescript
// Using ComfyMouse for drag and drop
await comfyMouse.dragAndDrop(
{ x: 100, y: 100 }, // From
{ x: 200, y: 200 } // To
)
// Standard ComfyPage helpers
await comfyPage.drag({ x: 100, y: 100 }, { x: 200, y: 200 })
await comfyPage.pan({ x: 200, y: 200 })
await comfyPage.zoom(-100) // Zoom in
```
### Workflow Management
Tests use workflows stored in `assets/` for consistent starting points:
```typescript
// Load a test workflow
await comfyPage.loadWorkflow('single_ksampler')
// Wait for workflow to load and stabilize
await comfyPage.nextFrame()
```
### Custom Assertions
The project includes custom Playwright assertions through `comfyExpect`:
```typescript
// Check if a node is in a specific state
await expect(node).toBePinned()
await expect(node).toBeBypassed()
await expect(node).toBeCollapsed()
```
## Troubleshooting Common Issues
### Flaky Tests
- **Timing Issues**: Always wait for animations to complete with `nextFrame()`
- **Coordinate Sensitivity**: Canvas coordinates are viewport-relative; use node references when possible
- **Test Isolation**: Tests run in parallel; avoid dependencies between tests
- **Screenshots vary**: Ensure your OS and browser match the reference environment (Linux)
- **Async / await**: Race conditions are a very common cause of test flakiness
## Screenshot Expectations
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment.
- To set new test expectations:
1. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch.
2. Add the `New Browser Test Expectation` tag to your pull request.
3. This will trigger a GitHub action to update the screenshot expectations automatically.
- **DO NOT commit local screenshot expectations** to the repository
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment
- While developing, you can generate local screenshots for your tests, but these will differ from CI-generated ones
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.
To set new test expectations for PR:
1. Write your test with screenshot assertions using `toHaveScreenshot(filename)`
2. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch
3. Add the `New Browser Test Expectation` tag to your pull request
4. The GitHub CI will automatically generate and commit the reference screenshots
This approach ensures consistent screenshot expectations across all PRs and avoids issues with platform-specific rendering.
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.

View File

@@ -0,0 +1,59 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"models": [
{
"name": "outdated_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
},
{
"name": "another_outdated_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
}
]
},
"widgets_values": ["current_selected_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,36 @@
{
"id": "5635564e-189f-49e4-9b25-6b7634bcd595",
"revision": 0,
"last_node_id": 78,
"last_link_id": 53,
"nodes": [
{
"id": 78,
"type": "DevToolsNodeWithV2ComboInput",
"pos": [1320, 904],
"size": [270.3199157714844, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "COMBO",
"type": "COMBO",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithV2ComboInput"
},
"widgets_values": ["A"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.19.7"
},
"version": 0.4
}

View File

@@ -133,6 +133,9 @@ export class ComfyPage {
// Inputs
public readonly workflowUploadInput: Locator
// Toasts
public readonly visibleToasts: Locator
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
@@ -159,6 +162,8 @@ export class ComfyPage {
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.visibleToasts = page.locator('.p-toast-message:visible')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
@@ -270,7 +275,6 @@ export class ComfyPage {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
localStorage.setItem('api-nodes-news-seen', 'true')
}, this.id)
}
await this.goto()
@@ -397,6 +401,30 @@ export class ComfyPage {
await this.nextFrame()
}
async deleteWorkflow(
workflowName: string,
whenMissing: 'ignoreMissing' | 'throwIfMissing' = 'ignoreMissing'
) {
// Open workflows tab
const { workflowsTab } = this.menu
await workflowsTab.open()
// Action to take if workflow missing
if (whenMissing === 'ignoreMissing') {
const workflows = await workflowsTab.getTopLevelSavedWorkflowNames()
if (!workflows.includes(workflowName)) return
}
// Delete workflow
await workflowsTab.getPersistedItem(workflowName).click({ button: 'right' })
await this.clickContextMenuItem('Delete')
await this.confirmDialog.delete.click()
// Clear toast & close tab
await this.closeToasts(1)
await workflowsTab.close()
}
async resetView() {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
@@ -413,7 +441,20 @@ export class ComfyPage {
}
async getVisibleToastCount() {
return await this.page.locator('.p-toast-message:visible').count()
return await this.visibleToasts.count()
}
async closeToasts(requireCount = 0) {
if (requireCount) await expect(this.visibleToasts).toHaveCount(requireCount)
// Clear all toasts
const toastCloseButtons = await this.page
.locator('.p-toast-close-button')
.all()
for (const button of toastCloseButtons) {
await button.click()
}
await expect(this.visibleToasts).toHaveCount(0)
}
async clickTextEncodeNode1() {
@@ -721,7 +762,7 @@ export class ComfyPage {
y: 625
}
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -733,7 +774,7 @@ export class ComfyPage {
},
button: 'right'
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -1005,6 +1046,8 @@ export class ComfyPage {
}
}
export const testComfySnapToGridGridSize = 50
export const comfyPageFixture = base.extend<{
comfyPage: ComfyPage
comfyMouse: ComfyMouse
@@ -1031,7 +1074,8 @@ export const comfyPageFixture = base.extend<{
'Comfy.EnableTooltips': false,
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true
'Comfy.TutorialCompleted': true,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
})
} catch (e) {
console.error(e)

View File

@@ -0,0 +1,251 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Background Image Upload', () => {
test.beforeEach(async ({ comfyPage }) => {
// Reset the background image setting before each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test.afterEach(async ({ comfyPage }) => {
// Clean up background image setting after each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test('should show background image upload component in settings', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
await expect(backgroundImageSetting).toBeVisible()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
})
test('should upload image file and set as background', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
// Set up file upload handler
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await uploadButton.click()
const fileChooser = await fileChooserPromise
// Upload the test image
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// Wait for upload to complete and verify the setting was updated
await comfyPage.page.waitForTimeout(500) // Give time for file reading
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const inputValue = await urlInput.inputValue()
expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
})
test('should accept URL input for background image', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
})
test('should clear background image when clear button is clicked', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', testImageUrl)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Click the clear button
await clearButton.click()
// Verify the input is now empty
await expect(urlInput).toHaveValue('')
// Verify clear button is now disabled
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
})
test('should show tooltip on upload and clear buttons', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await uploadButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(uploadTooltip).toBeVisible()
// Move away to hide tooltip
await comfyPage.page.locator('body').hover()
await comfyPage.page.waitForTimeout(100)
// Set a background to enable clear button
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await clearButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(clearTooltip).toBeVisible()
})
test('should maintain reactive updates between URL input and clear button state', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()
// Type some text - clear button should become enabled
await urlInput.fill('test')
await expect(clearButton).toBeEnabled()
// Clear the text manually - clear button should become disabled again
await urlInput.fill('')
await expect(clearButton).toBeDisabled()
// Add text again - clear button should become enabled
await urlInput.fill('https://example.com/image.png')
await expect(clearButton).toBeEnabled()
// Use clear button - should clear input and disable itself
await clearButton.click()
await expect(urlInput).toHaveValue('')
await expect(clearButton).toBeDisabled()
})
})

View File

@@ -0,0 +1,139 @@
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface ChatHistoryEntry {
prompt: string
response: string
response_id: string
}
async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
const nodeId = await page.evaluate(() => window['app'].graph.nodes[0]?.id)
// Simulate API sending display_component message
await page.evaluate(
({ nodeId, history }) => {
const event = new CustomEvent('display_component', {
detail: {
node_id: nodeId,
component: 'ChatHistoryWidget',
props: {
history: JSON.stringify(history)
}
}
})
window['app'].api.dispatchEvent(event)
return true
},
{ nodeId, history }
)
return nodeId
}
test.describe('Chat History Widget', () => {
let nodeId: string
test.beforeEach(async ({ comfyPage }) => {
nodeId = await renderChatHistory(comfyPage.page, [
{ prompt: 'Hello', response: 'World', response_id: '123' }
])
// Wait for chat history to be rendered
await comfyPage.page.waitForSelector('.pi-pencil')
})
test('displays chat history when receiving display_component message', async ({
comfyPage
}) => {
// Verify the chat history is displayed correctly
await expect(comfyPage.page.getByText('Hello')).toBeVisible()
await expect(comfyPage.page.getByText('World')).toBeVisible()
})
test('handles message editing interaction', async ({ comfyPage }) => {
// Get first node's ID
nodeId = await comfyPage.page.evaluate(() => {
const node = window['app'].graph.nodes[0]
// Make sure the node has a prompt widget (for editing functionality)
if (!node.widgets) {
node.widgets = []
}
// Add a prompt widget if it doesn't exist
if (!node.widgets.find((w) => w.name === 'prompt')) {
node.widgets.push({
name: 'prompt',
type: 'text',
value: 'Original prompt'
})
}
return node.id
})
await renderChatHistory(comfyPage.page, [
{
prompt: 'Message 1',
response: 'Response 1',
response_id: '123'
},
{
prompt: 'Message 2',
response: 'Response 2',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
const originalTextAreaInput = await comfyPage.page
.getByPlaceholder('text')
.nth(1)
.inputValue()
// Click edit button on first message
await comfyPage.page.getByLabel('Edit').first().click()
await comfyPage.nextFrame()
// Verify cancel button appears
await expect(comfyPage.page.getByLabel('Cancel')).toBeVisible()
// Click cancel edit
await comfyPage.page.getByLabel('Cancel').click()
// Verify prompt input is restored
await expect(comfyPage.page.getByPlaceholder('text').nth(1)).toHaveValue(
originalTextAreaInput
)
})
test('handles real-time updates to chat history', async ({ comfyPage }) => {
// Send initial history
await renderChatHistory(comfyPage.page, [
{
prompt: 'Initial message',
response: 'Initial response',
response_id: '123'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Update history with additional messages
await renderChatHistory(comfyPage.page, [
{
prompt: 'Follow-up',
response: 'New response',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Move mouse over the canvas to force update
await comfyPage.page.mouse.move(100, 100)
await comfyPage.nextFrame()
// Verify new messages appear
await expect(comfyPage.page.getByText('Follow-up')).toBeVisible()
await expect(comfyPage.page.getByText('New response')).toBeVisible()
})
})

View File

@@ -103,7 +103,7 @@ test.describe('Missing models warning', () => {
}
])
}
comfyPage.page.route(
await comfyPage.page.route(
'**/api/experiment/models',
(route) => route.fulfill(modelFoldersRes),
{ times: 1 }
@@ -121,7 +121,7 @@ test.describe('Missing models warning', () => {
}
])
}
comfyPage.page.route(
await comfyPage.page.route(
'**/api/experiment/models/text_encoders',
(route) => route.fulfill(clipModelsRes),
{ times: 1 }
@@ -133,6 +133,18 @@ test.describe('Missing models warning', () => {
await expect(missingModelsWarning).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
comfyPage
}) => {
// This tests the scenario where outdated model metadata exists in the workflow
// but the actual selected models (widget values) have changed
await comfyPage.loadWorkflow('model_metadata_widget_mismatch')
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should download missing model when clicking download button', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,6 +1,12 @@
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -57,8 +63,10 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const dragSelectNodes = async (
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -74,10 +82,67 @@ test.describe('Node Interaction', () => {
}
)
await comfyPage.page.keyboard.up('Meta')
}
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
await dragSelectNodes(comfyPage, clipNodes)
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
direction,
expectedPosition
}: {
direction: string
expectedPosition: (originalPosition: Position) => Position
}) => {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.executeCommand(
`Comfy.Canvas.MoveSelectedNodes.${direction}`
)
await comfyPage.canvas.press(`Control+Arrow${direction}`)
const newPositions = await getPositions()
expect(newPositions).toEqual(originalPositions.map(expectedPosition))
}
await testDirection({
direction: 'Down',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Right',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Up',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y - testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Left',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x - testComfySnapToGridGridSize
})
})
})
})
test('Can drag node', async ({ comfyPage }) => {
@@ -689,3 +754,42 @@ test.describe('Load duplicate workflow', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(1)
})
})
test.describe('Viewport settings', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
await comfyPage.setupWorkflowsDirectory({})
})
test('Keeps viewport settings when changing tabs', async ({
comfyPage,
comfyMouse
}) => {
// Screenshot the canvas element
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
// Save workflow as a new file, then zoom out before screen shot
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
await comfyMouse.move(comfyPage.emptySpace)
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
}
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await tabA.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
// And back to Workflow B
await tabB.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,556 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const app = window['app']
const canvas = app.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
canvas.setDirty(true, true)
}, nodePos)
await comfyPage.nextFrame()
await nodeRef.click('title')
}
test.describe('Node Help', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Selection Toolbox', () => {
test('Should open help menu for selected node', async ({ comfyPage }) => {
// Load a workflow with a node
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('default')
// Select a single node (KSampler) using node references
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found in the workflow')
}
// Select the node with panning to ensure toolbox is visible
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Wait for selection overlay container and toolbox to appear
await expect(
comfyPage.page.locator('.selection-overlay-container')
).toBeVisible()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
// Click the help button in the selection toolbox
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the node library sidebar is opened
await expect(
comfyPage.menu.nodeLibraryTab.selectedTabButton
).toBeVisible()
// Verify that the help page is shown for the correct node
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
})
test.describe('Node Library Sidebar', () => {
test('Should open help menu from node library', async ({ comfyPage }) => {
// Open the node library sidebar
await comfyPage.menu.nodeLibraryTab.open()
// Wait for node library to load
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
// Search for KSampler to make it easier to find
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
'KSampler'
)
// Find the KSampler node in search results
const ksamplerNode = comfyPage.page
.locator('.tree-explorer-node-label')
.filter({ hasText: 'KSampler' })
.first()
await expect(ksamplerNode).toBeVisible()
// Hover over the node to show action buttons
await ksamplerNode.hover()
// Click the help button
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the help page is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
test('Should show node library tab when clicking back from help page', async ({
comfyPage
}) => {
// Open the node library sidebar
await comfyPage.menu.nodeLibraryTab.open()
// Wait for node library to load
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
// Search for KSampler
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
'KSampler'
)
// Find and interact with the node
const ksamplerNode = comfyPage.page
.locator('.tree-explorer-node-label')
.filter({ hasText: 'KSampler' })
.first()
await ksamplerNode.hover()
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await helpButton.click()
// Verify help page is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
// Click the back button - use a more specific selector
const backButton = comfyPage.page.locator('button:has(.pi-arrow-left)')
await expect(backButton).toBeVisible()
await backButton.click()
// Verify that we're back to the node library view
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput
).toBeVisible()
// Verify help page is no longer visible
await expect(helpPage.locator('.node-help-content')).not.toBeVisible()
})
})
test.describe('Help Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
test('Should display loading state while fetching help', async ({
comfyPage
}) => {
// Mock slow network response
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
await route.fulfill({
status: 200,
body: '# Test Help Content\nThis is test help content.'
})
})
// Load workflow and select a node
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
// Verify loading spinner is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
// Wait for content to load
await expect(helpPage).toContainText('Test Help Content')
})
test('Should display fallback content when help file not found', async ({
comfyPage
}) => {
// Mock 404 response for help files
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await route.fulfill({
status: 404,
body: 'Not Found'
})
})
// Load workflow and select a node
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
// Verify fallback content is shown (description, inputs, outputs)
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('Description')
await expect(helpPage).toContainText('Inputs')
await expect(helpPage).toContainText('Outputs')
})
test('Should render markdown with images correctly', async ({
comfyPage
}) => {
// Mock response with markdown containing images
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Documentation
![Example Image](example.jpg)
![External Image](https://example.com/image.png)
## Parameters
- **steps**: Number of steps
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler Documentation')
// Check that relative image paths are prefixed correctly
const relativeImage = helpPage.locator('img[alt="Example Image"]')
await expect(relativeImage).toBeVisible()
await expect(relativeImage).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/example\.jpg/
)
// Check that absolute URLs are not modified
const externalImage = helpPage.locator('img[alt="External Image"]')
await expect(externalImage).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
test('Should render video elements with source tags in markdown', async ({
comfyPage
}) => {
// Mock response with video elements
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Demo
<video src="demo.mp4" controls autoplay></video>
<video src="/absolute/video.mp4" controls></video>
<video controls>
<source src="video.mp4" type="video/mp4">
<source src="https://example.com/video.webm" type="video/webm">
</video>
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Check relative video paths are prefixed
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
await expect(relativeVideo).toBeVisible()
await expect(relativeVideo).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/demo\.mp4/
)
await expect(relativeVideo).toHaveAttribute('controls', '')
await expect(relativeVideo).toHaveAttribute('autoplay', '')
// Check absolute paths are not modified
const absoluteVideo = helpPage.locator('video[src="/absolute/video.mp4"]')
await expect(absoluteVideo).toHaveAttribute('src', '/absolute/video.mp4')
// Check video source elements
const relativeVideoSource = helpPage.locator('source[src*="video.mp4"]')
await expect(relativeVideoSource).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/video\.mp4/
)
const externalVideoSource = helpPage.locator(
'source[src="https://example.com/video.webm"]'
)
await expect(externalVideoSource).toHaveAttribute(
'src',
'https://example.com/video.webm'
)
})
test('Should handle custom node documentation paths', async ({
comfyPage
}) => {
// First load workflow with custom node
await comfyPage.loadWorkflow('group_node_v1.3.3')
// Mock custom node documentation with fallback
await comfyPage.page.route(
'**/extensions/*/docs/*/en.md',
async (route) => {
await route.fulfill({ status: 404 })
}
)
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
await route.fulfill({
status: 200,
body: `# Custom Node Documentation
This is documentation for a custom node.
![Custom Image](assets/custom.png)
`
})
})
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes.map((n: any) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
await selectNodeWithPan(comfyPage, firstNode)
}
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
if (await helpButton.isVisible()) {
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('Custom Node Documentation')
// Check image path for custom nodes
const image = helpPage.locator('img[alt="Custom Image"]')
await expect(image).toHaveAttribute(
'src',
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
)
}
})
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
// Mock response with potentially dangerous content
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# Safe Content
<script>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
<a href="javascript:alert('XSS')">Dangerous Link</a>
<iframe src="evil.com"></iframe>
<!-- Safe content -->
<video src="safe.mp4" controls></video>
<img src="safe.jpg" alt="Safe Image">
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Dangerous elements should be removed
await expect(helpPage.locator('script')).toHaveCount(0)
await expect(helpPage.locator('iframe')).toHaveCount(0)
// Check that onerror attribute is removed
const images = helpPage.locator('img')
const imageCount = await images.count()
for (let i = 0; i < imageCount; i++) {
const img = images.nth(i)
const onError = await img.getAttribute('onerror')
expect(onError).toBeNull()
}
// Check that javascript: links are sanitized
const links = helpPage.locator('a')
const linkCount = await links.count()
for (let i = 0; i < linkCount; i++) {
const link = links.nth(i)
const href = await link.getAttribute('href')
if (href !== null) {
expect(href).not.toContain('javascript:')
}
}
// Safe content should remain
await expect(helpPage.locator('video[src*="safe.mp4"]')).toBeVisible()
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
これは日本語のドキュメントです。
`
})
})
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
This is English documentation.
`
})
})
// Set locale to Japanese
await comfyPage.setSetting('Comfy.Locale', 'ja')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
// Reset locale
await comfyPage.setSetting('Comfy.Locale', 'en')
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {
// Mock network error
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await route.abort('failed')
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
// Should show some content even on error
const content = await helpPage.textContent()
expect(content).toBeTruthy()
})
test('Should update help content when switching between nodes', async ({
comfyPage
}) => {
// Mock different help content for different nodes
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: '# KSampler Help\n\nThis is KSampler documentation.'
})
})
await comfyPage.page.route(
'**/docs/CheckpointLoaderSimple/en.md',
async (route) => {
await route.fulfill({
status: 200,
body: '# Checkpoint Loader Help\n\nThis is Checkpoint Loader documentation.'
})
}
)
await comfyPage.loadWorkflow('default')
// Select KSampler first
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler Help')
await expect(helpPage).toContainText('This is KSampler documentation')
// Now select Checkpoint Loader
const checkpointNodes = await comfyPage.getNodeRefsByType(
'CheckpointLoaderSimple'
)
await selectNodeWithPan(comfyPage, checkpointNodes[0])
// Click help button again
const helpButton2 = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton2.click()
// Content should update
await expect(helpPage).toContainText('Checkpoint Loader Help')
await expect(helpPage).toContainText(
'This is Checkpoint Loader documentation'
)
await expect(helpPage).not.toContainText('KSampler documentation')
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -32,7 +32,9 @@ test.describe('Templates', () => {
}
})
test('should have all required thumbnail media for each template', async ({
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()
@@ -142,4 +144,136 @@ test.describe('Templates', () => {
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({
comfyPage
}) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for at least one template card to appear
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
timeout: 5000
})
// Take snapshot of the template grid
const templateGrid = comfyPage.templates.content.locator('.grid').first()
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
// Check cards at mobile viewport size
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
// Check cards at tablet size
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
})
test('hover effects work on template cards', async ({ comfyPage }) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Get a template card
const firstCard = comfyPage.page.locator('.template-card').first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Take snapshot before hover
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
// Hover over the card
await firstCard.hover()
// Take snapshot after hover to verify hover effect
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
})
test('template cards descriptions adjust height dynamically', async ({
comfyPage
}) => {
// Setup test by intercepting templates response to inject cards with varying description lengths
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'short-description',
title: 'Short Description',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'This is a short description.'
},
{
name: 'medium-description',
title: 'Medium Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a medium length description that should take up two lines on most displays.'
},
{
name: 'long-description',
title: 'Long Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
// Mock the thumbnail images to avoid 404s
await comfyPage.page.route('**/templates/**.webp', async (route) => {
const headers = {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers
})
})
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify cards are visible with varying content lengths
await expect(
comfyPage.page.getByText('This is a short description.')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a medium length description')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a much longer description')
).toBeVisible({ timeout: 5000 })
// Take snapshot of a grid with specific cards
const templateGrid = comfyPage.templates.content
.locator('.grid:has-text("Short Description")')
.first()
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -53,6 +53,26 @@ test.describe('Combo text widget', () => {
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)
})
test('Should refresh combo values of nodes with v2 combo input spec', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('node_with_v2_combo_input')
// click canvas to focus
await comfyPage.page.mouse.click(400, 300)
// press R to trigger refresh
await comfyPage.page.keyboard.press('r')
// wait for nodes' widgets to be updated
await comfyPage.page.mouse.click(400, 300)
await comfyPage.nextFrame()
// get the combo widget's values
const comboValues = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes
.find((node) => node.title === 'Node With V2 Combo Input')
.widgets.find((widget) => widget.name === 'combo_input').options.values
})
expect(comboValues).toEqual(['A', 'B'])
})
})
test.describe('Boolean widget', () => {

View File

@@ -24,7 +24,7 @@ export default [
},
parser: tseslint.parser,
parserOptions: {
project: './tsconfig.json',
project: ['./tsconfig.json', './tsconfig.eslint.json'],
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue']

View File

@@ -4,9 +4,18 @@
<meta charset="UTF-8">
<title>ComfyUI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
<!-- Fullscreen mode on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="manifest" href="manifest.json">
</head>
<body class="litegraph grid">
<div id="vue-app"></div>
<script type="module" src="src/main.ts"></script>

9
manifest.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "ComfyUI",
"short_name": "ComfyUI",
"description": "ComfyUI: AI image generation platform",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"
}

3606
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.19.7",
"version": "1.22.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -29,11 +29,13 @@
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@executeautomation/playwright-mcp-server": "^1.0.5",
"@iconify/json": "^2.2.245",
"@lobehub/i18n-cli": "^1.20.0",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@playwright/test": "^1.52.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
@@ -63,6 +65,8 @@
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.19",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
@@ -72,7 +76,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.8",
"@comfyorg/litegraph": "^0.15.15",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -89,12 +93,14 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -1 +0,0 @@
/* Put custom styles here */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,58 +0,0 @@
<template>
<div>
<!-- This component does not render anything visible. -->
</div>
</template>
<script setup lang="ts">
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
const executionStore = useExecutionStore()
const executionText = computed(() =>
executionStore.isIdle
? ''
: `[${Math.round(executionStore.executionProgress * 100)}%]`
)
const settingStore = useSettingStore()
const newMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const workflowStore = useWorkflowStore()
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename
return workflowName
? isUnsavedText.value + workflowName + TITLE_SUFFIX
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)
const workflowTitle = computed(
() =>
executionText.value +
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
)
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
useTitle(title)
</script>

View File

@@ -63,15 +63,6 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
if (x.value !== 0 || y.value !== 0) {
return
}
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
x.value = storedPosition.value.x
y.value = storedPosition.value.y
captureLastDragState()
return
}
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
@@ -82,9 +73,25 @@ const setInitialPosition = () => {
return
}
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
captureLastDragState()
// Check if stored position exists and is within bounds
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
// Ensure stored position is within screen bounds
x.value = clamp(storedPosition.value.x, 0, screenWidth - menuWidth)
y.value = clamp(storedPosition.value.y, 0, screenHeight - menuHeight)
captureLastDragState()
return
}
// If no stored position or current position, set to bottom center
if (x.value === 0 && y.value === 0) {
x.value = clamp((screenWidth - menuWidth) / 2, 0, screenWidth - menuWidth)
y.value = clamp(
screenHeight - menuHeight - 10,
0,
screenHeight - menuHeight
)
captureLastDragState()
}
}
}
onMounted(setInitialPosition)

View File

@@ -0,0 +1,103 @@
<template>
<div class="flex gap-2">
<InputText
v-model="modelValue"
class="flex-1"
:placeholder="$t('g.imageUrl')"
/>
<Button
v-tooltip="$t('g.upload')"
:icon="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'"
size="small"
:disabled="isUploading"
@click="triggerFileInput"
/>
<Button
v-tooltip="$t('g.clear')"
outlined
icon="pi pi-trash"
severity="danger"
size="small"
:disabled="!modelValue"
@click="clearImage"
/>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
@change="handleFileUpload"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const modelValue = defineModel<string>()
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const triggerFileInput = () => {
fileInput.value?.click()
}
const uploadFile = async (file: File): Promise<string | null> => {
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'backgrounds')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
`Upload failed: ${resp.status} - ${resp.statusText}`
)
return null
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
}
}
const clearImage = () => {
modelValue.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>

View File

@@ -30,6 +30,15 @@
@click="download.triggerBrowserDownload"
/>
</div>
<div>
<Button
:label="$t('g.copyURL')"
size="small"
outlined
:disabled="!!props.error"
@click="copyURL"
/>
</div>
</div>
</template>
@@ -38,6 +47,7 @@ import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
@@ -49,9 +59,15 @@ const props = defineProps<{
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -36,6 +36,7 @@ import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import { type Component, markRaw } from 'vue'
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
import CustomFormValue from '@/components/common/CustomFormValue.vue'
import FormColorPicker from '@/components/common/FormColorPicker.vue'
import FormImageUpload from '@/components/common/FormImageUpload.vue'
@@ -102,6 +103,8 @@ function getFormComponent(item: FormItem): Component {
return FormColorPicker
case 'url':
return UrlInput
case 'backgroundImage':
return BackgroundImageUpload
default:
return InputText
}

View File

@@ -32,6 +32,8 @@ describe('TreeExplorerTreeNode', () => {
handleRename: () => {}
} as RenderedTreeExplorerNode
const mockHandleEditLabel = vi.fn()
beforeAll(() => {
// Create a Vue app instance for PrimeVuePrimeVue
const app = createApp({})
@@ -48,7 +50,10 @@ describe('TreeExplorerTreeNode', () => {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
plugins: [createTestingPinia(), i18n]
plugins: [createTestingPinia(), i18n],
provide: {
[InjectKeyHandleEditLabelFunction]: mockHandleEditLabel
}
}
})
@@ -72,7 +77,10 @@ describe('TreeExplorerTreeNode', () => {
},
global: {
components: { EditableText, Badge, InputText },
plugins: [createTestingPinia(), i18n, PrimeVue]
plugins: [createTestingPinia(), i18n, PrimeVue],
provide: {
[InjectKeyHandleEditLabelFunction]: mockHandleEditLabel
}
}
})

View File

@@ -70,11 +70,12 @@ const state = computed<GridState>(() => {
const fromCol = fromRow * cols.value
const toCol = toRow * cols.value
const remainingCol = items.length - toCol
const hasMoreToRender = remainingCol >= 0
return {
start: clamp(fromCol, 0, items?.length),
end: clamp(toCol, fromCol, items?.length),
isNearEnd: remainingCol <= cols.value * bufferRows
isNearEnd: hasMoreToRender && remainingCol <= cols.value * bufferRows
}
})
const renderedItems = computed(() =>

View File

@@ -1,14 +1,12 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="(item, index) in dialogStore.dialogStack"
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:auto-z-index="false"
:pt="item.dialogComponentProps.pt"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:aria-labelledby="item.key"
>
<template #header>
@@ -35,25 +33,11 @@
</template>
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { usePrimeVue } from '@primevue/core'
import Dialog from 'primevue/dialog'
import { computed, onMounted } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const primevue = usePrimeVue()
const baseZIndex = computed(() => {
return primevue?.config?.zIndex?.modal ?? 1100
})
onMounted(() => {
const mask = document.createElement('div')
ZIndex.set('model', mask, baseZIndex.value)
})
</script>
<style>

View File

@@ -1,74 +0,0 @@
<template>
<div class="flex flex-col gap-12 p-2 w-96">
<img src="@/assets/images/api-nodes-news.webp" alt="API Nodes News" />
<div class="flex flex-col gap-2 justify-center items-center">
<div class="text-xl">
{{ $t('apiNodesNews.introducing') }}
<span class="text-amber-500">API NODES</span>
</div>
<div class="text-muted">{{ $t('apiNodesNews.subtitle') }}</div>
</div>
<div class="flex flex-col gap-4">
<div
v-for="(step, index) in steps"
:key="index"
class="grid grid-cols-[auto_1fr] gap-2 items-center"
>
<Tag class="w-8 h-8" :value="index + 1" rounded />
<div class="flex flex-col gap-2">
<div>{{ step.title }}</div>
<div v-if="step.subtitle" class="text-muted">
{{ step.subtitle }}
</div>
</div>
</div>
</div>
<div class="flex flex-row justify-between">
<Button label="Learn More" text @click="handleLearnMore" />
<Button label="Close" @click="onClose" />
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import { onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const steps: {
title: string
subtitle?: string
}[] = [
{
title: t('apiNodesNews.steps.step1.title'),
subtitle: t('apiNodesNews.steps.step1.subtitle')
},
{
title: t('apiNodesNews.steps.step2.title'),
subtitle: t('apiNodesNews.steps.step2.subtitle')
},
{
title: t('apiNodesNews.steps.step3.title')
},
{
title: t('apiNodesNews.steps.step4.title')
}
]
const { onClose } = defineProps<{
onClose: () => void
}>()
const handleLearnMore = () => {
window.open('https://blog.comfy.org/p/comfyui-native-api-nodes', '_blank')
}
onBeforeUnmount(() => {
localStorage.setItem('api-nodes-news-seen', 'true')
})
</script>

View File

@@ -30,7 +30,7 @@
</div>
</template>
</ListBox>
<div class="flex justify-end py-3">
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<Button label="Open Manager" size="small" outlined @click="openManager" />
</div>
</template>
@@ -42,6 +42,7 @@ import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -49,6 +50,19 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const aboutPanelStore = useAboutPanelStore()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes

View File

@@ -67,9 +67,9 @@ import Tabs from 'primevue/tabs'
import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 { flattenTree } from '@/utils/treeUtil'
@@ -107,7 +107,7 @@ const {
getSearchResults
} = useSettingSearch()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
@@ -140,7 +140,7 @@ watch(activeCategory, (_, oldValue) => {
activeCategory.value = oldValue
}
if (activeCategory.value?.key === 'credits') {
void authService.fetchBalance()
void authActions.fetchBalance()
}
})
</script>

View File

@@ -94,13 +94,22 @@
<small class="text-muted text-center">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
<Message
v-if="authActions.accessError.value"
severity="info"
icon="pi pi-info-circle"
variant="outlined"
closable
>
{{ t('toastMessages.useApiKeyTip') }}
</Message>
</div>
<!-- Terms & Contact -->
@@ -134,11 +143,12 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'
@@ -150,7 +160,7 @@ const { onSuccess } = defineProps<{
}>()
const { t } = useI18n()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
@@ -161,25 +171,25 @@ const toggleState = () => {
}
const signInWithGoogle = async () => {
if (await authService.signInWithGoogle()) {
if (await authActions.signInWithGoogle()) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authService.signInWithGithub()) {
if (await authActions.signInWithGithub()) {
onSuccess()
}
}
const signInWithEmail = async (values: SignInData) => {
if (await authService.signInWithEmail(values.email, values.password)) {
if (await authActions.signInWithEmail(values.email, values.password)) {
onSuccess()
}
}
const signUpWithEmail = async (values: SignUpData) => {
if (await authService.signUpWithEmail(values.email, values.password)) {
if (await authActions.signUpWithEmail(values.email, values.password)) {
onSuccess()
}
}
@@ -188,4 +198,8 @@ const userIsInChina = ref(false)
onMounted(async () => {
userIsInChina.value = await isInChina()
})
onUnmounted(() => {
authActions.accessError.value = false
})
</script>

View File

@@ -51,7 +51,7 @@
import Button from 'primevue/button'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
@@ -65,9 +65,9 @@ const {
preselectedAmountOption?: number
}>()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const handleSeeDetails = async () => {
await authService.accessBillingPortal()
await authActions.accessBillingPortal()
}
</script>

View File

@@ -23,10 +23,10 @@ import Button from 'primevue/button'
import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { updatePasswordSchema } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const loading = ref(false)
const { onSuccess } = defineProps<{
@@ -37,7 +37,7 @@ const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
loading.value = true
try {
await authService.updatePassword(event.values.password)
await authActions.updatePassword(event.values.password)
onSuccess()
} finally {
loading.value = false

View File

@@ -41,9 +41,9 @@ import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const {
amount,
@@ -61,7 +61,7 @@ const loading = ref(false)
const handleBuyNow = async () => {
loading.value = true
await authService.purchaseCredits(editable ? customAmount.value : amount)
await authActions.purchaseCredits(editable ? customAmount.value : amount)
loading.value = false
didClickBuyNow.value = true
}
@@ -69,7 +69,7 @@ const handleBuyNow = async () => {
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authService.fetchBalance()
void authActions.fetchBalance()
}
})
</script>

View File

@@ -124,12 +124,16 @@
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
v-if="$field?.error && $field.touched"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
{{
$field.value
? t('issueReport.validation.maxLength')
: t('issueReport.validation.descriptionRequired')
}}
</Message>
</FormField>
</div>

View File

@@ -55,6 +55,7 @@
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="3"
:grid-style="GRID_STYLE"
@@ -92,7 +93,7 @@
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -199,6 +200,10 @@ const {
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
whenever(selectedTab, () => {
pageNumber.value = 0
})
const isUpdateAvailableTab = computed(
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
)
@@ -403,19 +408,41 @@ const handleGridContainerClick = (event: MouseEvent) => {
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
// Track the last pack ID for which we've fetched full registry data
const lastFetchedPackId = ref<string | null>(null)
// Whenever a single pack is selected, fetch its full info once
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
if (!selectedNodePack.value?.id) return
// If only a single node pack is selected, fetch full node pack info from registry
const pack = selectedNodePack.value
if (!pack?.id) return
if (hasMultipleSelections.value) return
const data = await getPackById.call(selectedNodePack.value.id)
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
selectedNodePacks.value = [mergedPack]
// Replace pack in displayPacks so that children receive a fresh prop reference
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
if (idx !== -1) {
displayPacks.value.splice(idx, 1, mergedPack)
}
}
})
if (data?.id === selectedNodePack.value?.id) {
// If selected node hasn't changed since request, merge registry & Algolia data
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch(searchQuery, () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
gridContainer.scrollTop = 0
}
})

View File

@@ -0,0 +1,41 @@
<template>
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
class="object-cover"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '100%',
height = '12rem'
} = defineProps<{
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -7,19 +7,15 @@
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: {
class:
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
},
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<PackCardHeader :node-pack="nodePack" />
<PackBanner :node-pack="nodePack" />
</template>
<template #content>
<ContentDivider />
<template v-if="isInstalling">
<div
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
@@ -34,46 +30,63 @@
</template>
<template v-else>
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
class="self-stretch inline-flex flex-col justify-start items-start"
>
<PackIcon :node-pack="nodePack" />
<div
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
>
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
>
<span
class="text-base font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
>
{{ nodePack.description }}
</p>
</div>
<div
class="self-stretch inline-flex justify-start items-center gap-2"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div class="flex flex-col gap-y-2">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
class="self-stretch inline-flex justify-start items-center gap-1"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
<div
v-if="nodesCount"
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div
class="text-center justify-center font-medium leading-3"
>
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
@@ -92,11 +105,12 @@ import { whenever } from '@vueuse/core'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
@@ -107,6 +121,8 @@ const { nodePack, isSelected = false } = defineProps<{
isSelected?: boolean
}>()
const { d } = useI18n()
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
@@ -122,4 +138,19 @@ whenever(isInstalled, () => (isInstalling.value = false))
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
const publisherName = computed(() => {
if (!nodePack) return null
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
const formattedLatestVersionDate = computed(() => {
if (!nodePack.latest_version?.createdAt) return null
return d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
})
</script>

View File

@@ -1,39 +1,29 @@
<template>
<div
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
>
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="publisherName" class="max-w-40 truncate">
{{ publisherName }}
</span>
</div>
<div
v-if="nodePack.latest_version?.createdAt"
class="flex items-center gap-2 truncate"
>
{{ $t('g.updated') }}
{{
$d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
}}
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton :node-packs="[nodePack]" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const publisherName = computed(() => {
if (!nodePack) return null
const { n } = useI18n()
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
</script>

View File

@@ -20,6 +20,7 @@
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>

View File

@@ -36,7 +36,7 @@
text
size="small"
severity="secondary"
@click="() => authService.fetchBalance()"
@click="() => authActions.fetchBalance()"
/>
</div>
</div>
@@ -112,8 +112,8 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
@@ -127,7 +127,7 @@ interface CreditHistoryItemData {
const { t } = useI18n()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
@@ -142,7 +142,7 @@ const handlePurchaseCreditsClick = () => {
}
const handleCreditsHistoryClick = async () => {
await authService.accessBillingPortal()
await authActions.accessBillingPortal()
}
const handleMessageSupport = () => {

View File

@@ -1,6 +1,8 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -19,7 +21,16 @@ describe('SettingItem', () => {
const mountComponent = (props: any, options = {}): any => {
return mount(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()]
plugins: [PrimeVue, i18n, createPinia()],
components: {
Tag
},
directives: {
tooltip: Tooltip
},
stubs: {
'i-material-symbols:experiment-outline': true
}
},
props,
...options
@@ -43,4 +54,21 @@ describe('SettingItem', () => {
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
})
it('handles tooltips with @ symbols without errors', () => {
const wrapper = mountComponent({
setting: {
id: 'TestSetting',
name: 'Test Setting',
type: 'boolean',
tooltip:
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
}
})
// Should not throw an error and tooltip should be preserved as-is
expect(wrapper.vm.formItem.tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})
})

View File

@@ -28,6 +28,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import { st } from '@/i18n'
import { useSettingStore } from '@/stores/settingStore'
import type { SettingOption, SettingParams } from '@/types/settingTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -64,7 +65,7 @@ const formItem = computed(() => {
...props.setting,
name: t(`settings.${normalizedId}.name`, props.setting.name),
tooltip: props.setting.tooltip
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
: undefined,
options: props.setting.options
? translateOptions(props.setting.options)

View File

@@ -26,9 +26,9 @@
<h3 class="font-medium">
{{ $t('userSettings.email') }}
</h3>
<a :href="'mailto:' + userEmail" class="hover:underline">
<span class="text-muted">
{{ userEmail }}
</a>
</span>
</div>
<div class="flex flex-col gap-0.5">

View File

@@ -9,6 +9,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
@@ -39,7 +41,8 @@ const i18n = createI18n({
error: 'Invalid API Key',
helpText: 'Need an API key?',
generateKey: 'Get one here',
whitelistInfo: 'About non-whitelisted sites'
whitelistInfo: 'About non-whitelisted sites',
description: 'Use your Comfy API key to enable API Nodes'
}
},
g: {
@@ -108,7 +111,7 @@ describe('ApiKeyForm', () => {
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
'https://platform.comfy.org/login'
`${COMFY_PLATFORM_BASE_URL}/login`
)
})
})

View File

@@ -48,7 +48,7 @@
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
target="_blank"
class="text-blue-500 cursor-pointer"
>
@@ -87,6 +87,7 @@ import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'

View File

@@ -0,0 +1,293 @@
import { Form } from '@primevue/forms'
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
sendPasswordResetEmail: vi.fn()
}))
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}
}))
}))
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoading = false
})
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
},
props,
...options
})
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
})
it('shows button when not loading', () => {
mockLoading = false
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
it('renders password input with correct attributes', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
})

View File

@@ -7,15 +7,12 @@
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
{{ t('auth.login.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
:id="emailInputId"
autocomplete="email"
class="h-10"
name="email"
type="text"
@@ -37,8 +34,11 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="text-muted text-base font-medium cursor-pointer"
@click="handleForgotPassword($form.email?.value)"
class="text-muted text-base font-medium cursor-pointer select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
</span>
@@ -77,16 +77,18 @@ import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthService = useFirebaseAuthService()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
const { t } = useI18n()
@@ -94,14 +96,34 @@ const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
const handleForgotPassword = async (email: string) => {
if (!email) return
await firebaseAuthService.sendPasswordReset(email)
const handleForgotPassword = async (
email: string,
isValid: boolean | undefined
) => {
if (!email || !isValid) {
toast.add({
severity: 'warn',
summary: t('auth.login.emailPlaceholder'),
life: 5_000
})
// Focus the email input
document.getElementById(emailInputId)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -12,16 +12,17 @@
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, watch } from 'vue'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { type DomWidgetState, useDomWidgetStore } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(
() => Array.from(domWidgetStore.widgetStates.values()) as DomWidgetState[]
const widgetStates = computed(() =>
Array.from(domWidgetStore.widgetStates.values())
)
const updateWidgets = () => {
@@ -54,18 +55,13 @@ const updateWidgets = () => {
}
const canvasStore = useCanvasStore()
watch(
whenever(
() => canvasStore.canvas,
(lgCanvas) => {
if (!lgCanvas) return
lgCanvas.onDrawForeground = useChainCallback(
lgCanvas.onDrawForeground,
() => {
updateWidgets()
}
)
},
(canvas) =>
(canvas.onDrawForeground = useChainCallback(
canvas.onDrawForeground,
updateWidgets
)),
{ immediate: true }
)
</script>

View File

@@ -27,7 +27,6 @@
class="w-full h-full touch-none"
/>
<NodeBadge />
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover />
@@ -53,7 +52,6 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeBadge from '@/components/graph/NodeBadge.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
@@ -62,6 +60,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
@@ -71,7 +70,7 @@ import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n } from '@/i18n'
import { i18n, t } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -87,6 +86,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { getCurrentVersion } from '@/utils/versioning'
const emit = defineEmits<{
ready: []
@@ -169,6 +169,20 @@ watch(
await colorPaletteService.loadColorPalette(currentPaletteId)
}
)
watch(
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
async () => {
if (!canvasStore.canvas) return
const currentPaletteId = colorPaletteStore.activePaletteId
if (!currentPaletteId) return
// Reload color palette to apply background image
await colorPaletteService.loadColorPalette(currentPaletteId)
// Mark background canvas as dirty
canvasStore.canvas.setDirty(false, true)
}
)
watch(
() => colorPaletteStore.activePaletteId,
async (newValue) => {
@@ -225,39 +239,13 @@ watch(
}
)
// Save the drag & scale info in the serialized workflow if the setting is enabled
watch(
[
() => canvasStore.canvas,
() => settingStore.get('Comfy.EnableWorkflowViewRestore')
],
([canvas, enableWorkflowViewRestore]) => {
const extra = canvas?.graph?.extra
if (!extra) return
if (enableWorkflowViewRestore) {
extra.ds = {
get scale() {
return canvas.ds.scale
},
get offset() {
const [x, y] = canvas.ds.offset
return [x, y]
}
}
} else {
delete extra.ds
}
}
)
useEventListener(
canvasRef,
'litegraph:no-items-selected',
() => {
toastStore.add({
severity: 'warn',
summary: 'No items selected',
summary: t('toastMessages.nothingSelected'),
life: 2000
})
},
@@ -280,6 +268,7 @@ const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
onMounted(async () => {
useGlobalLitegraph()
@@ -293,7 +282,7 @@ onMounted(async () => {
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init(comfyApp)
ChangeTracker.init()
await loadCustomNodesI18n()
try {
await settingStore.loadSettingValues()
@@ -312,16 +301,21 @@ onMounted(async () => {
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})
if (!settingStore.get('Comfy.InstalledVersion')) {
const currentVersion =
Object.keys(settingStore.settingValues).length > 0
? '0.0.1'
: getCurrentVersion()
await settingStore.set('Comfy.InstalledVersion', currentVersion)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
// @ts-expect-error fixme ts strict error
window['app'] = comfyApp
// @ts-expect-error fixme ts strict error
window['graph'] = comfyApp.graph
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true

View File

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

View File

@@ -14,12 +14,10 @@
<script setup lang="ts">
import { createBounds } from '@comfyorg/litegraph'
import type { LGraphCanvas } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasStore } from '@/stores/graphStore'
const canvasStore = useCanvasStore()
@@ -28,8 +26,8 @@ const { style, updatePosition } = useAbsolutePosition()
const visible = ref(false)
const showBorder = ref(false)
const positionSelectionOverlay = (canvas: LGraphCanvas) => {
const selectedItems = canvas.selectedItems
const positionSelectionOverlay = () => {
const { selectedItems } = canvasStore.getCanvas()
showBorder.value = selectedItems.size > 1
if (!selectedItems.size) {
@@ -48,26 +46,18 @@ const positionSelectionOverlay = (canvas: LGraphCanvas) => {
}
// Register listener on canvas creation.
watch(
() => canvasStore.canvas as LGraphCanvas | null,
(canvas: LGraphCanvas | null) => {
if (!canvas) return
canvas.onSelectionChange = useChainCallback(
canvas.onSelectionChange,
// Wait for next frame as sometimes the selected items haven't been
// rendered yet, so the boundingRect is not available on them.
() => requestAnimationFrame(() => positionSelectionOverlay(canvas))
)
whenever(
() => canvasStore.getCanvas().state.selectionChanged,
() => {
requestAnimationFrame(() => {
positionSelectionOverlay()
canvasStore.getCanvas().state.selectionChanged = false
})
},
{ immediate: true }
)
whenever(
() => canvasStore.getCanvas().ds.state,
() => positionSelectionOverlay(canvasStore.getCanvas()),
{ deep: true }
)
canvasStore.getCanvas().ds.onChanged = positionSelectionOverlay
watch(
() => canvasStore.canvas?.state?.draggingItems,
@@ -77,10 +67,10 @@ watch(
// the correct position.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2656
if (draggingItems === false) {
setTimeout(() => {
requestAnimationFrame(() => {
visible.value = true
positionSelectionOverlay(canvasStore.canvas as LGraphCanvas)
}, 100)
positionSelectionOverlay()
})
} else {
// Selection change update to visible state is delayed by a frame. Here
// we also delay a frame so that the order of events is correct when

View File

@@ -6,96 +6,44 @@
content: 'p-0 flex flex-row'
}"
>
<ExecuteButton v-show="nodeSelected" />
<ColorPickerButton v-show="nodeSelected || groupSelected" />
<Button
v-show="nodeSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
}"
severity="secondary"
text
data-testid="bypass-button"
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
>
<template #icon>
<i-game-icons:detour />
</template>
</Button>
<Button
v-show="nodeSelected || groupSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-thumbtack"
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
/>
<Button
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="danger"
text
icon="pi pi-trash"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
<Button
v-show="isRefreshable"
severity="info"
text
icon="pi pi-refresh"
@click="refreshSelected"
/>
<Button
<ExecuteButton />
<ColorPickerButton />
<BypassButton />
<PinButton />
<MaskEditorButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
v-for="command in extensionToolboxCommands"
:key="command.id"
v-tooltip.top="{
value:
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
showDelay: 1000
}"
severity="secondary"
text
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
@click="() => commandStore.execute(command.id)"
:command="command"
/>
<HelpButton />
</Panel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
import { st, t } from '@/i18n'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
import { useExtensionService } from '@/services/extensionService'
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const { isRefreshable, refreshSelected } = useRefreshableSelection()
const nodeSelected = computed(() =>
canvasStore.selectedItems.some(isLGraphNode)
)
const groupSelected = computed(() =>
canvasStore.selectedItems.some(isLGraphGroup)
)
const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems
.map(
@@ -108,7 +56,7 @@ const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
)
return Array.from(commandIds)
.map((commandId) => commandStore.getCommand(commandId))
.filter((command) => command !== undefined)
.filter((command): command is ComfyCommandImpl => command !== undefined)
})
</script>

View File

@@ -0,0 +1,31 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
}"
severity="secondary"
text
data-testid="bypass-button"
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
>
<template #icon>
<i-game-icons:detour />
</template>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
</script>

View File

@@ -1,6 +1,7 @@
<template>
<div class="relative">
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
severity="secondary"
text
@click="() => (showColorPicker = !showColorPicker)"

View File

@@ -0,0 +1,22 @@
<template>
<Button
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="danger"
text
icon="pi pi-trash"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
</script>

View File

@@ -1,5 +1,6 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: isDisabled
? t('selectionToolbox.executeButton.disabledTooltip')
@@ -36,7 +37,7 @@ const buttonHovered = ref(false)
const selectedOutputNodes = computed(
() =>
canvasStore.selectedItems.filter(
(item) => isLGraphNode(item) && item.constructor.nodeData.output_node
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
) as LGraphNode[]
)
@@ -45,7 +46,7 @@ const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
function outputNodeStokeStyle(this: LGraphNode) {
if (
this.selected &&
this.constructor.nodeData.output_node &&
this.constructor.nodeData?.output_node &&
buttonHovered.value
) {
return { color: 'orange', lineWidth: 2, padding: 10 }

View File

@@ -0,0 +1,27 @@
<template>
<Button
v-tooltip.top="{
value:
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
showDelay: 1000
}"
severity="secondary"
text
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
@click="() => commandStore.execute(command.id)"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { st } from '@/i18n'
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{
command: ComfyCommand
}>()
const commandStore = useCommandStore()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<Button
v-show="nodeDef"
v-tooltip.top="{
value: $t('g.help'),
showDelay: 1000
}"
class="help-button"
text
icon="pi pi-question-circle"
severity="secondary"
@click="showHelp"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useCanvasStore } from '@/stores/graphStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
if (canvasStore.selectedItems.length !== 1) return null
const item = canvasStore.selectedItems[0]
if (!isLGraphNode(item)) return null
return nodeDefStore.fromLGraphNode(item)
})
const showHelp = () => {
const def = nodeDef.value
if (!def) return
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
}
nodeHelpStore.openHelp(def)
}
</script>

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