Compare commits

...

103 Commits

Author SHA1 Message Date
Terry Jia
6ea615fb8c graph mutation service implementation 2025-08-17 23:48:31 -04:00
Jin Yi
ceac8f3741 Modal Standardization (#4784) 2025-08-18 09:41:15 +09:00
Christian Byrne
b1057f164b [fix] Resolve group node execution error when connecting to external nodes (#5054)
* [fix] resolve group node execution error when connecting to external nodes

Fixed ExecutableGroupNodeChildDTO.resolveInput to properly handle connections from group node children to external nodes. The method now tries to find nodes by their full ID first (for external nodes) before falling back to the shortened ID (for internal group nodes).

Added comprehensive unit tests to prevent regression.

* [feat] Add error check for unsupported group nodes inside subgraphs

Added validation to detect when group node children are executing within subgraph contexts (execution ID has >2 segments) and provide clear error message directing users to convert to subgraphs instead.

Includes comprehensive test coverage for the new validation.
2025-08-17 16:39:06 -07:00
pythongosssss
4a189bdc93 Minor updates to subgraph breadcrumb item (#5060)
- change active item text to primary color
- change rename action to behave the same as double clicking label
2025-08-17 11:18:36 -07:00
Christian Byrne
f0adb4c9d3 [bugfix] Allow removeInput/removeOutput on nodes without graph reference (#5053)
- Modified removeInput/removeOutput to skip disconnect operations when node has no graph
- Both methods now safely handle nodes that aren't part of a graph
- Added comprehensive tests for the new behavior
- Fixes #5037
2025-08-17 11:14:53 -07:00
Christian Byrne
d5d0aa52c2 [refactor] Refactor minimap initialization logic (#5052)
* move ref initialization to the component

* remove redundant init
2025-08-17 10:52:25 -07:00
ComfyUI Wiki
69c660b3b7 handle minimap cleanup called before map set (#5038)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-08-17 09:46:59 -07:00
pythongosssss
88579c2a40 Update menu items with a active toggle state to not close menu when clicked (#5050) 2025-08-17 09:01:41 -07:00
Christian Byrne
7ab247aa1d Improve release command flow and GTM criteria (#5040)
- Reorganize steps to complete all analysis before execution
- Move Breaking Change Analysis to Step 3 (was Step 6)
- Move Dependency Analysis to Step 4 (was Step 7)
- Move GTM Feature Summary to Step 5 (was Step 16)
- Add stricter GTM criteria to avoid minor features
- Simplify PR data extraction to prevent timeouts
- Enhance Version Preview to suggest version based on analysis

These changes ensure critical analysis steps aren't skipped during
execution and provide clearer criteria for marketing-worthy features.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-16 12:25:28 -07:00
Alexander Piskun
c78d03dd2c api_nodes: added prices for Vidu Video nodes (#5035) 2025-08-16 07:45:15 -07:00
Comfy Org PR Bot
65785af348 [release] Increment version to 1.26.4 (#5032)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-15 20:21:20 -07:00
Arjan Singh
ec4ad5ea92 fix: issue #4121 (#5029) 2025-08-15 18:41:14 -07:00
Christian Byrne
e9ddf29507 [bugfix] Preserve nested subgraph widget values during serialization (#5023)
When saving workflows with nested subgraphs, promoted widget values were not being synchronized back to the subgraph definitions before serialization. This caused widget values to revert to their original defaults when reloading the workflow.

The fix overrides the serialize() method in SubgraphNode to sync promoted widget values to their corresponding widgets in the subgraph definition before serialization occurs.

Fixes the issue where nested subgraph widget values would be lost after save/reload.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-15 14:35:11 -07:00
AustinMroz
fdd8564c07 Deep copy subgraphs to clipboard, update nested ids on paste (#5003)
* Deep copy to clipboard, update nested ids on paste

The copyToClipboard function wasn't walking subgraphs and leaving nested
subgraphs unserialized. This has now been fixed.

This requires that equivalent support be added to _pasteFromClipboard to
update the ids of nested subgraphs which are pasted.

* Add extra advisory comments
2025-08-15 14:03:29 -07:00
Christian Byrne
d18081a54e fix: improve minimap subgraph navigation with graph UUID callback tracking (#5018)
- Replace single callback storage with Map using graph UUIDs as keys
- Fix minimap not updating when navigating between subgraphs
- Add proper cleanup and error handling for callback management
- Switch from app.canvas.graph to reactive workflowStore.activeSubgraph
- Prevent callback wrapping recursion by tracking setup state per graph
2025-08-15 13:34:44 -07:00
Christian Byrne
45cc6ca2b4 Fix widget disconnection issue in subgraphs #4922 (#5015)
* [bugfix] Fix widget disconnection issue in subgraphs

When disconnecting a node from a SubgraphInput, the target input's link
reference was not being cleared in LLink.disconnect(). This caused
widgets to remain greyed out because they still thought they were
connected (slot.link was not null).

The fix ensures that when a link is disconnected, the target node's
input slot is properly cleaned up by setting input.link = null.

Fixes #4922

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [test] Add tests for LLink disconnect fix for widget issue

Add comprehensive tests for the LLink.disconnect() method to verify
that target input link references are properly cleared when disconnecting.
This prevents widgets from remaining greyed out after disconnection.

Tests cover:
- Basic disconnect functionality with link reference cleanup
- Edge cases with invalid target nodes
- Preventing interference between different connections

Related to #4922

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-15 13:12:47 -07:00
Christian Byrne
c303a3f037 [fix] Complete traditional to simplified Chinese character conversion (#5013)
* [fix] Complete traditional to simplified Chinese character conversion

Fixes issue where the automated translation system was incorrectly
mixing traditional Chinese characters into simplified Chinese (zh)
locale files after PR #4410 added zh-TW support.

Changes:
- Updated .i18nrc.cjs with explicit guidelines for AI model to
  distinguish between simplified and traditional Chinese
- Fixed 50+ traditional characters in zh locale files:
  - commands.json: 畫→画, 減→减, 筆→笔
  - main.json: 關→关, 刪→删, 複→复, 製→制, 輸→输, etc.
  - settings.json: 舊→旧, 標→标, 選→选, etc.

Completed the systematic conversion work started in PRs #5005 and #4865
without overwriting any human translator decisions.

Fixes #5010

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update locales [skip ci]

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 13:01:05 -07:00
Alexander Piskun
c90fd18ade api_nodes: added prices for gpt-5 series models (#4958) 2025-08-15 12:36:18 -07:00
Johnpaul Chiwetelu
2ed1704749 Translated Keyboard Shortcuts (#5007)
* fix: Update command label rendering to use i18n normalization

* fix: Replace deprecated  with t for command label rendering

* fix: Simplify command rendering check in ShortcutsList tests

* fix: Add missing translation for command label in ShortcutsList tests
2025-08-15 11:45:10 -07:00
Christian Byrne
7d5a4d423e [feat] Improve low quality rendering zoom threshold tooltip (#5009)
* [docs] Improve low quality rendering zoom threshold tooltip

Clarify the behavior of the setting to explain that lower values maintain quality when zoomed out, while higher values enable simplified rendering at normal zoom levels.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update locales [skip ci]

* [docs] Improve low quality rendering zoom threshold tooltip

Clarify the behavior of the setting to explain that lower values maintain quality when zoomed out, while higher values enable simplified rendering at normal zoom levels.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update locales [skip ci]

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 11:44:26 -07:00
ComfyUI Wiki
7aaa0f022e fix: Correct traditional Chinese to simplified Chinese in translations (#5005)
* Correct some translations that use traditional Chinese to simplified Chinese.

* Update locales [skip ci]

* Correct the rest of the translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 10:34:48 -07:00
Yoland Yan
a132dad216 [test] Add tests for --disable-api-nodes release fetch skip functionality (#4799)
- Add comprehensive test coverage for the new --disable-api-nodes argument handling
- Tests verify release fetching is properly skipped when argument is present
- Cover edge cases including multiple args, null argv, and missing system stats
- Ensures backward compatibility when argument is not present
2025-08-15 10:14:41 -07:00
AustinMroz
9dbdc6a72b Fix inconsistency on bypass from context menu (#4988)
When a node is bypassed from the selection toolbox or by pressing a
keybind for bypass, it will also recursively bypass the contents of a
subgraph. This effect was not applied when clicking the bypass button
from the context menu. The context menu option has been updated to
perform the same action as the others so that behaviour is consistent.
2025-08-14 22:34:20 -07:00
Comfy Org PR Bot
7b228d693d [release] Increment version to 1.26.3 (#4995)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-14 21:17:23 -07:00
Christian Byrne
547af0e043 feat: Add GTM feature summary step to release command (#4990)
- Adds Step 16 to analyze PRs for marketing-worthy features
- Extracts PR data including media assets (images, videos, GIFs)
- Claude evaluates which features would interest end users
- Generates gtm-summary-VERSION.md for sharing with marketing team
- Many releases will correctly identify no marketing features (normal for bug fixes)

This helps the GTM team identify demo opportunities without manual PR review.
2025-08-14 18:26:20 -07:00
pythongosssss
4ca6220adf Refactor app menu items (#4665)
* Restructures the application menu
- rename Workflow to File
- move new & template items to top level
- add View menu and related sub items

Commands
- add "active" state getter shown as checkmark in the menu

Node side panel
- add refresh node defs
- change reset view icon

Help center
- change to use store for visibility

Fixes
- Fix bug with mouse down where if you drag mouse out, mouse up wasn't caught
- Fix issue with canvas info setting not triggering a redraw on change

* Fix missing translation warnings

* Add separator under new

* tidy

* Update locales [skip ci]

* fix some tests

* fix

* Hide icon if there is an active state within the menu item group

* Update locales [skip ci]

* Fix tests

* Implement feedback
- Remove queue, node lib, model lib, workflows, manager, help center
- Add minimap, link visibility

* Update locales [skip ci]

* Add plus icon on "New" menu item

* Update locales [skip ci]

* Fix test

* Fix translations

* Update locales [skip ci]

* Update locales [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-08-14 14:53:46 -07:00
Christian Byrne
1e41c6dc45 fix: Handle missing subgraph inputs gracefully during workflow import (#4985)
When loading workflows, SubgraphNode would throw an error if an input
exists in the serialized data that doesn't exist in the current subgraph
definition. This can happen when:
- Subgraph definitions change after workflows are saved
- Workflows are shared between users with different subgraph versions
- Dynamic inputs were added that don't exist in the base definition

This change converts the hard error to a warning and continues processing,
allowing workflows to load even with mismatched subgraph configurations.

Fixes #4905
2025-08-14 14:32:01 -07:00
Christian Byrne
5224c63bce [fix] Prevent incompatible connections to SubgraphInputNode occupied slots (#4984)
## Summary

This PR fixes #4681 by building upon the foundation laid in PR #1182
(litegraph.js). It prevents incompatible type connections when dragging
from a normal node's output to a SubgraphInputNode's occupied slot.

Before:


https://github.com/user-attachments/assets/03def938-dccc-4b2c-b65b-745abf02a13b

After:


https://github.com/user-attachments/assets/7a0a2ed4-9ecd-4147-be56-d643d448d4cb

## Background

PR #1182 implemented:
- `isValidTarget()` method in SubgraphInput/SubgraphOutput classes for
validation
- Visual feedback during drag (40% opacity for invalid targets)
- Validation at the slot level

However, there was a missing piece: while the visual feedback correctly
showed invalid targets, the actual connection would still be made when
dropped.

## Changes

This PR extends PR #1182 by adding the missing connection prevention:

1. **Added `canConnectToSubgraphInput()` method** to render link
classes:
   - `MovingOutputLink`
   - `ToOutputRenderLink`
   - `FloatingRenderLink`
- All methods use the existing `SubgraphInput.isValidTarget()` from PR
#1182

2. **Added validation in `LinkConnector.dropOnIoNode()`**:
   - Checks `canConnectToSubgraphInput()` before allowing the connection
   - Logs a warning when rejecting invalid connections
   - Follows the same pattern as regular node connections

3. **Added `isSubgraphInputValidDrop()` method**:
   - Provides validation for hover states
   - Ensures consistent validation across the UI
2025-08-14 12:51:43 -07:00
Alexander Piskun
89c78b0ecb fix pricing for KlingImage2VideoNode (#4957)
## Summary

Following up #4938 where I forgot to add pricing for new model in the
`KlingImage2VideoNode`.

## Screenshots (if applicable)

<img width="1461" height="1228" alt="Screenshot from 2025-08-13
09-15-21"
src="https://github.com/user-attachments/assets/01be8ab9-820b-4112-9a54-1ce4f23de4eb"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4957-fix-pricing-for-KlingImage2VideoNode-24e6d73d36508122b40ede36fdd50115)
by [Unito](https://www.unito.io)
2025-08-13 17:33:35 -07:00
Christian Byrne
4a3bd39650 [feat] Restore group node conversion menu with deprecated label (#4967)
## Summary
- Partially reverts commit c84218d6 to restore group node functionality
in context menus
- Adds "(Deprecated)" label to indicate the feature is deprecated
- Fixes TypeError when right-clicking on group nodes
- Re-enables tests that were disabled when the feature was removed

## Changes
1. **Restored context menu options** - Added back "Convert to Group Node
(Deprecated)" and "Manage Group Nodes" menu items
2. **Fixed null reference error** - Added null-safe operator to prevent
errors when right-clicking group nodes
3. **Re-enabled tests** - Restored 7 tests that were disabled in commit
586f8824

## Test plan
- [x] Right-click on canvas → verify "Convert to Group Node
(Deprecated)" appears
- [x] Right-click on nodes → verify the same menu option appears
- [x] Select multiple nodes and use the menu option → verify conversion
works
- [x] Right-click on group nodes → verify no errors occur
- [x] Run browser tests → verify all re-enabled tests pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4967-feat-Restore-group-node-conversion-menu-with-deprecated-label-24e6d73d36508149a6f2dbef47223e94)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 15:13:18 -07:00
Christian Byrne
db1b81b7ff fix: Add guards for _listenerController.abort() calls in SubgraphNode (#4968)
This fix adds guards before calling `_listenerController.abort()` to
prevent runtime errors when loading workflows. The guards check that
`_listenerController` exists and has an `abort` function before calling
it, matching the pattern used in Comfy-Org/litegraph.js#1134.

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/4907

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4968-fix-Add-guards-for-_listenerController-abort-calls-in-SubgraphNode-24e6d73d3650813ebeeed69ee676faeb)
by [Unito](https://www.unito.io)
2025-08-13 14:42:34 -07:00
Terry Jia
5e81343142 show group self color in minimap (#4954)
a tiny fix that show group self color in minimap when checking node
color

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4954-show-group-self-color-in-minimap-24e6d73d3650812dbc58e9b134805f2d)
by [Unito](https://www.unito.io)
2025-08-13 14:39:08 -07:00
AustinMroz
6566acb406 Bundled subgraph fixes (#4964)
### Group support for subgraph unpacking
The unpacking code would silently delete groups (the cosmetic colored
rectangles). They are now correctly transferred.
### Fix subgraph node position on conversion to subgraph
Converting to subgraph will no longer cause nodes to inch upwards

![subgraph-conversion-positioning](https://github.com/user-attachments/assets/e120c3f9-5602-4dba-9075-c1eadb534f9a)
### Make unpacking use same positioning calcs as conversion
Non trivial, but unpacking is now a proper inverse for conversion.

![subgraph-conversion-inverse](https://github.com/user-attachments/assets/4fcaffca-1c97-4d71-93f7-1af569b1c941)
### Clean up old output links when unpacking
Unpacked nodes were left with dangling outputs. This would cause
cascading issues later, such as when consecutively unpacking nested
subgraphs.
### Minor refactoring for code clarity

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4964-Bundled-subgraph-fixes-24e6d73d365081d3a043ef1531d9d38a)
by [Unito](https://www.unito.io)
2025-08-13 13:04:44 -07:00
pythongosssss
efc0431a5e Update side toolbar menu (#4946)
Side toolbar menu UI updates

## Summary

- Currently the template modal is very hidden. Many users do not find it
- The current icons are quite aleatory 

## Changes

 **What**: 
- Add templates shortcut button
- Add item label in normal size
- Use custom icon

Critical design decisions or edge cases that need attention:
- Sidebar tabs registered using custom icons will have their associated
command registed with an undefined icon (currently only string icons are
accepted, not components). I couldn't see anywhere directly using this
icon, but we should consider autogenerating an icon font so we can use
classes for our custom icons (or locating and updating locations to
support both icon types)

## Screenshots (if applicable)
Normal mode:
<img width="621" height="1034" alt="image"
src="https://github.com/user-attachments/assets/c1d1cee2-004e-4ff8-b3fa-197329b0d2ae"
/>

Small mode:
<img width="176" height="325" alt="image"
src="https://github.com/user-attachments/assets/3824b8f6-bc96-4e62-aece-f0265113d2e3"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4946-Update-side-toolbar-menu-24d6d73d365081c5b2bdc0ee8b61dc50)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 11:46:03 -07:00
snomiao
1784e2b5a3 [feat] Add alternative package manager lockfiles to .gitignore (#4961)
## Summary
- Add bun.lock, bun.lockb, pnpm-lock.yaml, and yarn.lock to .gitignore
- Allows users to use faster package managers (Bun, pnpm) without making
git status dirty
- Maintains npm as the default while supporting developer choice of
package manager

## Test plan
- [x] Verify .gitignore changes are correct
- [ ] Test that creating these lockfiles doesn't show in git status
- [ ] Confirm existing npm functionality remains unaffected

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4961-feat-Add-alternative-package-manager-lockfiles-to-gitignore-24e6d73d3650817c8fa4fb8e94df5ac6)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-13 09:08:37 -07:00
snomiao
39df4ac9da [feat] Add Linux core dump to .gitignore (#4960)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 07:35:45 -07:00
AustinMroz
eba0b42674 Trigger updateSelectedItems on subgraph conversion (#4949)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-12 20:16:48 -07:00
Alexander Piskun
ef1852d551 gemini-2.5-pro and flash models; corrected prices (#4945) 2025-08-12 14:52:44 -07:00
Alexander Piskun
983ebb2ba7 pricing update for MinimaxHailuoVideo node and Kling "kling-v2-1" model (#4938) 2025-08-12 14:15:06 -07:00
AustinMroz
db71365768 Implement subgraph unpacking (#4840) 2025-08-12 13:45:29 -07:00
Christian Byrne
17d7ba8bcb [ci] Add lint:fix:no-cache script for consistency (#4948)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-12 13:25:22 -07:00
Christian Byrne
24a386c766 fix: add cleanup for graphChanged listener in useWorkflowPersistence (#4947) 2025-08-12 12:27:04 -07:00
Christian Byrne
c42c9315f4 [refactor] Replace lodash with es-toolkit (#4935) 2025-08-12 12:22:09 -07:00
Sidharth
d068b8351e Feat: add duplicate workflow on sidebar (#4895) 2025-08-12 12:04:29 -07:00
Christian Byrne
1cf8087be0 [ci] Optimize ESLint performance with caching and generated file excl… (#4926)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-12 13:31:54 -04:00
filtered
9c31d708a2 Add automatic trackpad / mouse sensing (#4913) 2025-08-12 10:15:32 -07:00
Comfy Org PR Bot
9a70e927aa 1.26.2 (#4939)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-12 00:52:23 -07:00
arab-future-academy
dc444faa75 Feature/arabic translation (#4916) 2025-08-11 23:29:01 -07:00
Jennifer Weber
a055ec2dff [bugfix] Fix queue not updating with completed task images (#4936)
Co-authored-by: Jennifer Weber <weberjc@MacBookPro.lan>
2025-08-11 22:36:43 -07:00
Christian Byrne
2138ceea80 [fix] ensure consistent link release behavior for subgraph IO nodes (#4931) 2025-08-11 19:28:15 -07:00
Christian Byrne
7972550f6b [fix] Fix link deletion from middle button when connected to reroute nodes deletes wrong link (#4928)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-11 19:26:37 -07:00
Chenlei Hu
c7baf3c340 [feat] Add knip for unused code detection (#4890) 2025-08-11 19:23:08 -07:00
Terry Jia
8403bd0e3e minimap improve (#4679)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-11 14:04:02 -07:00
Christian Byrne
90f54414ab fix: Multiple links from reroute create single slot on SubgraphOutputNode (#4915)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 13:12:31 -07:00
Christian Byrne
505c242ff4 [refactor] Replace stringOrEmpty with lodash toString (#4917)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 11:48:59 -07:00
Christian Byrne
fbc6edde25 [feat] Add red styling to Remove Slot context menu option (#4918)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 11:48:02 -07:00
AustinMroz
2c215a6251 Fix subgraph reroute serialization (#4911) 2025-08-11 11:46:32 -07:00
Christian Byrne
71a43193df [feat] Make hotkey for exiting subgraphs configurable in user keybindings (#4818)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-11 10:22:13 -07:00
Christian Byrne
d0d13bfe4c [ci] standardize release notes format in release commands (#4912) 2025-08-11 10:07:29 -07:00
Christian Byrne
a1a8d48544 [feat] Replace removeFromArray with lodash pull (#4906)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 09:13:02 -07:00
Terry Jia
d22d62b670 [3d] initial version of 3d viewer (#3968)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-10 21:09:19 -07:00
Chenlei Hu
8e357c41e3 [feat] Add PR creation automation command (#4892)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-10 19:53:35 -04:00
Christian Byrne
c4912dcd54 [fix] Add bounds checking for clipspace indices to prevent paste errors (#4849)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-10 15:45:28 -07:00
Comfy Org PR Bot
109542dca3 1.26.1 (#4889)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-09 19:48:35 -07:00
Christian Byrne
ffc812a8f5 [refactor] Remove unused omitBy function (#4886)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 19:12:48 -07:00
Christian Byrne
b745f533ba [feat] Replace manual clamp function with lodash (#4874)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 15:34:18 -07:00
Christian Byrne
8f289c8e67 Fix Alt-Click-Drag-Copy of Subgraph Nodes (#4879) 2025-08-09 15:33:59 -07:00
Vivek Chavan
79b4c78116 fix: hide More menu when no submenu items are visible (#4837) 2025-08-09 15:12:31 -07:00
Vivek Chavan
48aea928e0 fix: hide Desktop User Guide menu item in web builds (#4828) 2025-08-09 15:08:33 -07:00
pythongosssss
03ad06ea14 Add preview to workflow tabs (#4290) 2025-08-09 14:39:40 -07:00
filtered
ff5943f770 Reorder subgraph context menu items (#4870)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 14:20:26 -07:00
Christian Byrne
b1117b9838 [ci] Add chromium-0.5x to test matrix (#4880)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 14:15:08 -07:00
filtered
2d11fb1f90 [CI] Pin third party GH actions to specific SHAs (#4878) 2025-08-09 13:18:43 -07:00
Christian Byrne
e70b127f2a Revert animated-image-preview-saved-webp snapshot change from #4863 (#4873)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 12:31:16 -07:00
Makki Shizu
0d8e4fe719 Fix Simplified Chinese Translation (#4865) 2025-08-09 11:23:30 -07:00
filtered
5f5f44b310 Fix execution breaks on multi/any-type slots (#4864) 2025-08-09 11:17:10 -07:00
filtered
b42878a9da Remove unused Litegraph context menu options (#4867)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 11:14:54 -07:00
Christian Byrne
5cc269eff1 Fix Alt+click create reroute (2/2) (#4863)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 11:13:37 -07:00
Vivek Chavan
16d7436883 Fix: Alt+click reroute creation on high-DPI displays (#4831) 2025-08-09 08:59:19 -07:00
AustinMroz
db452c1e63 Fix disconnection from subgraph inputs (#4800) 2025-08-09 03:45:52 -04:00
Chenlei Hu
10d80165c4 [bugfix] Fix subgraph I/O slot rename dialog showing stale label content (#4852)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-08 23:40:26 -07:00
Benjamin Lu
c3997dfdb0 docs: add AGENTS.md file (#4858)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-08 23:39:59 -07:00
Chenlei Hu
7bbbf59722 feat: Enable double-click on subgraph slot labels for renaming (#4833) 2025-08-08 18:11:21 -07:00
Christian Byrne
8bf60777e7 [CI] Exclude vue-nodes-migration branch from playwright tests (#4844) 2025-08-08 16:52:25 -07:00
AustinMroz
ba28fa4621 Support preview display on subgraphNodes (#4814) 2025-08-08 13:58:31 -07:00
Chenlei Hu
95ab88693c feat: Add smooth slide-up animation to SelectionToolbox (#4832) 2025-08-07 21:34:10 -07:00
Vivek Chavan
5d71d6f9cf fix: correct branch protection status contexts for RC branches (#4829) 2025-08-07 19:06:41 -07:00
AustinMroz
8899b425a8 Rename subgraph widgets when slot is renamed (#4821) 2025-08-07 15:18:18 -07:00
AustinMroz
1fc4fd2ca8 Remove subgraphs from add node context menu (#4820) 2025-08-07 14:54:14 -07:00
Christian Byrne
1b9bacaeef [fix] Handle fork PRs in lint-and-format workflow (#4819) 2025-08-07 13:51:02 -07:00
snomiao
65cc06771c [ci] Merge ESLint and Prettier workflows with auto-fix for faster iteration (#4638) 2025-08-07 11:58:34 -07:00
Christian Byrne
3c154d8487 [refactor] Remove 5 unused settings from apiSchema (#4811)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-07 11:52:58 -07:00
Christian Byrne
c6c20e53fb [docs] Improve icon documentation with practical examples (#4810) 2025-08-07 11:52:40 -07:00
Johnpaul Chiwetelu
70c06d10bb Keyboard Shortcut Bottom Panel (#4635) 2025-08-07 11:51:23 -07:00
Comfy Org PR Bot
f4482eb35a 1.26.0 (#4812)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-07 11:37:26 -07:00
Copilot
e77411d9da Add touch screen dragging support to minimap (#4781)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: snomiao <7323030+snomiao@users.noreply.github.com>
2025-08-07 17:36:27 +08:00
Christian Byrne
04f8ae416f [docs] Create CONTRIBUTING.md and reorganize documentation (#4804) 2025-08-07 00:07:52 -07:00
Christian Byrne
e63337ccbe [docs] Add feature flags documentation (#4797) 2025-08-07 00:04:26 -07:00
AustinMroz
385c56d93d Fix widget display on subgraph nodes (#4798) 2025-08-06 23:38:07 -07:00
Christian Byrne
f3b82e377a [fix] Display dynamic keybinding in minimap tooltip (#4805) 2025-08-06 22:18:20 -07:00
Christian Byrne
85aa89da45 [test] Regenerate template test screenshots (#4795)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-06 17:29:35 -07:00
Johnpaul Chiwetelu
5e9b8785a5 Scroll templates better (#4584) 2025-08-06 16:13:29 -07:00
Christian Byrne
386eb9391a Revert test PR for backport workflow (#4794) 2025-08-06 15:53:43 -07:00
295 changed files with 26899 additions and 3062 deletions

View File

@@ -111,50 +111,7 @@ echo "Last stable release: $LAST_STABLE"
```
7. **HUMAN ANALYSIS**: Review change summary and verify scope
### Step 3: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 4: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 5: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 6: Breaking Change Analysis
### Step 3: Breaking Change Analysis
1. Analyze API changes in:
- Public TypeScript interfaces
@@ -169,7 +126,7 @@ echo "Last stable release: $LAST_STABLE"
3. Generate breaking change summary
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
### Step 7: Analyze Dependency Updates
### Step 4: Analyze Dependency Updates
1. **Check significant dependency updates:**
```bash
@@ -195,7 +152,117 @@ echo "Last stable release: $LAST_STABLE"
done
```
### Step 8: Generate Comprehensive Release Notes
### Step 5: Generate GTM Feature Summary
1. **Collect PR data for analysis:**
```bash
# Get list of PR numbers from commits
PR_NUMBERS=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | \
grep -oE "#[0-9]+" | tr -d '#' | sort -u)
# Save PR data for each PR
echo "[" > prs-${NEW_VERSION}.json
first=true
for PR in $PR_NUMBERS; do
[[ "$first" == true ]] && first=false || echo "," >> prs-${NEW_VERSION}.json
gh pr view $PR --json number,title,author,body,labels 2>/dev/null >> prs-${NEW_VERSION}.json || echo "{}" >> prs-${NEW_VERSION}.json
done
echo "]" >> prs-${NEW_VERSION}.json
```
2. **Analyze for GTM-worthy features:**
```
<task>
Review these PRs to identify features worthy of marketing attention.
A feature is GTM-worthy if it meets ALL of these criteria:
- Introduces a NEW capability users didn't have before (not just improvements)
- Would be a compelling reason for users to upgrade to this version
- Can be demonstrated visually or has clear before/after comparison
- Affects a significant portion of the user base
NOT GTM-worthy:
- Bug fixes (even important ones)
- Minor UI tweaks or color changes
- Performance improvements without user-visible impact
- Internal refactoring
- Small convenience features
- Features that only improve existing functionality marginally
For each GTM-worthy feature, note:
- PR number, title, and author
- Media links from the PR description
- One compelling sentence on why users should care
If there are no GTM-worthy features, just say "No marketing-worthy features in this release."
</task>
PR data: [contents of prs-${NEW_VERSION}.json]
```
3. **Generate GTM notification:**
```bash
# Save to gtm-summary-${NEW_VERSION}.md based on analysis
# If GTM-worthy features exist, include them with testing instructions
# If not, note that this is a maintenance/bug fix release
# Check if notification is needed
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
echo "✅ No GTM notification needed for this release"
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
else
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
echo "📤 Share this file in #gtm channel to notify the team"
fi
```
### Step 6: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number based on analysis:
- Major version if breaking changes detected
- Minor version if new features added
- Patch version if only bug fixes
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 7: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 8: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 9: Generate Comprehensive Release Notes
1. Extract commit messages since base release:
```bash
@@ -210,31 +277,54 @@ echo "Last stable release: $LAST_STABLE"
echo "WARNING: PR #$PR not on main branch!"
done
```
3. Create comprehensive release notes including:
- **Version Change**: Show version bump details
- **Changelog** grouped by type:
- 🚀 **Features** (feat:)
- 🐛 **Bug Fixes** (fix:)
- 💥 **Breaking Changes** (BREAKING CHANGE)
- 📚 **Documentation** (docs:)
- 🔧 **Maintenance** (chore:, refactor:)
- ⬆️ **Dependencies** (deps:, dependency updates)
- **Litegraph Changes** (if version updated):
- 🚀 Features: ${LITEGRAPH_FEATURES}
- 🐛 Bug Fixes: ${LITEGRAPH_FIXES}
- 💥 Breaking Changes: ${LITEGRAPH_BREAKING}
- 🔧 Other Changes: ${LITEGRAPH_OTHER}
- **Other Major Dependencies**: ${OTHER_DEP_CHANGES}
- Include PR numbers and links
- Add issue references (Fixes #123)
4. **Save release notes:**
3. Create standardized release notes using this exact template:
```bash
# Save release notes for PR and GitHub release
echo "$RELEASE_NOTES" > release-notes-${NEW_VERSION}.md
```
5. **CONTENT REVIEW**: Release notes clear and comprehensive with dependency details?
cat > release-notes-${NEW_VERSION}.md << 'EOF'
## ⚠️ Breaking Changes
<!-- List breaking changes if any, otherwise remove this entire section -->
- Breaking change description (#PR_NUMBER)
### Step 9: Create Version Bump PR
---
## What's Changed
### 🚀 Features
<!-- List features here, one per line with PR reference -->
- Feature description (#PR_NUMBER)
### 🐛 Bug Fixes
<!-- List bug fixes here, one per line with PR reference -->
- Bug fix description (#PR_NUMBER)
### 🔧 Maintenance
<!-- List refactoring, chore, and other maintenance items -->
- Maintenance item description (#PR_NUMBER)
### 📚 Documentation
<!-- List documentation changes if any, remove section if empty -->
- Documentation update description (#PR_NUMBER)
### ⬆️ Dependencies
<!-- List dependency updates -->
- Updated dependency from vX.X.X to vY.Y.Y (#PR_NUMBER)
**Full Changelog**: https://github.com/Comfy-Org/ComfyUI_frontend/compare/${BASE_TAG}...v${NEW_VERSION}
EOF
```
4. **Parse commits and populate template:**
- Group commits by conventional commit type (feat:, fix:, chore:, etc.)
- Extract PR numbers from commit messages
- For breaking changes, analyze if changes affect:
- Public APIs (app object, api module)
- Extension/workspace manager APIs
- Node schema, workflow schema, or other public schemas
- Any other public-facing interfaces
- For dependency updates, list version changes with PR numbers
- Remove empty sections (e.g., if no documentation changes)
- Ensure consistent bullet format: `- Description (#PR_NUMBER)`
5. **CONTENT REVIEW**: Release notes follow standard format?
### Step 10: Create Version Bump PR
**For standard version bumps (patch/minor/major):**
```bash
@@ -273,40 +363,14 @@ echo "Workflow triggered. Waiting for PR creation..."
--body-file release-notes-${NEW_VERSION}.md \
--label "Release"
```
3. **Add required sections to PR body:**
3. **Update PR with release notes:**
```bash
# Create PR body with release notes plus required sections
cat > pr-body.md << EOF
${RELEASE_NOTES}
## Breaking Changes
${BREAKING_CHANGES:-None}
## Testing Performed
- ✅ Full test suite (unit, component)
- ✅ TypeScript compilation
- ✅ Linting checks
- ✅ Build verification
- ✅ Security audit
## Distribution Channels
- GitHub Release (with dist.zip)
- PyPI Package (comfyui-frontend-package)
- npm Package (@comfyorg/comfyui-frontend-types)
## Post-Release Tasks
- [ ] Verify all distribution channels
- [ ] Update external documentation
- [ ] Monitor for issues
EOF
# For workflow-created PRs, update the body with our release notes
gh pr edit ${PR_NUMBER} --body-file release-notes-${NEW_VERSION}.md
```
4. Update PR with enhanced description:
```bash
gh pr edit ${PR_NUMBER} --body-file pr-body.md
```
5. **PR REVIEW**: Version bump PR created and enhanced correctly?
4. **PR REVIEW**: Version bump PR created with standardized release notes?
### Step 10: Critical Release PR Verification
### Step 11: Critical Release PR Verification
1. **CRITICAL**: Verify PR has "Release" label:
```bash
@@ -328,7 +392,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 11: Pre-Merge Validation
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
@@ -336,7 +400,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 12: Execute Release
### Step 13: Execute Release
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
2. Merge the Release PR:
@@ -369,7 +433,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh run watch ${WORKFLOW_RUN_ID}
```
### Step 13: Enhance GitHub Release
### Step 14: Enhance GitHub Release
1. Wait for automatic release creation:
```bash
@@ -397,7 +461,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh release view v${NEW_VERSION}
```
### Step 14: Verify Multi-Channel Distribution
### Step 15: Verify Multi-Channel Distribution
1. **GitHub Release:**
```bash
@@ -435,7 +499,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
### Step 15: Post-Release Monitoring Setup
### Step 16: Post-Release Monitoring Setup
1. **Monitor immediate release health:**
```bash
@@ -505,11 +569,49 @@ echo "Workflow triggered. Waiting for PR creation..."
## Files Generated
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
EOF
```
4. **RELEASE COMPLETION**: All post-release setup completed?
### Step 17: Create Release Summary
1. **Create comprehensive release summary:**
```bash
cat > release-summary-${NEW_VERSION}.md << EOF
# Release Summary: ComfyUI Frontend v${NEW_VERSION}
**Released:** $(date)
**Type:** ${VERSION_TYPE}
**Duration:** ~${RELEASE_DURATION} minutes
**Release Commit:** ${RELEASE_COMMIT}
## Metrics
- **Commits Included:** ${COMMITS_COUNT}
- **Contributors:** ${CONTRIBUTORS_COUNT}
- **Files Changed:** ${FILES_CHANGED}
- **Lines Added/Removed:** +${LINES_ADDED}/-${LINES_REMOVED}
## Distribution Status
- ✅ GitHub Release: Published
- ✅ PyPI Package: Available
- ✅ npm Types: Available
## Next Steps
- Monitor for 24-48 hours
- Address any critical issues immediately
- Plan next release cycle
## Files Generated
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
EOF
```
2. **RELEASE COMPLETION**: All steps completed successfully?
## Advanced Safety Features
### Rollback Procedures
@@ -592,55 +694,46 @@ The command implements multiple quality gates:
- Draft release status
- Python package specs require that prereleases use alpha/beta/rc as the preid
## Common Issues and Solutions
## Critical Implementation Notes
### Issue: Pre-release Version Confusion
**Problem**: Not sure whether to promote pre-release or create new version
**Solution**:
- Follow semver standards: a prerelease version is followed by a normal release. It should have the same major, minor, and patch versions as the prerelease.
When executing this release process, pay attention to these key aspects:
### Issue: Wrong Commit Count
**Problem**: Changelog includes commits from other branches
**Solution**: Always use `--first-parent` flag with git log
### Version Handling
- For pre-release versions (e.g., 1.24.0-rc.1), the next stable release should be the same version without the suffix (1.24.0)
- Never skip version numbers - follow semantic versioning strictly
**Update**: Sometimes update-locales doesn't add [skip ci] - always verify!
### Commit History Analysis
- **ALWAYS** use `--first-parent` flag with git log to avoid including commits from merged feature branches
- Verify PR merge targets before including them in changelogs:
```bash
gh pr view ${PR_NUMBER} --json baseRefName
```
### Issue: Missing PRs in Changelog
**Problem**: PR was merged to different branch
**Solution**: Verify PR merge target with:
```bash
gh pr view ${PR_NUMBER} --json baseRefName
```
### Release Workflow Triggers
- The "Release" label on the PR is **CRITICAL** - without it, PyPI/npm publishing won't occur
- Check for `[skip ci]` in commit messages before merging - this blocks the release workflow
- If you encounter `[skip ci]`, push an empty commit to override it:
```bash
git commit --allow-empty -m "Trigger release workflow"
```
### Issue: Incomplete Dependency Changelog
**Problem**: Litegraph or other dependency updates only show version bump, not actual changes
**Solution**: The command now automatically:
- Detects litegraph version changes between releases
- Clones the litegraph repository temporarily
- Extracts and categorizes changes between versions
- Includes detailed litegraph changelog in release notes
- Cleans up temporary files after analysis
### PR Creation Details
- Version bump PRs come from `comfy-pr-bot`, not `github-actions`
- The workflow typically completes in 20-30 seconds
- Always wait for the PR to be created before trying to edit it
### Issue: Release Failed Due to [skip ci]
**Problem**: Release workflow didn't trigger after merge
**Prevention**: Always avoid this scenario
- Ensure that `[skip ci]` or similar flags are NOT in the `HEAD` commit message of the PR
- Push a new, empty commit to the PR
- Always double-check this immediately before merging
### Breaking Changes Detection
- Analyze changes to public-facing APIs:
- The `app` object and its methods
- The `api` module exports
- Extension and workspace manager interfaces
- Node schema, workflow schema, and other public schemas
- Any modifications to these require marking as breaking changes
**Recovery Strategy**:
1. Revert version in a new PR (e.g., 1.24.0 → 1.24.0-1)
2. Merge the revert PR
3. Run version bump workflow again
4. This creates a fresh PR without [skip ci]
Benefits: Cleaner than creating extra version numbers
## Key Learnings & Notes
1. **PR Author**: Version bump PRs are created by `comfy-pr-bot`, not `github-actions`
2. **Workflow Speed**: Version bump workflow typically completes in ~20-30 seconds
3. **Update-locales Behavior**: Inconsistent - sometimes adds [skip ci], sometimes doesn't
4. **Recovery Options**: Reverting version is cleaner than creating extra versions
5. **Dependency Tracking**: Command now automatically includes litegraph and major dependency changes in changelogs
6. **Litegraph Integration**: Temporary cloning of litegraph repo provides detailed change analysis between versions
### Recovery Procedures
If the release workflow fails to trigger:
1. Create a revert PR to restore the previous version
2. Merge the revert
3. Re-run the version bump workflow
4. This approach is cleaner than creating extra version numbers

View File

@@ -138,14 +138,50 @@ For each commit:
```bash
gh pr create --base core/X.Y --head release/1.23.5 \
--title "[Release] v1.23.5" \
--body "..." \
--body "Release notes will be added shortly..." \
--label "Release"
```
3. **CRITICAL**: Verify "Release" label is added
4. PR description should include:
- Version: `1.23.4` → `1.23.5`
- Included fixes (link to previous PR)
- Release notes for users
4. Create standardized release notes:
```bash
cat > release-notes-${NEW_VERSION}.md << 'EOF'
## ⚠️ Breaking Changes
<!-- List breaking changes if any, otherwise remove this entire section -->
- Breaking change description (#PR_NUMBER)
---
## What's Changed
### 🚀 Features
<!-- List features here, one per line with PR reference -->
- Feature description (#PR_NUMBER)
### 🐛 Bug Fixes
<!-- List bug fixes here, one per line with PR reference -->
- Bug fix description (#PR_NUMBER)
### 🔧 Maintenance
<!-- List refactoring, chore, and other maintenance items -->
- Maintenance item description (#PR_NUMBER)
### 📚 Documentation
<!-- List documentation changes if any, remove section if empty -->
- Documentation update description (#PR_NUMBER)
### ⬆️ Dependencies
<!-- List dependency updates -->
- Updated dependency from vX.X.X to vY.Y.Y (#PR_NUMBER)
**Full Changelog**: https://github.com/Comfy-Org/ComfyUI_frontend/compare/v${CURRENT_VERSION}...v${NEW_VERSION}
EOF
```
- For hotfixes, typically only populate the "Bug Fixes" section
- Include links to the cherry-picked PRs/commits
- Update the PR body with the release notes:
```bash
gh pr edit ${PR_NUMBER} --body-file release-notes-${NEW_VERSION}.md
```
5. **CONFIRMATION REQUIRED**: Release PR has "Release" label?
### Step 11: Monitor Release Process

131
.claude/commands/pr.md Normal file
View File

@@ -0,0 +1,131 @@
# Create PR
Automate PR creation with proper tags, labels, and concise summary.
## Step 1: Check Prerequisites
```bash
# Ensure you have uncommitted changes
git status
# If changes exist, commit them first
git add .
git commit -m "[tag] Your commit message"
```
## Step 2: Push and Create PR
You'll create the PR with the following structure:
### PR Tags (use in title)
- `[feat]` - New features → label: `enhancement`
- `[bugfix]` - Bug fixes → label: `verified bug`
- `[refactor]` - Code restructuring → label: `enhancement`
- `[docs]` - Documentation → label: `documentation`
- `[test]` - Test changes → label: `enhancement`
- `[ci]` - CI/CD changes → label: `enhancement`
### Label Mapping
#### General Labels
- Feature/Enhancement: `enhancement`
- Bug fixes: `verified bug`
- Documentation: `documentation`
- Dependencies: `dependencies`
- Performance: `Performance`
- Desktop app: `Electron`
#### Product Area Labels
**Core Features**
- `area:nodes` - Node-related functionality
- `area:workflows` - Workflow management
- `area:queue` - Queue system
- `area:models` - Model handling
- `area:templates` - Template system
- `area:subgraph` - Subgraph functionality
**UI Components**
- `area:ui` - General user interface improvements
- `area:widgets` - Widget system
- `area:dom-widgets` - DOM-based widgets
- `area:links` - Connection links between nodes
- `area:groups` - Node grouping functionality
- `area:reroutes` - Reroute nodes
- `area:previews` - Preview functionality
- `area:minimap` - Minimap navigation
- `area:floating-toolbox` - Floating toolbar
- `area:mask-editor` - Mask editing tools
**Navigation & Organization**
- `area:navigation` - Navigation system
- `area:search` - Search functionality
- `area:workspace-management` - Workspace features
- `area:topbar-menu` - Top bar menu
- `area:help-menu` - Help menu system
**System Features**
- `area:settings` - Settings/preferences
- `area:hotkeys` - Keyboard shortcuts
- `area:undo-redo` - Undo/redo system
- `area:customization` - Customization features
- `area:auth` - Authentication
- `area:comms` - Communication/networking
**Development & Infrastructure**
- `area:CI/CD` - CI/CD pipeline
- `area:testing` - Testing infrastructure
- `area:vue-migration` - Vue migration work
- `area:manager` - ComfyUI Manager integration
**Platform-Specific**
- `area:mobile` - Mobile support
- `area:3d` - 3D-related features
**Special Areas**
- `area:i18n` - Translation/internationalization
- `area:CNR` - Comfy Node Registry
## Step 3: Execute PR Creation
```bash
# First, push your branch
git push -u origin $(git branch --show-current)
# Then create the PR (replace placeholders)
gh pr create \
--title "[TAG] Brief description" \
--body "$(cat <<'EOF'
## Summary
One sentence describing what changed and why.
## Changes
- **What**: Core functionality added/modified
- **Breaking**: Any breaking changes (if none, omit this line)
- **Dependencies**: New dependencies (if none, omit this line)
## Review Focus
- Critical design decisions or edge cases that need attention
Fixes #ISSUE_NUMBER
EOF
)" \
--label "APPROPRIATE_LABEL" \
--base main
```
## Additional Options
- Add multiple labels: `--label "enhancement,Performance"`
- Request reviewers: `--reviewer @username`
- Mark as draft: `--draft`
- Open in browser after creation: `--web`

View File

@@ -49,7 +49,7 @@ DO NOT use deprecated PrimeVue components. Use these replacements instead:
## Development Guidelines
1. Leverage VueUse functions for performance-enhancing styles
2. Use lodash for utility functions
2. Use es-toolkit for utility functions
3. Use TypeScript for type safety
4. Implement proper props and emits definitions
5. Utilize Vue 3's Teleport component when needed

View File

@@ -18,7 +18,7 @@ Use Tailwind CSS for styling
Leverage VueUse functions for performance-enhancing styles
Use lodash for utility functions
Use es-toolkit for utility functions
Use TypeScript for type safety

20
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,20 @@
## Summary
<!-- One sentence describing what changed and why. -->
## Changes
- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line) -->
- **Dependencies**: <!-- New dependencies (if none, remove this line) -->
## Review Focus
<!-- Critical design decisions or edge cases that need attention -->
<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->
## Screenshots (if applicable)
<!-- Add screenshots or video recording to help explain your changes -->

View File

@@ -19,10 +19,10 @@ jobs:
should-proceed: ${{ steps.check-status.outputs.proceed }}
steps:
- name: Wait for other CI checks
uses: lewagon/wait-on-check-action@v1.3.1
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
with:
ref: ${{ github.event.pull_request.head.sha }}
check-regexp: '^(eslint|prettier|test|playwright-tests)'
check-regexp: '^(lint-and-format|test|playwright-tests)'
wait-interval: 30
repo-token: ${{ secrets.GITHUB_TOKEN }}
@@ -30,7 +30,7 @@ jobs:
id: check-status
run: |
# Get all check runs for this commit
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("eslint|prettier|test|playwright-tests")) | {name, conclusion}')
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("lint-and-format|test|playwright-tests")) | {name, conclusion}')
# Check if any required checks failed
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then

View File

@@ -145,7 +145,7 @@ jobs:
-d '{
"required_status_checks": {
"strict": true,
"contexts": ["build", "test"]
"contexts": ["lint-and-format", "test", "playwright-tests"]
},
"enforce_admins": false,
"required_pull_request_reviews": {

View File

@@ -1,17 +0,0 @@
name: ESLint
on:
pull_request:
branches-ignore: [ wip/*, draft/*, temp/* ]
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- run: npm ci
- run: npm run lint

View File

@@ -1,23 +0,0 @@
name: Prettier Check
on:
pull_request:
branches-ignore: [ wip/*, draft/*, temp/* ]
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Run Prettier check
run: npm run format:check

83
.github/workflows/lint-and-format.yaml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Lint and Format
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
permissions:
contents: write
pull-requests: write
jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint with auto-fix
run: npm run lint:fix
- name: Run Prettier with auto-format
run: npm run format
- name: Check for changes
id: verify-changed-files
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit changes
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add .
git commit -m "[auto-fix] Apply ESLint and Prettier fixes"
git push
- name: Final validation
run: |
npm run lint
npm run format:check
npm run knip
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
})
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\nnpm run prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\nnpm run lint:fix\nnpm run format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
})

View File

@@ -4,7 +4,7 @@ on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration]
jobs:
setup:
@@ -60,7 +60,7 @@ jobs:
strategy:
fail-fast: false
matrix:
browser: [chromium, chromium-2x, mobile-chrome]
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Wait for cache propagation
run: sleep 10

View File

@@ -61,6 +61,11 @@ jobs:
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated ComfyUI-Manager API types..."
npm run lint:fix:no-cache -- ./src/types/generatedManagerTypes.ts
- name: Check for changes
id: check-changes
run: |
@@ -75,7 +80,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 ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'

View File

@@ -61,6 +61,11 @@ jobs:
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
npm run lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |

14
.gitignore vendored
View File

@@ -7,6 +7,15 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Package manager lockfiles (allow users to use different package managers)
bun.lock
bun.lockb
pnpm-lock.yaml
yarn.lock
# ESLint cache
.eslintcache
node_modules
dist
dist-ssr
@@ -58,5 +67,8 @@ dist.zip
# Temporary repository directory
templates_repo/
# Vites timestamped config modules
# Vite's timestamped config modules
vite.config.mts.timestamp-*.mjs
# Linux core dumps
./core

View File

@@ -9,10 +9,14 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
IMPORTANT Chinese Translation Guidelines:
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
`
});

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# Repository Guidelines
## Project Structure & Module Organization
- Source: `src/` (Vue 3 + TypeScript). Key areas: `components/`, `views/`, `stores/` (Pinia), `composables/`, `services/`, `utils/`, `assets/`, `locales/`.
- Routing/i18n/entry: `src/router.ts`, `src/i18n.ts`, `src/main.ts`.
- Tests: unit/component in `tests-ui/` and `src/components/**/*.{test,spec}.ts`; E2E in `browser_tests/`.
- Public assets: `public/`. Build output: `dist/`.
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.js`, `.prettierrc`.
## Build, Test, and Development Commands
- `npm run dev`: Start Vite dev server.
- `npm run dev:electron`: Dev server with Electron API mocks.
- `npm run build`: Type-check then production build to `dist/`.
- `npm run preview`: Preview the production build locally.
- `npm run test:unit`: Run Vitest unit tests (`tests-ui/`).
- `npm run test:component`: Run component tests (`src/components/`).
- `npm run test:browser`: Run Playwright E2E tests (`browser_tests/`).
- `npm run lint` / `npm run lint:fix`: Lint (ESLint). `npm run format` / `format:check`: Prettier.
- `npm run typecheck`: Vue TSC type checking.
## Coding Style & Naming Conventions
- Language: TypeScript, Vue SFCs (`.vue`). Indent 2 spaces; single quotes; no semicolons; width 80 (see `.prettierrc`).
- Imports: sorted/grouped by plugin; run `npm run format` before committing.
- ESLint: Vue + TS rules; no floating promises; unused imports disallowed; i18n raw text restrictions in templates.
- Naming: Vue components in PascalCase (e.g., `MenuHamburger.vue`); composables `useXyz.ts`; Pinia stores `*Store.ts`.
## Testing Guidelines
- Frameworks: Vitest (unit/component, happy-dom) and Playwright (E2E).
- Test files: `**/*.{test,spec}.{ts,tsx,js}` under `tests-ui/`, `src/components/`, and `src/lib/litegraph/test/`.
- Coverage: text/json/html reporters enabled; aim to cover critical logic and new features.
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
## Commit & Pull Request Guidelines
- Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate.
- PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable.
- Quality gates: `npm run lint`, `npm run typecheck`, and relevant tests must pass. Keep PRs focused and small.
## Security & Configuration Tips
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`.

333
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,333 @@
# Contributing to ComfyUI Frontend
We're building this frontend together and would love your help — no matter how you'd like to pitch in! You don't need to write code to make a difference.
## Ways to Contribute
- **Pull Requests:** Add features, fix bugs, or improve code health. Browse [issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues) for inspiration. Look for the `Good first issue` label if you're new to the project.
- **Vote on Features:** Give a 👍 to the feature requests you care about to help us prioritize.
- **Verify Bugs:** Try reproducing reported issues and share your results (even if the bug doesn't occur!).
- **Community Support:** Hop into our [Discord](https://discord.com/invite/comfyorg) to answer questions or get help.
- **Share & Advocate:** Tell your friends, tweet about us, or share tips to support the project.
Have another idea? Drop into Discord or open an issue, and let's chat!
## Development Setup
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (v16 or later; v20/v22 strongly recommended) and npm
- Git for version control
- A running ComfyUI backend instance
- **Tech Stack**:
- [Vue 3.5 Composition API](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 (integrated in src/lib) 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
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit hook is used to auto-format code on commit.
### Dev Server
Note: The dev server will NOT load any extension from the ComfyUI server. Only core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
- Run `npm run dev:electron` to start the dev server with electron API mocked
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
After you start the dev server, you should see following logs:
```
> comfyui-frontend@1.3.42 dev
> vite
VITE v5.4.6 ready in 488 ms
➜ Local: http://localhost:5173/
➜ Network: http://172.21.80.1:5173/
➜ Network: http://192.168.2.20:5173/
➜ press h + enter to show help
```
Make sure your desktop machine and touch device are on the same network. On your touch device,
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
## Development Workflow
### Architecture Decision Records
We document significant architectural decisions using ADRs (Architecture Decision Records). See [docs/adr/](docs/adr/) for all ADRs and the template for creating new ones.
### Backporting Changes to Release Branches
When you fix a bug that affects a version in feature freeze, we use an automated backport process to apply the fix to the release candidate branch.
#### Real Example
- Subgraphs feature was released in v1.24
- While developing v1.25, we discovered a bug in subgraphs
- v1.24 is in feature freeze (only accepting bug fixes, no new features)
- The fix needs to be applied to both main (v1.25) and the v1.24 release candidate
#### How to Backport Your Fix
1. Create your PR fixing the bug on `main` branch as usual
2. Before merging, add these labels to your PR:
- `needs-backport` - triggers the automated backport workflow
- `1.24` - targets the `core/1.24` release candidate branch
3. Merge your PR normally
4. The automated workflow will:
- Create a new branch from `core/1.24`
- Apply your changes to that branch
- Open a new PR to `core/1.24`
- Comment on your original PR with a link to the backport PR
#### When to Use Backporting
- Bug fixes for features already released
- Security fixes
- Critical issues affecting existing functionality
- Never for new features (these wait for the next release cycle)
#### Handling Conflicts
If the automated cherry-pick fails due to conflicts, the workflow will comment on your PR with:
- The list of conflicting files
- Instructions to manually cherry-pick to the release candidate branch
See [PR #4616](https://github.com/Comfy-Org/ComfyUI_frontend/pull/4616) for the actual subgraph bugfix that was backported from v1.25 to v1.24.
## Code Editor Configuration
### Recommended Setup
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.).
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.
### 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.
```
## Testing
### Unit Tests
- `npm i` to install all dependencies
- `npm run test:unit` to execute all unit tests
### Component Tests
Component tests verify Vue components in `src/components/`.
- `npm run test:component` to execute all component tests
### Playwright Tests
Playwright tests verify the whole app. See [browser_tests/README.md](browser_tests/README.md) for details.
### Running All Tests
Before submitting a PR, ensure all tests pass:
```bash
npm run test:unit
npm run test:component
npm run test:browser
npm run typecheck
npm run lint
npm run format
```
## Code Style Guidelines
### TypeScript
- Use TypeScript for all new code
- Avoid `any` types - use proper type definitions
- Never use `@ts-expect-error` - fix the underlying type issue
### Vue 3 Patterns
- Use Composition API for all components
- Follow Vue 3.5+ patterns (props destructuring is reactive)
- Use `<script setup>` syntax
### Styling
- Use Tailwind CSS classes instead of custom CSS
- Follow the existing dark theme pattern: `dark-theme:` prefix (not `dark:`)
### Internationalization
- All user-facing strings must use vue-i18n
- Add translations to `src/locales/en/main.json`
- Use translation keys: `const { t } = useI18n(); t('key.path')`
## Icons
The project supports three types of icons, all with automatic imports (no manual imports needed):
1. **PrimeIcons** - Built-in PrimeVue icons using CSS classes: `<i class="pi pi-plus" />`
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i-lucide:settings />`, `<i-mdi:folder />`
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `src/assets/icons/custom/`.
For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md).
## Working with litegraph.js
Since Aug 5, 2025, litegraph.js is now integrated directly into this repository. It was merged using git subtree to preserve the complete commit history ([PR #4667](https://github.com/Comfy-Org/ComfyUI_frontend/pull/4667), [ADR](docs/adr/0001-merge-litegraph-into-frontend.md)).
### Important Notes
- **Issue References**: Commits from the original litegraph repository may contain issue/PR numbers (e.g., #4667) that refer to issues/PRs in the original litegraph.js repository, not this one.
- **File Paths**: When viewing historical commits, file paths may show the original structure before the subtree merge. In those cases, just consider the paths relative to the new litegraph folder.
- **Contributing**: All litegraph modifications should now be made directly in this repository.
The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is now archived.
## Submitting Changes
### Pull Request Process
1. Ensure your branch is up to date with main
2. Run all tests and ensure they pass
3. Create a pull request with a clear title and description
4. Use conventional commit format for PR titles:
- `[feat]` for new features
- `[fix]` for bug fixes
- `[docs]` for documentation
- `[refactor]` for code refactoring
- `[test]` for test additions/changes
- `[chore]` for maintenance tasks
### PR Description Template
```
## Description
Brief description of the changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
- [ ] Unit tests pass
- [ ] Component tests pass
- [ ] Browser tests pass (if applicable)
- [ ] Manual testing completed
## Screenshots (if applicable)
Add screenshots for UI changes
```
### Review Process
1. All PRs require at least one review
2. Address review feedback promptly
3. Keep PRs focused - one feature/fix per PR
4. Large features should be discussed in an issue first
## Questions?
If you have questions about contributing:
- Check existing issues and discussions
- Ask in our [Discord](https://discord.com/invite/comfyorg)
- Open a new issue for clarification
Thank you for contributing to ComfyUI Frontend!

203
README.md
View File

@@ -4,8 +4,6 @@
**Official front-end implementation of [ComfyUI](https://github.com/comfyanonymous/ComfyUI).**
<!-- Testing automatic backport workflow -->
[![Website][website-shield]][website-url]
[![Discord][discord-shield]][discord-url]
[![Matrix][matrix-shield]][matrix-url]
@@ -77,7 +75,7 @@ The development of successive minor versions overlaps. For example, while versio
<summary>v1.5: Native translation (i18n)</summary>
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, Korean, or Arabic. This native
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
More details available here: https://blog.comfy.org/p/native-localization-support-i18n
@@ -514,201 +512,18 @@ The selection toolbox will display the command button when items are selected:
## Contributing
We're building this frontend together and would love your help — no matter how you'd like to pitch in! You don't need to write code to make a difference.
We welcome contributions to ComfyUI Frontend! Please see our [Contributing Guide](CONTRIBUTING.md) for:
Here are some ways to get involved:
- **Pull Requests:** Add features, fix bugs, or improve code health. Browse [issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues) for inspiration.
- **Vote on Features:** Give a 👍 to the feature requests you care about to help us prioritize.
- **Verify Bugs:** Try reproducing reported issues and share your results (even if the bug doesn't occur!).
- **Community Support:** Hop into our [Discord](https://www.comfy.org/discord) to answer questions or get help.
- **Share & Advocate:** Tell your friends, tweet about us, or share tips to support the project.
Have another idea? Drop into Discord or open an issue, and let's chat!
### Architecture Decision Records
We document significant architectural decisions using ADRs (Architecture Decision Records). See [docs/adr/](docs/adr/) for all ADRs and the template for creating new ones.
- Ways to contribute (code, documentation, testing, community support)
- Development setup and workflow
- Code style guidelines
- Testing requirements
- How to submit pull requests
- Backporting fixes to release branches
## Development
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (v16 or later; v20/v22 strongly recommended) 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
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
hook is used to auto-format code on commit.
### Dev Server
Note: The dev server will NOT load any extension from the ComfyUI server. Only
core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
- Run `npm run dev:electron` to start the dev server with electron API mocked
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
After you start the dev server, you should see following logs:
```
> comfyui-frontend@1.3.42 dev
> vite
VITE v5.4.6 ready in 488 ms
➜ Local: http://localhost:5173/
➜ Network: http://172.21.80.1:5173/
➜ Network: http://192.168.2.20:5173/
➜ press h + enter to show help
```
Make sure your desktop machine and touch device are on the same network. On your touch device,
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
### Recommended Code Editor Configuration
This project includes `.vscode/launch.json.default` and `.vscode/settings.json.default` files with recommended launch and workspace settings for editors that use the `.vscode` directory (e.g., VS Code, Cursor, etc.).
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
- `npm i` to install all dependencies
- `npm run test:unit` to execute all unit tests.
### Component Test
Component test verifies Vue components in `src/components/`.
- `npm run test:component` to execute all component tests.
### Playwright Test
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
### Custom Icons
The project supports custom SVG icons through the unplugin-icons system. Custom icons are stored in `src/assets/icons/custom/` and can be used as Vue components with the `i-comfy:` prefix.
For detailed instructions on adding and using custom icons, see [src/assets/icons/README.md](src/assets/icons/README.md).
### litegraph.js
Since Aug 5, 2025, litegraph.js is now integrated directly into this repository. It was merged using git subtree to preserve the complete commit history ([PR #4667](https://github.com/Comfy-Org/ComfyUI_frontend/pull/4667), [ADR](docs/adr/0001-merge-litegraph-into-frontend.md)).
#### Important Notes
- **Issue References**: Commits from the original litegraph repository may contain issue/PR numbers (e.g., #4667) that refer to issues/PRs in the original litegraph.js repository, not this one.
- **File Paths**: When viewing historical commits, file paths may show the original structure before the subtree merge. In those cases, just consider the paths relative to the new litegraph folder.
- **Contributing**: All litegraph modifications should now be made directly in this repository.
The original litegraph repository (<https://github.com/Comfy-Org/litegraph.js>) is now archived.
For detailed development setup, testing procedures, and technical information, please refer to [CONTRIBUTING.md](CONTRIBUTING.md).
### i18n

View File

@@ -0,0 +1,259 @@
{
"id": "dec788c2-9829-4a5d-a1ee-d6f0a678b42a",
"revision": 0,
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413, 389],
"size": [425.27801513671875, 180.6060791015625],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [415, 186],
"size": [422.84503173828125, 164.31304931640625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473, 609],
"size": [315, 106],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [863, 186],
"size": [315, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209, 188],
"size": [210, 46],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1451, 189],
"size": [210, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [26, 474],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [1]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
}
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"reroutes": [
{
"id": 1,
"pos": [372.66668701171875, 415.33331298828125],
"linkIds": [3]
}
],
"linkExtensions": [
{
"id": 3,
"parentId": 1
}
],
"frontendVersion": "1.26.1"
},
"version": 0.4
}

View File

@@ -767,8 +767,8 @@ export class ComfyPage {
await this.nextFrame()
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' })
async rightClickCanvas(x: number = 10, y: number = 10) {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
}
@@ -786,6 +786,164 @@ export class ComfyPage {
await this.nextFrame()
}
/**
* Core helper method for interacting with subgraph I/O slots.
* Handles both input/output slots and both right-click/double-click actions.
*
* @param slotType - 'input' or 'output'
* @param action - 'rightClick' or 'doubleClick'
* @param slotName - Optional specific slot name to target
* @private
*/
private async interactWithSubgraphSlot(
slotType: 'input' | 'output',
action: 'rightClick' | 'doubleClick',
slotName?: string
): Promise<void> {
const foundSlot = await this.page.evaluate(
async (params) => {
const { slotType, action, targetSlotName } = params
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the appropriate node and slots
const node =
slotType === 'input'
? currentGraph.inputNode
: currentGraph.outputNode
const slots =
slotType === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) {
throw new Error(`No ${slotType} node found in subgraph`)
}
if (!slots || slots.length === 0) {
throw new Error(`No ${slotType} slots found in subgraph`)
}
// Filter slots based on target name and action type
const slotsToTry = targetSlotName
? slots.filter((slot) => slot.name === targetSlotName)
: action === 'rightClick'
? slots
: [slots[0]] // Right-click tries all, double-click uses first
if (slotsToTry.length === 0) {
throw new Error(
targetSlotName
? `${slotType} slot '${targetSlotName}' not found`
: `No ${slotType} slots available to try`
)
}
// Handle the interaction based on action type
if (action === 'rightClick') {
// Right-click: try each slot until one works
for (const slot of slotsToTry) {
if (!slot.pos) continue
const event = {
canvasX: slot.pos[0],
canvasY: slot.pos[1],
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return {
success: true,
slotName: slot.name,
x: slot.pos[0],
y: slot.pos[1]
}
}
}
} else if (action === 'doubleClick') {
// Double-click: use first slot with bounding rect center
const slot = slotsToTry[0]
if (!slot.boundingRect) {
throw new Error(`${slotType} slot bounding rect not found`)
}
const rect = slot.boundingRect
const testX = rect[0] + rect[2] / 2 // x + width/2
const testY = rect[1] + rect[3] / 2 // y + height/2
const event = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(event)
}
}
// Wait briefly for dialog to appear
await new Promise((resolve) => setTimeout(resolve, 200))
return { success: true, slotName: slot.name, x: testX, y: testY }
}
return { success: false }
},
{ slotType, action, targetSlotName: slotName }
)
if (!foundSlot.success) {
const actionText =
action === 'rightClick' ? 'open context menu for' : 'double-click'
throw new Error(
slotName
? `Could not ${actionText} ${slotType} slot '${slotName}'`
: `Could not find any ${slotType} slot to ${actionText}`
)
}
// Wait for the appropriate UI element to appear
if (action === 'rightClick') {
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
} else {
await this.nextFrame()
}
}
/**
* Right-clicks on a subgraph input slot to open the context menu.
* Must be called when inside a subgraph.
@@ -800,93 +958,7 @@ export class ComfyPage {
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetInputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the input node
const inputNode = currentGraph.inputNode
if (!inputNode) {
throw new Error('No input node found in subgraph')
}
// Get available inputs
const inputs = currentGraph.inputs
if (!inputs || inputs.length === 0) {
throw new Error('No input slots found in subgraph')
}
// Filter to specific input if requested
const inputsToTry = targetInputName
? inputs.filter((inp) => inp.name === targetInputName)
: inputs
if (inputsToTry.length === 0) {
throw new Error(
targetInputName
? `Input slot '${targetInputName}' not found`
: 'No input slots available to try'
)
}
// Try right-clicking on each input slot position until one works
for (const input of inputsToTry) {
if (!input.pos) continue
const testX = input.pos[0]
const testY = input.pos[1]
// Create a right-click event at the input slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the input node's right-click handler
if (inputNode.onPointerDown) {
inputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, inputName: input.name, x: testX, y: testY }
}
}
return { success: false }
}, inputName)
if (!foundSlot.success) {
throw new Error(
inputName
? `Could not open context menu for input slot '${inputName}'`
: 'Could not find any input slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
return this.interactWithSubgraphSlot('input', 'rightClick', inputName)
}
/**
@@ -900,93 +972,31 @@ export class ComfyPage {
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
return this.interactWithSubgraphSlot('output', 'rightClick', outputName)
}
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
/**
* Double-clicks on a subgraph input slot to rename it.
* Must be called when inside a subgraph.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries the first available input slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickSubgraphInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'doubleClick', inputName)
}
// Get the output node
const outputNode = currentGraph.outputNode
if (!outputNode) {
throw new Error('No output node found in subgraph')
}
// Get available outputs
const outputs = currentGraph.outputs
if (!outputs || outputs.length === 0) {
throw new Error('No output slots found in subgraph')
}
// Filter to specific output if requested
const outputsToTry = targetOutputName
? outputs.filter((out) => out.name === targetOutputName)
: outputs
if (outputsToTry.length === 0) {
throw new Error(
targetOutputName
? `Output slot '${targetOutputName}' not found`
: 'No output slots available to try'
)
}
// Try right-clicking on each output slot position until one works
for (const output of outputsToTry) {
if (!output.pos) continue
const testX = output.pos[0]
const testY = output.pos[1]
// Create a right-click event at the output slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the output node's right-click handler
if (outputNode.onPointerDown) {
outputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, outputName: output.name, x: testX, y: testY }
}
}
return { success: false }
}, outputName)
if (!foundSlot.success) {
throw new Error(
outputName
? `Could not open context menu for output slot '${outputName}'`
: 'Could not find any output slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
/**
* Double-clicks on a subgraph output slot to rename it.
* Must be called when inside a subgraph.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries the first available output slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickSubgraphOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'doubleClick', outputName)
}
/**

View File

@@ -50,7 +50,7 @@ export class Topbar {
workflowName: string,
command: 'Save' | 'Save As' | 'Export'
) {
await this.triggerTopbarCommand(['Workflow', command])
await this.triggerTopbarCommand(['File', command])
await this.getSaveDialog().fill(workflowName)
await this.page.keyboard.press('Enter')
@@ -72,8 +72,8 @@ export class Topbar {
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
if (path.length < 1) {
throw new Error('Path cannot be empty')
}
const menu = await this.openTopbarMenu()
@@ -85,6 +85,13 @@ export class Topbar {
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })
await topLevelMenu.waitFor({ state: 'visible' })
// Handle top-level commands (like "New")
if (path.length === 1) {
await topLevelMenuItem.click()
return
}
await topLevelMenu.hover()
let currentMenu = topLevelMenu

View File

@@ -1,5 +1,5 @@
import _ from 'es-toolkit/compat'
import fs from 'fs'
import _ from 'lodash'
import path from 'path'
import type { Request, Route } from 'playwright'
import { v4 as uuidv4 } from 'uuid'
@@ -75,7 +75,9 @@ export default class TaskHistory {
private async handleGetView(route: Route) {
const fileName = getFilenameParam(route.request())
if (!this.outputContentTypes.has(fileName)) route.continue()
if (!this.outputContentTypes.has(fileName)) {
return route.continue()
}
const asset = this.loadAsset(fileName)
return route.fulfill({

View File

@@ -0,0 +1,280 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Bottom Panel Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
// Click shortcuts toggle button in sidebar
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Click toggle button again to hide
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Panel should be hidden again
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
})
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Essentials tab should be visible and active by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toBeVisible()
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
// Should display shortcut categories
await expect(
comfyPage.page.locator('.subcategory-title').first()
).toBeVisible()
// Should display some keyboard shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
// Should have workflow, node, and queue sections
await expect(
comfyPage.page.getByRole('heading', { name: 'Workflow' })
).toBeVisible()
await expect(
comfyPage.page.getByRole('heading', { name: 'Node' })
).toBeVisible()
await expect(
comfyPage.page.getByRole('heading', { name: 'Queue' })
).toBeVisible()
})
test('should display view controls shortcuts tab', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
// View controls tab should be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
// Should display view controls shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
// Should have view and panel controls sections
await expect(
comfyPage.page.getByRole('heading', { name: 'View' })
).toBeVisible()
await expect(
comfyPage.page.getByRole('heading', { name: 'Panel Controls' })
).toBeVisible()
})
test('should switch between shortcuts tabs', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Essentials should be active initially
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
// View controls should now be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).not.toHaveAttribute('aria-selected', 'true')
// Switch back to essentials
await comfyPage.page.getByRole('tab', { name: /Essential/i }).click()
// Essentials should be active again
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).not.toHaveAttribute('aria-selected', 'true')
})
test('should display formatted keyboard shortcuts', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Wait for shortcuts to load
await comfyPage.page.waitForSelector('.key-badge')
// Check for common formatted keys
const keyBadges = comfyPage.page.locator('.key-badge')
const count = await keyBadges.count()
expect(count).toBeGreaterThanOrEqual(1)
// Should show formatted modifier keys
const badgeText = await keyBadges.allTextContents()
const hasModifiers = badgeText.some((text) =>
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
)
expect(hasModifiers).toBeTruthy()
})
test('should maintain panel state when switching to terminal', async ({
comfyPage
}) => {
// Open shortcuts panel first
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Open terminal panel (should switch panels)
await comfyPage.page
.locator('button[aria-label*="Toggle Bottom Panel"]')
.click()
// Panel should still be visible but showing terminal content
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Switch back to shortcuts
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Should show shortcuts content again
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
})
test('should handle keyboard navigation', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Focus the first tab
await comfyPage.page.getByRole('tab', { name: /Essential/i }).focus()
// Use arrow keys to navigate between tabs
await comfyPage.page.keyboard.press('ArrowRight')
// View controls tab should now have focus
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toBeFocused()
// Press Enter to activate the tab
await comfyPage.page.keyboard.press('Enter')
// Tab should be selected
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
})
test('should close panel by clicking shortcuts button again', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Click shortcuts button again to close
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
})
test('should display shortcuts in organized columns', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Should have 3-column grid layout
await expect(comfyPage.page.locator('.md\\:grid-cols-3')).toBeVisible()
// Should have multiple subcategory sections
const subcategoryTitles = comfyPage.page.locator('.subcategory-title')
const titleCount = await subcategoryTitles.count()
expect(titleCount).toBeGreaterThanOrEqual(2)
})
test('should open shortcuts panel with Ctrl+Shift+K', async ({
comfyPage
}) => {
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
// Press Ctrl+Shift+K to open shortcuts panel
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Should show essentials tab by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
})
test('should open settings dialog when clicking manage shortcuts button', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Manage shortcuts button should be visible
await expect(
comfyPage.page.getByRole('button', { name: /Manage Shortcuts/i })
).toBeVisible()
// Click manage shortcuts button
await comfyPage.page
.getByRole('button', { name: /Manage Shortcuts/i })
.click()
// Settings dialog should open with keybinding tab
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
// Should show keybinding settings (check for keybinding-related content)
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()
})
})

View File

@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test.skip('Can be added to canvas using node library sidebar', async ({
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
).toHaveLength(0)
})
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
)
})
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test.skip('Manage group opens with the correct group selected', async ({
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
expect(visibleInputCount).toBe(2)
})
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
@@ -268,10 +268,7 @@ test.describe('Group Node', () => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.menu.topbar.triggerTopbarCommand([
'Edit',
'Clear Workflow'
])
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
@@ -280,7 +277,7 @@ test.describe('Group Node', () => {
test('Copies and pastes group node into a newly created blank workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
@@ -296,7 +293,7 @@ test.describe('Group Node', () => {
test('Serializes group node after copy and paste across workflows', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.ctrlV()
const currentGraphState = await comfyPage.page.evaluate(() =>
window['app'].graph.serialize()

View File

@@ -1,4 +1,4 @@
import { expect } from '@playwright/test'
import { Locator, expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
@@ -684,7 +684,7 @@ test.describe('Load workflow', () => {
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage to persist the workflow paths before reloading
@@ -767,6 +767,17 @@ test.describe('Viewport settings', () => {
comfyPage,
comfyMouse
}) => {
const changeTab = async (tab: Locator) => {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(comfyPage.emptySpace)
// If tooltip is visible, wait for it to hide
await expect(
comfyPage.page.locator('.workflow-popover-fade')
).toHaveCount(0)
}
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
@@ -794,15 +805,13 @@ test.describe('Viewport settings', () => {
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await tabA.click()
await comfyPage.nextFrame()
await changeTab(tabA)
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotA
)
// And back to Workflow B
await tabB.click()
await comfyPage.nextFrame()
await changeTab(tabB)
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotB
)

View File

@@ -73,9 +73,80 @@ test.describe('Menu', () => {
expect(isTextCutoff).toBe(false)
})
test('Clicking on active state items does not close menu', async ({
comfyPage
}) => {
// Open the menu
await comfyPage.menu.topbar.openTopbarMenu()
const menu = comfyPage.page.locator('.comfy-command-menu')
// Navigate to View menu
const viewMenuItem = comfyPage.page.locator(
'.p-menubar-item-label:text-is("View")'
)
await viewMenuItem.hover()
// Wait for submenu to appear
const viewSubmenu = comfyPage.page
.locator('.p-tieredmenu-submenu:visible')
.first()
await viewSubmenu.waitFor({ state: 'visible' })
// Find Bottom Panel menu item
const bottomPanelMenuItem = viewSubmenu
.locator('.p-tieredmenu-item:has-text("Bottom Panel")')
.first()
const bottomPanelItem = bottomPanelMenuItem.locator(
'.p-menubar-item-label:text-is("Bottom Panel")'
)
await bottomPanelItem.waitFor({ state: 'visible' })
// Get checkmark icon element
const checkmark = bottomPanelMenuItem.locator('.pi-check')
// Check initial state of bottom panel (it's initially hidden)
const bottomPanel = comfyPage.page.locator('.bottom-panel')
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible initially (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click Bottom Panel to toggle it on
await bottomPanelItem.click()
// Verify menu is still visible after clicking
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is now visible
await expect(bottomPanel).toBeVisible()
// Checkmark should now be visible (panel is shown)
await expect(checkmark).not.toHaveClass(/invisible/)
// Click Bottom Panel again to toggle it off
await bottomPanelItem.click()
// Verify menu is still visible after second click
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is hidden again
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')
await workflowMenuItem.hover()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'

View File

@@ -24,8 +24,14 @@ test.describe('Minimap', () => {
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
await expect(minimapContainer).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
'.minimap-main-container'
)
await expect(minimapMainContainer).toHaveCSS('position', 'absolute')
await expect(minimapMainContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {

View File

@@ -18,7 +18,7 @@ test.describe('Reroute Node', () => {
[workflowName]: workflowName
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
// Insert the workflow
const workflowsTab = comfyPage.menu.workflowsTab
@@ -48,7 +48,9 @@ test.describe('LiteGraph Native Reroute Node', () => {
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
})
test('Can add reroute by alt clicking on link', async ({ comfyPage }) => {
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
comfyPage
}) => {
const loadCheckpointNode = (
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
)[0]
@@ -98,4 +100,29 @@ test.describe('LiteGraph Native Reroute Node', () => {
'native_reroute_context_menu.png'
)
})
test('Can delete link that is connected to two reroutes', async ({
comfyPage
}) => {
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/4695
await comfyPage.loadWorkflow(
'reroute/single-native-reroute-default-workflow'
)
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
// since the link is a bezier curve and not a straight line.
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
// Click the middle point of the link to open the context menu.
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
// Click the "Delete" context menu option.
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_delete_from_midpoint_context_menu.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -24,11 +24,11 @@ test.describe('Canvas Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test.skip('Can convert to group node', async ({ comfyPage }) => {
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Convert to Group Node')
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -317,6 +317,25 @@ test.describe('Workflows sidebar', () => {
])
})
test('Can duplicate workflow from context menu', async ({ comfyPage }) => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json'
})
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await comfyPage.clickContextMenuItem('Duplicate')
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*workflow1 (Copy).json'
])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json'

View File

@@ -0,0 +1,157 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// Constants
const INITIAL_NAME = 'initial_slot_name'
const RENAMED_NAME = 'renamed_slot_name'
const SECOND_RENAMED_NAME = 'second_renamed_name'
// Common selectors
const SELECTORS = {
promptDialog: '.graphdialog input'
} as const
test.describe('Subgraph Slot Rename Dialog', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial slot label
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || graph.inputs?.[0]?.name || null
})
// First rename
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the rename worked
const afterFirstRename = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
const slot = graph.inputs?.[0]
return {
label: slot?.label || null,
name: slot?.name || null,
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_NAME)
// Now rename again - this is where the bug would show
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.rightClickSubgraphInputSlot()
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
// Complete the second rename to ensure everything still works
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the second rename worked
const afterSecondRename = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
})
test('Shows current output slot label in rename dialog', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial output slot label
const initialOutputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || graph.outputs?.[0]?.name || null
})
// First rename
await comfyPage.rightClickSubgraphOutputSlot(initialOutputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Now rename again to check for stale content
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.rightClickSubgraphOutputSlot()
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
})
})

View File

@@ -155,6 +155,182 @@ test.describe('Subgraph Operations', () => {
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename input slots via double-click', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
await comfyPage.doubleClickSubgraphInputSlot(initialInputLabel)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename output slots via double-click', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialOutputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || null
})
await comfyPage.doubleClickSubgraphOutputSlot(initialOutputLabel)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const renamedOutputName = 'renamed_output'
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || null
})
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
})
test('Right-click context menu still works alongside double-click', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
// Test that right-click still works for renaming
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const rightClickRenamedName = 'right_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can double-click on slot label text to rename', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
// Use direct pointer event approach to double-click on label
await comfyPage.page.evaluate(() => {
const app = window['app']
const graph = app.canvas.graph
const input = graph.inputs?.[0]
if (!input?.labelPos) {
throw new Error('Could not get label position for testing')
}
// Use labelPos for more precise clicking on the text
const testX = input.labelPos[0]
const testY = input.labelPos[1]
const leftClickEvent = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
const inputNode = graph.inputNode
if (inputNode?.onPointerDown) {
inputNode.onPointerDown(
leftClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
}
})
// Wait for dialog to appear
await comfyPage.page.waitForTimeout(200)
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const labelClickRenamedName = 'label_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Subgraph Creation and Deletion', () => {
@@ -196,6 +372,68 @@ test.describe('Subgraph Operations', () => {
const deletedNode = await comfyPage.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect a second subgraph node to be created (2 total)
expect(subgraphNodes.length).toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes and expect all unique IDs
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect the second subgraph node to have a unique type
const nodeType1 = await subgraphNodes[0].getType()
const nodeType2 = await subgraphNodes[1].getType()
expect(nodeType1).not.toBe(nodeType2)
})
})
})
test.describe('Operations Inside Subgraphs', () => {
@@ -466,4 +704,103 @@ test.describe('Subgraph Operations', () => {
expect(finalCount).toBe(parentCount)
})
})
test.describe('Navigation Hotkeys', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'q',
ctrl: false,
alt: true,
shift: false
}
}
])
await comfyPage.setSetting('Comfy.Keybinding.UnsetBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'Escape',
ctrl: false,
alt: false,
shift: false
}
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.page.waitForTimeout(1024)
// Navigate into subgraph
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
state: 'visible'
})
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
).not.toBeVisible()
// Should still be in subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,155 @@
import { expect } from '@playwright/test'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Workflow Tab Thumbnails', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
await comfyPage.setup()
})
async function getTab(comfyPage: ComfyPage, index: number) {
const tab = comfyPage.page
.locator(`.workflow-tabs .p-togglebutton`)
.nth(index)
return tab
}
async function getTabPopover(
comfyPage: ComfyPage,
index: number,
name?: string
) {
const tab = await getTab(comfyPage, index)
await tab.hover()
const popover = comfyPage.page.locator('.workflow-popover-fade')
await expect(popover).toHaveCount(1)
await expect(popover).toBeVisible({ timeout: 500 })
if (name) {
await expect(popover).toContainText(name)
}
return popover
}
async function getTabThumbnailImage(
comfyPage: ComfyPage,
index: number,
name?: string
) {
const popover = await getTabPopover(comfyPage, index, name)
const thumbnailImg = popover.locator('.workflow-preview-thumbnail img')
return thumbnailImg
}
async function getNodeThumbnailBase64(comfyPage: ComfyPage, index: number) {
const thumbnailImg = await getTabThumbnailImage(comfyPage, index)
const src = (await thumbnailImg.getAttribute('src'))!
// Convert blob to base64, need to execute a script to get the base64
const base64 = await comfyPage.page.evaluate(async (src: string) => {
const blob = await fetch(src).then((res) => res.blob())
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}, src)
return base64
}
test('Should show thumbnail when hovering over a non-active tab', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
0,
'Unsaved Workflow'
)
await expect(thumbnailImg).toBeVisible()
})
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
1,
'Unsaved Workflow (2)'
)
await expect(thumbnailImg).not.toBeVisible()
})
async function addNode(comfyPage: ComfyPage, category: string, node: string) {
const canvasArea = await comfyPage.canvas.boundingBox()
await comfyPage.page.mouse.move(
canvasArea!.x + canvasArea!.width - 100,
100
)
await comfyPage.delay(300) // Wait for the popover to hide
await comfyPage.rightClickCanvas(200, 200)
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText(category).click()
await comfyPage.nextFrame()
await comfyPage.page.getByText(node).click()
await comfyPage.nextFrame()
}
test('Thumbnail should update when switching tabs', async ({ comfyPage }) => {
// Wait for initial workflow to load
await comfyPage.nextFrame()
// Create a new workflow (tab 1) which will be empty
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.nextFrame()
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)
// Tab 1 is currently active, so we can only get thumbnail for tab 0
// Step 1: Different tabs should show different previews
const tab0ThumbnailWithNodes = await getNodeThumbnailBase64(comfyPage, 0)
// Add a node to tab 1 (current active tab)
await addNode(comfyPage, 'loaders', 'Load Checkpoint')
await comfyPage.nextFrame()
// Switch to tab 0 so we can get tab 1's thumbnail
await (await getTab(comfyPage, 0)).click()
await comfyPage.nextFrame()
const tab1ThumbnailWithNode = await getNodeThumbnailBase64(comfyPage, 1)
// The thumbnails should be different
expect(tab0ThumbnailWithNodes).not.toBe(tab1ThumbnailWithNode)
// Step 2: Switching without changes shouldn't update thumbnail
const tab1ThumbnailBefore = await getNodeThumbnailBase64(comfyPage, 1)
// Switch to tab 1 and back to tab 0 without making changes
await (await getTab(comfyPage, 1)).click()
await comfyPage.nextFrame()
await (await getTab(comfyPage, 0)).click()
await comfyPage.nextFrame()
const tab1ThumbnailAfter = await getNodeThumbnailBase64(comfyPage, 1)
expect(tab1ThumbnailBefore).toBe(tab1ThumbnailAfter)
// Step 3: Adding another node should cause thumbnail to change
// We're on tab 0, add a node
await addNode(comfyPage, 'loaders', 'Load VAE')
await comfyPage.nextFrame()
// Switch to tab 1 and back to update tab 0's thumbnail
await (await getTab(comfyPage, 1)).click()
const tab0ThumbnailAfterNewNode = await getNodeThumbnailBase64(comfyPage, 0)
// The thumbnail should have changed after adding a node
expect(tab0ThumbnailWithNodes).not.toBe(tab0ThumbnailAfterNewNode)
})
})

362
docs/FEATURE_FLAGS.md Normal file
View File

@@ -0,0 +1,362 @@
# ComfyUI Feature Flags System
## Overview
The ComfyUI feature flags system enables capability negotiation between frontend and backend, allowing both sides to communicate their supported features and adapt behavior accordingly. This ensures backward compatibility while enabling progressive enhancement of features.
## System Architecture
### High-Level Flow
```mermaid
sequenceDiagram
participant Frontend
participant WebSocket
participant Backend
participant FeatureFlags Module
Frontend->>WebSocket: Connect
WebSocket-->>Frontend: Connection established
Note over Frontend: First message must be feature flags
Frontend->>WebSocket: Send client feature flags
WebSocket->>Backend: Receive feature flags
Backend->>FeatureFlags Module: Store client capabilities
Backend->>FeatureFlags Module: Get server features
FeatureFlags Module-->>Backend: Return server capabilities
Backend->>WebSocket: Send server feature flags
WebSocket-->>Frontend: Receive server features
Note over Frontend,Backend: Both sides now know each other's capabilities
Frontend->>Frontend: Store server features
Frontend->>Frontend: Components use useFeatureFlags()
```
### Component Architecture
```mermaid
graph TB
subgraph Frontend
A[clientFeatureFlags.json] --> B[api.ts]
B --> C[WebSocket Handler]
D[useFeatureFlags composable] --> B
E[Vue Components] --> D
end
subgraph Backend
F[feature_flags.py] --> G[SERVER_FEATURE_FLAGS]
H[server.py WebSocket] --> F
I[Feature Consumers] --> F
end
C <--> H
style A fill:#f9f,stroke:#333,stroke-width:2px
style G fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#9ff,stroke:#333,stroke-width:2px
```
## Feature Flag Structure
Feature flags are organized as a flat dictionary at the top level, with extensions nested under an `extension` object:
### Naming Convention
- **Core features**: Top-level keys (e.g., `"async_execution"`, `"supports_batch_queue"`)
- **Client features**: Top-level keys (e.g., `"supports_preview_metadata"`)
- **Extensions**: Nested under `"extension"` object (e.g., `extension.manager`)
### Structure Example
```json
{
"async_execution": true,
"supports_batch_queue": false,
"supports_preview_metadata": true,
"supports_websocket_v2": false,
"max_upload_size": 104857600,
"extension": {
"manager": {
"supports_v4": true,
"supports_ai_search": false
}
}
}
```
## Implementation Details
### Backend Implementation
```mermaid
classDiagram
class FeatureFlagsModule {
+SERVER_FEATURE_FLAGS: Dict
+get_server_features() Dict
+supports_feature(sockets_metadata, sid, feature_name) bool
+get_connection_feature(sockets_metadata, sid, feature_name, default) Any
}
class PromptServer {
-sockets_metadata: Dict
+websocket_handler()
+send()
}
class FeatureConsumer {
<<interface>>
+check_feature()
+use_feature()
}
PromptServer --> FeatureFlagsModule
FeatureConsumer --> FeatureFlagsModule
```
### Frontend Implementation
The `useFeatureFlags` composable provides reactive access to feature flags, meaning components will automatically update when feature flags change (e.g., during WebSocket reconnection).
```mermaid
classDiagram
class ComfyApi {
+serverFeatureFlags: Record~string, unknown~
+getClientFeatureFlags() Record
+serverSupportsFeature(name) boolean
+getServerFeature(name, default) T
}
class useFeatureFlags {
+serverSupports(name) boolean
+getServerFeature(name, default) T
+createServerFeatureFlag(name) ComputedRef
+extension: ExtensionFlags
}
class VueComponent {
<<component>>
+setup()
}
ComfyApi <-- useFeatureFlags
VueComponent --> useFeatureFlags
```
## Examples
### 1. Preview Metadata Support
```mermaid
graph LR
A[Preview Generation] --> B{supports_preview_metadata?}
B -->|Yes| C[Send metadata with preview]
B -->|No| D[Send preview only]
C --> E[Enhanced preview with node info]
D --> F[Basic preview image]
```
**Backend Usage:**
```python
# Check if client supports preview metadata
if feature_flags.supports_feature(
self.server_instance.sockets_metadata,
self.server_instance.client_id,
"supports_preview_metadata"
):
# Send enhanced preview with metadata
metadata = {
"node_id": node_id,
"prompt_id": prompt_id,
"display_node_id": display_node_id,
"parent_node_id": parent_node_id,
"real_node_id": real_node_id,
}
self.server_instance.send_sync(
BinaryEventTypes.PREVIEW_IMAGE_WITH_METADATA,
(image, metadata),
self.server_instance.client_id,
)
```
### 2. Max Upload Size
```mermaid
graph TB
A[Client File Upload] --> B[Check max_upload_size]
B --> C{File size OK?}
C -->|Yes| D[Upload file]
C -->|No| E[Show error]
F[Backend] --> G[Set from CLI args]
G --> H[Convert MB to bytes]
H --> I[Include in feature flags]
```
**Backend Configuration:**
```python
# In feature_flags.py
SERVER_FEATURE_FLAGS = {
"supports_preview_metadata": True,
"max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes
}
```
**Frontend Usage:**
```typescript
const { getServerFeature } = useFeatureFlags()
const maxUploadSize = getServerFeature('max_upload_size', 100 * 1024 * 1024) // Default 100MB
```
## Using Feature Flags
### Frontend Access Patterns
1. **Direct API access:**
```typescript
// Check boolean feature
if (api.serverSupportsFeature('supports_preview_metadata')) {
// Feature is supported
}
// Get feature value with default
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
```
2. **Using the composable (recommended for reactive components):**
```typescript
const { serverSupports, getServerFeature, extension } = useFeatureFlags()
// Check feature support
if (serverSupports('supports_preview_metadata')) {
// Use enhanced previews
}
// Use reactive convenience properties (automatically update if flags change)
if (extension.manager.supportsV4.value) {
// Use V4 manager API
}
```
3. **Reactive usage in templates:**
```vue
<template>
<div v-if="featureFlags.extension.manager.supportsV4">
<!-- V4-specific UI -->
</div>
<div v-else>
<!-- Legacy UI -->
</div>
</template>
<script setup>
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const featureFlags = useFeatureFlags()
</script>
```
### Backend Access Patterns
```python
# Check if a specific client supports a feature
if feature_flags.supports_feature(
sockets_metadata,
client_id,
"supports_preview_metadata"
):
# Client supports this feature
# Get feature value with default
max_size = feature_flags.get_connection_feature(
sockets_metadata,
client_id,
"max_upload_size",
100 * 1024 * 1024 # Default 100MB
)
```
## Adding New Feature Flags
### Backend
1. **For server capabilities**, add to `SERVER_FEATURE_FLAGS` in `comfy_api/feature_flags.py`:
```python
SERVER_FEATURE_FLAGS = {
"supports_preview_metadata": True,
"max_upload_size": args.max_upload_size * 1024 * 1024,
"your_new_feature": True, # Add your flag
}
```
2. **Use in your code:**
```python
if feature_flags.supports_feature(sockets_metadata, sid, "your_new_feature"):
# Feature-specific code
```
### Frontend
1. **For client capabilities**, add to `src/config/clientFeatureFlags.json`:
```json
{
"supports_preview_metadata": false,
"your_new_feature": true
}
```
2. **For extension features**, update the composable to add convenience accessors:
```typescript
// In useFeatureFlags.ts
const extension = {
manager: {
supportsV4: computed(() => getServerFeature('extension.manager.supports_v4', false))
},
yourExtension: {
supportsNewFeature: computed(() => getServerFeature('extension.yourExtension.supports_new_feature', false))
}
}
return {
// ... existing returns
extension
}
```
## Testing Feature Flags
```mermaid
graph LR
A[Test Scenarios] --> B[Both support feature]
A --> C[Only frontend supports]
A --> D[Only backend supports]
A --> E[Neither supports]
B --> F[Feature enabled]
C --> G[Feature disabled]
D --> H[Feature disabled]
E --> I[Feature disabled]
```
Test your feature flags with different combinations:
- Frontend with flag + Backend with flag = Feature works
- Frontend with flag + Backend without = Graceful degradation
- Frontend without + Backend with flag = No feature usage
- Neither has flag = Default behavior
### Example Test
```typescript
// In tests-ui/tests/api.featureFlags.test.ts
it('should handle preview metadata based on feature flag', () => {
// Mock server supports feature
api.serverFeatureFlags = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Mock server doesn't support feature
api.serverFeatureFlags = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})

View File

@@ -14,7 +14,10 @@ export default [
ignores: [
'src/scripts/*',
'src/extensions/core/*',
'src/types/vue-shim.d.ts'
'src/types/vue-shim.d.ts',
// Generated files that don't need linting
'src/types/comfyRegistryTypes.ts',
'src/types/generatedManagerTypes.ts'
]
},
{

75
knip.config.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
entry: [
'src/main.ts',
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts',
'eslint.config.js',
'tailwind.config.js',
'postcss.config.js',
'playwright.config.ts',
'playwright.i18n.config.ts',
'vitest.config.ts',
'scripts/**/*.{js,ts}'
],
project: [
'src/**/*.{js,ts,vue}',
'tests-ui/**/*.{js,ts,vue}',
'browser_tests/**/*.{js,ts}',
'scripts/**/*.{js,ts}'
],
ignore: [
// Generated files
'dist/**',
'types/**',
'node_modules/**',
// Config files that might not show direct usage
'.husky/**',
// Temporary or cache files
'.vite/**',
'coverage/**',
// i18n config
'.i18nrc.cjs',
// Test setup files
'browser_tests/globalSetup.ts',
'browser_tests/globalTeardown.ts',
'browser_tests/utils/**',
// Scripts
'scripts/**',
// Vite config files
'vite.electron.config.mts',
'vite.types.config.mts',
// Auto generated manager types
'src/types/generatedManagerTypes.ts'
],
ignoreExportsUsedInFile: true,
// Vue-specific configuration
vue: true,
// Only check for unused files, disable all other rules
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
rules: {
binaries: 'off',
classMembers: 'off',
dependencies: 'off',
devDependencies: 'off',
duplicates: 'off',
enumMembers: 'off',
exports: 'off',
nsExports: 'off',
nsTypes: 'off',
types: 'off',
unlisted: 'off'
},
// Include dependencies analysis
includeEntryExports: true,
// Workspace configuration for monorepo-like structure
workspaces: {
'.': {
entry: ['src/main.ts']
}
}
}
export default config

1103
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.25.5",
"version": "1.26.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -21,8 +21,11 @@
"test:component": "vitest run src/components/",
"prepare": "husky || true",
"preview": "vite preview",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip",
"locale": "lobe-i18n locale",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts"
@@ -38,7 +41,6 @@
"@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",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
@@ -56,6 +58,7 @@
"happy-dom": "^15.11.0",
"husky": "^9.0.11",
"identity-obj-proxy": "^3.0.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
@@ -96,12 +99,12 @@
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"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",

View File

@@ -49,6 +49,13 @@ export default defineConfig({
grep: /@2x/ // Run all tests tagged with @2x
},
{
name: 'chromium-0.5x',
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 0.5 },
timeout: 15000,
grep: /@0.5x/ // Run all tests tagged with @0.5x
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },

View File

@@ -51,7 +51,7 @@ const template = await fetch('/templates/default.json')
## General Guidelines
- Use lodash for utility functions
- Use es-toolkit for utility functions
- Implement proper TypeScript types
- Follow Vue 3 composition API style guide
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`

View File

@@ -616,7 +616,8 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d canvas,
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas{
.comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas{
display: flex;
width: 100% !important;
height: 100% !important;

View File

@@ -1,53 +1,148 @@
# ComfyUI Custom Icons Guide
# ComfyUI Icons Guide
This guide explains how to add and use custom SVG icons in the ComfyUI frontend.
ComfyUI supports three types of icons that can be used throughout the interface. All icons are automatically imported - no manual imports needed!
## Overview
## Quick Start - Code Examples
ComfyUI uses a hybrid icon system that supports:
- **PrimeIcons** - Legacy icon library (CSS classes like `pi pi-plus`)
- **Iconify** - Modern icon system with 200,000+ icons
- **Custom Icons** - Your own SVG icons
Custom icons are powered by [unplugin-icons](https://github.com/unplugin/unplugin-icons) and integrate seamlessly with Vue's component system.
## Quick Start
### 1. Add Your SVG Icon
Place your SVG file in the `custom/` directory:
```
src/assets/icons/custom/
└── your-icon.svg
```
### 2. Use in Components
### 1. PrimeIcons
```vue
<template>
<!-- Use as a Vue component -->
<i-comfy:your-icon />
<!-- In a PrimeVue button -->
<Button>
<!-- Basic usage -->
<i class="pi pi-plus" />
<i class="pi pi-cog" />
<i class="pi pi-check text-green-500" />
<!-- In PrimeVue components -->
<button icon="pi pi-save" label="Save" />
<button icon="pi pi-times" severity="danger" />
</template>
```
[Browse all PrimeIcons →](https://primevue.org/icons/#list)
### 2. Iconify Icons (Recommended)
```vue
<template>
<!-- Primary icon set: Lucide -->
<i-lucide:download />
<i-lucide:settings />
<i-lucide:workflow class="text-2xl" />
<!-- Other popular icon sets -->
<i-mdi:folder-open />
<!-- Material Design Icons -->
<i-heroicons:document-text />
<!-- Heroicons -->
<i-tabler:brand-github />
<!-- Tabler Icons -->
<i-carbon:cloud-upload />
<!-- Carbon Icons -->
<!-- With styling -->
<i-lucide:save class="w-6 h-6 text-blue-500" />
</template>
```
[Browse 200,000+ icons →](https://icon-sets.iconify.design/)
### 3. Custom Icons
```vue
<template>
<!-- Your custom SVG icons from src/assets/icons/custom/ -->
<i-comfy:workflow />
<i-comfy:node-tree />
<i-comfy:my-custom-icon class="text-xl" />
<!-- In PrimeVue button -->
<Button severity="secondary">
<template #icon>
<i-comfy:your-icon />
<i-comfy:workflow />
</template>
</Button>
</template>
```
## SVG Requirements
## Icon Usage Patterns
### File Naming
- Use kebab-case: `workflow-icon.svg`, `node-tree.svg`
- Avoid special characters and spaces
- The filename becomes the icon name
### In Buttons
```vue
<template>
<!-- PrimeIcon in button (simple) -->
<Button icon="pi pi-check" label="Confirm" />
<!-- Iconify/Custom in button (template) -->
<Button>
<template #icon>
<i-lucide:save />
</template>
Save File
</Button>
</template>
```
### Conditional Icons
```vue
<template>
<i-lucide:eye v-if="isVisible" />
<i-lucide:eye-off v-else />
<!-- Or with ternary -->
<component :is="isLocked ? 'i-lucide:lock' : 'i-lucide:lock-open'" />
</template>
```
### With Tooltips
```vue
<template>
<i-lucide:info
v-tooltip="'Click for more information'"
class="cursor-pointer"
/>
</template>
```
## Using Iconify Icons
### Finding Icons
1. Visit [Iconify Icon Sets](https://icon-sets.iconify.design/)
2. Search or browse collections
3. Click on any icon to get its name
4. Use with `i-[collection]:[icon-name]` format
### Popular Collections
- **Lucide** (`i-lucide:`) - Our primary icon set, clean and consistent
- **Material Design Icons** (`i-mdi:`) - Comprehensive Material Design icons
- **Heroicons** (`i-heroicons:`) - Beautiful hand-crafted SVG icons
- **Tabler** (`i-tabler:`) - 3000+ free SVG icons
- **Carbon** (`i-carbon:`) - IBM's design system icons
## Adding Custom Icons
### 1. Add Your SVG
Place your SVG file in `src/assets/icons/custom/`:
```
src/assets/icons/custom/
├── workflow-duplicate.svg
├── node-preview.svg
└── your-icon.svg
```
### 2. SVG Format Requirements
### SVG Format
```xml
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="..." />
<!-- Use currentColor for theme compatibility -->
<path fill="currentColor" d="..." />
</svg>
```
@@ -57,59 +152,98 @@ src/assets/icons/custom/
- Use `currentColor` for theme-aware icons
- Keep SVGs optimized and simple
### Color Theming
### 3. Use Immediately
For icons that adapt to the current theme, use `currentColor`:
```vue
<template>
<i-comfy:your-icon />
</template>
```
No imports needed - icons are auto-discovered!
## Icon Guidelines
### Naming Conventions
- **Files**: `kebab-case.svg` (workflow-icon.svg)
- **Usage**: `<i-comfy:workflow-icon />`
### Size & Styling
```vue
<template>
<!-- Size with Tailwind classes -->
<i-lucide:plus class="w-4 h-4" />
<!-- 16px -->
<i-lucide:plus class="w-6 h-6" />
<!-- 24px (default) -->
<i-lucide:plus class="w-8 h-8" />
<!-- 32px -->
<!-- Or text size -->
<i-lucide:plus class="text-sm" />
<i-lucide:plus class="text-2xl" />
<!-- Colors -->
<i-lucide:check class="text-green-500" />
<i-lucide:x class="text-red-500" />
</template>
```
### Theme Compatibility
Always use `currentColor` in SVGs for automatic theme adaptation:
```xml
<!-- ✅ Good: Uses currentColor -->
<!-- ✅ Good: Adapts to light/dark theme -->
<svg viewBox="0 0 24 24">
<path stroke="currentColor" fill="none" d="..." />
<path fill="currentColor" d="..." />
</svg>
<!-- ❌ Bad: Hardcoded colors -->
<!-- ❌ Bad: Fixed colors -->
<svg viewBox="0 0 24 24">
<path stroke="white" fill="black" d="..." />
<path fill="#000000" d="..." />
</svg>
```
## Usage Examples
## Migration Guide
### From PrimeIcons to Iconify/Custom
### Basic Icon
```vue
<i-comfy:workflow />
<template>
<!-- Before -->
<Button icon="pi pi-download" />
<!-- After -->
<Button>
<template #icon>
<i-lucide:download />
</template>
</Button>
</template>
```
### With Classes
```vue
<i-comfy:workflow class="text-2xl text-blue-500" />
```
### From Inline SVG to Custom Icon
### In Buttons
```vue
<Button severity="secondary" text>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
<template>
<!-- Before: Inline SVG -->
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path d="..." />
</svg>
### Conditional Icons
```vue
<template #icon>
<i-comfy:workflow v-if="isWorkflow" />
<i-comfy:node v-else />
<!-- After: Save as custom/my-icon.svg and use -->
<i-comfy:my-icon class="w-6 h-6" />
</template>
```
## Technical Details
### How It Works
### Auto-Import System
1. **unplugin-icons** automatically discovers SVG files in `custom/`
2. During build, SVGs are converted to Vue components
3. Components are tree-shaken - only used icons are bundled
4. The `i-` prefix and `comfy:` namespace identify custom icons
Icons are automatically imported using `unplugin-icons` - no manual imports needed! Just use the icon component directly.
### Configuration
@@ -119,17 +253,18 @@ The icon system is configured in `vite.config.mts`:
Icons({
compiler: 'vue3',
customCollections: {
'comfy': FileSystemIconLoader('src/assets/icons/custom'),
comfy: FileSystemIconLoader('src/assets/icons/custom')
}
})
```
### TypeScript Support
Icons are automatically typed. If TypeScript doesn't recognize a new icon:
1. Restart your dev server
2. Check that the SVG file is valid
3. Ensure the filename follows kebab-case convention
Icons are fully typed. If TypeScript doesn't recognize a new custom icon:
1. Restart the dev server
2. Ensure the SVG file is valid
3. Check filename follows kebab-case
## Troubleshooting
@@ -157,22 +292,6 @@ Icons are automatically typed. If TypeScript doesn't recognize a new icon:
4. **Theme support**: Always use `currentColor` for adaptable icons
5. **Test both themes**: Verify icons look good in light and dark modes
## Migration from PrimeIcons
When replacing a PrimeIcon with a custom icon:
```vue
<!-- Before: PrimeIcon -->
<Button icon="pi pi-box" />
<!-- After: Custom icon -->
<Button>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
## Adding Icon Collections
To add an entire icon set from npm:
@@ -181,4 +300,11 @@ To add an entire icon set from npm:
2. Configure in `vite.config.mts`
3. Use with the appropriate prefix
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.
## Resources
- [PrimeIcons List](https://primevue.org/icons/#list)
- [Iconify Icon Browser](https://icon-sets.iconify.design/)
- [Lucide Icons](https://lucide.dev/icons/)
- [unplugin-icons docs](https://github.com/unplugin/unplugin-icons)

View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.91396 12.7428L5.41396 10.7428C5.57175 10.1116 5.09439 9.50024 4.44382 9.50024H2.50538C2.04651 9.50024 1.64652 9.81253 1.53523 10.2577L1.03523 12.2577C0.877446 12.8888 1.3548 13.5002 2.00538 13.5002H3.94382C4.40269 13.5002 4.80267 13.1879 4.91396 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5.91396 6.74277L6.41396 4.74277C6.57175 4.11163 6.09439 3.50024 5.44382 3.50024H3.50538C3.04651 3.50024 2.64652 3.81253 2.53523 4.2577L2.03523 6.2577C1.87745 6.88885 2.3548 7.50024 3.00538 7.50024H4.94382C5.40269 7.50024 5.80267 7.18794 5.91396 6.74277Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10.914 12.7428L11.414 10.7428C11.5718 10.1116 11.0944 9.50024 10.4438 9.50024H8.50538C8.04651 9.50024 7.64652 9.81253 7.53523 10.2577L7.03523 12.2577C6.87745 12.8888 7.3548 13.5002 8.00538 13.5002H9.94382C10.4027 13.5002 10.8027 13.1879 10.914 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M12.2342 5.46739L11.5287 7.11354C11.4248 7.35597 11.0811 7.35597 10.9772 7.11354L10.2717 5.46739C10.2414 5.39659 10.185 5.34017 10.1141 5.30983L8.468 4.60433C8.22557 4.50044 8.22557 4.15675 8.468 4.05285L10.1141 3.34736C10.185 3.31701 10.2414 3.26059 10.2717 3.18979L10.9772 1.54364C11.0811 1.30121 11.4248 1.30121 11.5287 1.54364L12.2342 3.18979C12.2645 3.26059 12.3209 3.31701 12.3918 3.34736L14.0379 4.05285C14.2803 4.15675 14.2803 4.50044 14.0379 4.60433L12.3918 5.30983C12.3209 5.34017 12.2645 5.39659 12.2342 5.46739Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 10L10.598 10.2577C10.4812 10.6954 10.0848 11 9.63172 11H5.30161C4.64458 11 4.16608 10.3772 4.33538 9.74234L5.40204 5.74234C5.51878 5.30458 5.91523 5 6.36828 5H10.8286C11.4199 5 11.8505 5.56051 11.6982 6.13185L11.6736 6.22389M14 8H10M4.5 8H2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.1894 6.24254L13.6894 4.24254C13.8471 3.61139 13.3698 3 12.7192 3H3.78077C3.3219 3 2.92192 3.3123 2.81062 3.75746L2.31062 5.75746C2.15284 6.38861 2.63019 7 3.28077 7H12.2192C12.6781 7 13.0781 6.6877 13.1894 6.24254Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M13.1894 12.2425L13.6894 10.2425C13.8471 9.61139 13.3698 9 12.7192 9H8.78077C8.3219 9 7.92192 9.3123 7.81062 9.75746L7.31062 11.7575C7.15284 12.3886 7.6302 13 8.28077 13H12.2192C12.6781 13 13.0781 12.6877 13.1894 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5.18936 12.2425L5.68936 10.2425C5.84714 9.61139 5.36978 9 4.71921 9H3.78077C3.3219 9 2.92192 9.3123 2.81062 9.75746L2.31062 11.7575C2.15284 12.3886 2.6302 13 3.28077 13H4.21921C4.67808 13 5.07806 12.6877 5.18936 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 970 B

View File

@@ -20,7 +20,7 @@ import {
useLocalStorage,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'lodash'
import { clamp } from 'es-toolkit/compat'
import Panel from 'primevue/panel'
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'

View File

@@ -11,18 +11,33 @@
class="p-3 border-none"
>
<span class="font-bold">
{{ tab.title.toUpperCase() }}
{{
shouldCapitalizeTab(tab.id)
? tab.title.toUpperCase()
: tab.title
}}
</span>
</Tab>
</div>
<Button
class="justify-self-end"
icon="pi pi-times"
severity="secondary"
size="small"
text
@click="bottomPanelStore.bottomPanelVisible = false"
/>
<div class="flex items-center gap-2">
<Button
v-if="isShortcutsTabActive"
:label="$t('shortcuts.manageShortcuts')"
icon="pi pi-cog"
severity="secondary"
size="small"
text
@click="openKeybindingSettings"
/>
<Button
class="justify-self-end"
icon="pi pi-times"
severity="secondary"
size="small"
text
@click="closeBottomPanel"
/>
</div>
</div>
</TabList>
</Tabs>
@@ -44,9 +59,32 @@ import Button from 'primevue/button'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import Tabs from 'primevue/tabs'
import { computed } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { useDialogService } from '@/services/dialogService'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
const dialogService = useDialogService()
const isShortcutsTabActive = computed(() => {
const activeTabId = bottomPanelStore.activeBottomPanelTabId
return (
activeTabId === 'shortcuts-essentials' ||
activeTabId === 'shortcuts-view-controls'
)
})
const shouldCapitalizeTab = (tabId: string): boolean => {
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
}
const openKeybindingSettings = async () => {
dialogService.showSettingsDialog('keybinding')
}
const closeBottomPanel = () => {
bottomPanelStore.activePanel = null
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="h-full flex flex-col p-4">
<div class="flex-1 min-h-0 overflow-auto">
<ShortcutsList
:commands="essentialsCommands"
:subcategories="essentialsSubcategories"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
ESSENTIALS_CONFIG,
useCommandSubcategories
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
import { useCommandStore } from '@/stores/commandStore'
import ShortcutsList from './ShortcutsList.vue'
const commandStore = useCommandStore()
const essentialsCommands = computed(() =>
commandStore.commands.filter((cmd) => cmd.category === 'essentials')
)
const { subcategories: essentialsSubcategories } = useCommandSubcategories(
essentialsCommands,
ESSENTIALS_CONFIG
)
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div class="shortcuts-list flex justify-center">
<div class="grid gap-4 md:gap-24 h-full grid-cols-1 md:grid-cols-3 w-[90%]">
<div
v-for="(subcategoryCommands, subcategory) in filteredSubcategories"
:key="subcategory"
class="flex flex-col"
>
<h3
class="subcategory-title text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 mb-4"
>
{{ getSubcategoryTitle(subcategory) }}
</h3>
<div class="flex flex-col gap-1">
<div
v-for="command in subcategoryCommands"
:key="command.id"
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
>
<div class="shortcut-info flex-grow pr-4">
<div class="shortcut-name text-sm font-medium">
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
</div>
</div>
<div class="keybinding-display flex-shrink-0">
<div
class="keybinding-combo flex gap-1"
:aria-label="`Keyboard shortcut: ${command.keybinding!.combo.getKeySequences().join(' + ')}`"
>
<span
v-for="key in command.keybinding!.combo.getKeySequences()"
:key="key"
class="key-badge px-2 py-1 text-xs font-mono bg-surface-200 dark-theme:bg-surface-600 rounded border min-w-6 text-center"
>
{{ formatKey(key) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()
const { subcategories } = defineProps<{
commands: ComfyCommandImpl[]
subcategories: Record<string, ComfyCommandImpl[]>
}>()
const filteredSubcategories = computed(() => {
const result: Record<string, ComfyCommandImpl[]> = {}
for (const [subcategory, commands] of Object.entries(subcategories)) {
result[subcategory] = commands.filter((cmd) => !!cmd.keybinding)
}
return result
})
const getSubcategoryTitle = (subcategory: string): string => {
const titleMap: Record<string, string> = {
workflow: t('shortcuts.subcategories.workflow'),
node: t('shortcuts.subcategories.node'),
queue: t('shortcuts.subcategories.queue'),
view: t('shortcuts.subcategories.view'),
'panel-controls': t('shortcuts.subcategories.panelControls')
}
return titleMap[subcategory] || subcategory
}
const formatKey = (key: string): string => {
const keyMap: Record<string, string> = {
Control: 'Ctrl',
Meta: 'Cmd',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Backspace: '⌫',
Delete: '⌦',
Enter: '↵',
Escape: 'Esc',
Tab: '⇥',
' ': 'Space'
}
return keyMap[key] || key
}
</script>
<style scoped>
.subcategory-title {
color: var(--p-text-muted-color);
}
.key-badge {
background-color: var(--p-surface-200);
border: 1px solid var(--p-surface-300);
min-width: 1.5rem;
text-align: center;
}
.dark-theme .key-badge {
background-color: var(--p-surface-600);
border-color: var(--p-surface-500);
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="h-full flex flex-col p-4">
<div class="flex-1 min-h-0 overflow-auto">
<ShortcutsList
:commands="viewControlsCommands"
:subcategories="viewControlsSubcategories"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
VIEW_CONTROLS_CONFIG,
useCommandSubcategories
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
import { useCommandStore } from '@/stores/commandStore'
import ShortcutsList from './ShortcutsList.vue'
const commandStore = useCommandStore()
const viewControlsCommands = computed(() =>
commandStore.commands.filter((cmd) => cmd.category === 'view-controls')
)
const { subcategories: viewControlsSubcategories } = useCommandSubcategories(
viewControlsCommands,
VIEW_CONTROLS_CONFIG
)
</script>

View File

@@ -32,7 +32,6 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
@@ -98,18 +97,6 @@ const home = computed(() => ({
}
}))
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {

View File

@@ -10,7 +10,8 @@
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive
'p-breadcrumb-item-link-icon-visible': isActive,
'active-breadcrumb-item': isActive
}"
@click="handleClick"
>
@@ -111,21 +112,7 @@ const menuItems = computed<MenuItem[]>(() => {
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
command: startRename
},
{
label: t('breadcrumbsMenu.duplicate'),
@@ -175,20 +162,24 @@ const handleClick = (event: MouseEvent) => {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
startRename()
}
}
const startRename = () => {
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
@@ -212,4 +203,8 @@ const inputBlur = async (doRename: boolean) => {
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
.active-breadcrumb-item {
color: var(--text-primary);
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<div class="flex flex-col gap-3 h-full">
<div class="flex justify-between text-xs">
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
<div>{{ t('apiNodesCostBreakdown.costPerRun') }}</div>
</div>
<ScrollPanel class="flex-grow h-0">
<div class="flex flex-col gap-2">
<div
v-for="node in nodes"
:key="node.name"
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
>
<div class="flex items-center gap-2">
<span class="text-base font-medium leading-tight">{{
node.name
}}</span>
</div>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<span class="text-base font-medium leading-tight">
{{ node.cost.toFixed(costPrecision) }}
</span>
</div>
</div>
</div>
</ScrollPanel>
<template v-if="showTotal && nodes.length > 1">
<Divider class="my-2" />
<div class="flex justify-between items-center border-t px-3">
<span class="text-sm">{{ t('apiNodesCostBreakdown.totalCost') }}</span>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-yellow-500 p-1"
/>
<span>{{ totalCost.toFixed(costPrecision) }}</span>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ApiNodeCost } from '@/types/apiNodeTypes'
const { t } = useI18n()
const {
nodes,
showTotal = true,
costPrecision = 3
} = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
costPrecision?: number
}>()
const totalCost = computed(() =>
nodes.reduce((sum, node) => sum + node.cost, 0)
)
</script>

View File

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

View File

@@ -0,0 +1,124 @@
<template>
<div
ref="containerRef"
class="relative overflow-hidden w-full h-full flex items-center justify-center"
>
<Skeleton
v-if="!isImageLoaded"
width="100%"
height="100%"
class="absolute inset-0"
/>
<img
v-show="isImageLoaded"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<div
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<i class="pi pi-image text-2xl" />
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
const {
src,
alt = '',
imageClass = '',
imageStyle,
rootMargin = '300px'
} = defineProps<{
src: string
alt?: string
imageClass?: string | string[] | Record<string, boolean>
imageStyle?: Record<string, any>
rootMargin?: string
}>()
const containerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const isIntersecting = ref(false)
const isImageLoaded = ref(false)
const hasError = ref(false)
const cachedSrc = ref<string | undefined>(undefined)
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
// Use intersection observer to detect when the image container comes into view
useIntersectionObserver(
containerRef,
(entries) => {
const entry = entries[0]
isIntersecting.value = entry?.isIntersecting ?? false
},
{
rootMargin,
threshold: 0.1
}
)
// Only start loading the image when it's in view
const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
hasError.value = true
} else if (cachedMedia.objectUrl) {
const acquiredUrl = acquireUrl(src)
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
} else {
cachedSrc.value = src
}
} catch (error) {
console.warn('Failed to load cached media:', error)
cachedSrc.value = src
}
} else if (!shouldLoad) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
// Hide image when out of view
isImageLoaded.value = false
cachedSrc.value = undefined
hasError.value = false
}
},
{ immediate: true }
)
const onImageLoad = () => {
isImageLoaded.value = true
hasError.value = false
}
const onImageError = () => {
hasError.value = true
isImageLoaded.value = false
}
onUnmounted(() => {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
})
</script>

View File

@@ -42,7 +42,7 @@
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'

View File

@@ -16,7 +16,7 @@
<script setup lang="ts" generic="T">
import { useElementSize, useScroll, whenever } from '@vueuse/core'
import { clamp, debounce } from 'lodash'
import { clamp, debounce } from 'es-toolkit/compat'
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
type GridState = {

View File

@@ -0,0 +1,15 @@
<template>
<button
class="flex justify-center items-center outline-none border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white w-8 h-8 rounded-lg cursor-pointer"
role="button"
@click="onClick"
>
<slot></slot>
</button>
</template>
<script setup lang="ts">
const { onClick } = defineProps<{
onClick: () => void
}>()
</script>

View File

@@ -0,0 +1,67 @@
<template>
<BaseWidgetLayout>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i-lucide:puzzle class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{ t('g.title') }}</span>
</template>
</LeftSidePanel>
</template>
<template #header>
<!-- here -->
</template>
<template #content>
<!-- here -->
</template>
<template #rightPanel>
<RightSidePanel></RightSidePanel>
</template>
</BaseWidgetLayout>
</template>
<script setup lang="ts">
import { provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { NavGroupData, NavItemData } from '@/types/custom_components/navTypes'
import { OnCloseKey } from '@/types/custom_components/widgetTypes'
import BaseWidgetLayout from './layout/BaseWidgetLayout.vue'
import LeftSidePanel from './panel/LeftSidePanel.vue'
import RightSidePanel from './panel/RightSidePanel.vue'
const { t } = useI18n()
const { onClose } = defineProps<{
onClose: () => void
}>()
provide(OnCloseKey, onClose)
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
]
}
])
const selectedNavItem = ref<string | null>('installed')
</script>

View File

@@ -0,0 +1,176 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-100 dark-theme:bg-zinc-800"
>
<IconButton
v-show="!isRightPanelOpen && hasRightPanel"
class="absolute top-4 right-16 z-10 transition-opacity duration-200"
:class="{
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
}"
@click="toggleRightPanel"
>
<i-lucide:panel-right class="text-sm" />
</IconButton>
<IconButton
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
@click="closeDialog"
>
<i class="pi pi-times text-sm"></i>
</IconButton>
<div class="flex w-full h-full">
<Transition name="slide-panel">
<nav
v-if="$slots.leftPanel && showLeftPanel"
:class="[
PANEL_SIZES.width,
PANEL_SIZES.minWidth,
PANEL_SIZES.maxWidth
]"
>
<slot name="leftPanel"></slot>
</nav>
</Transition>
<div class="flex-1 flex bg-zinc-100 dark-theme:bg-neutral-900">
<div class="w-full h-full flex flex-col">
<header
v-if="$slots.header"
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
>
<div class="flex-1 flex gap-2 flex-shrink-0">
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
<i-lucide:panel-left-close v-else class="text-sm" />
</IconButton>
<slot name="header"></slot>
</div>
<div class="flex justify-end gap-2 min-w-20">
<slot name="header-right-area"></slot>
<IconButton
v-if="isRightPanelOpen && hasRightPanel"
@click="toggleRightPanel"
>
<i-lucide:panel-right-close class="text-sm" />
</IconButton>
</div>
</header>
<main class="flex-1">
<slot name="content"></slot>
</main>
</div>
<Transition name="slide-panel-right">
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80"
>
<slot name="rightPanel"></slot>
</aside>
</Transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { computed, inject, ref, useSlots, watch } from 'vue'
import IconButton from '@/components/custom/button/IconButton.vue'
import { OnCloseKey } from '@/types/custom_components/widgetTypes'
const BREAKPOINTS = { sm: 480 }
const PANEL_SIZES = {
width: 'w-1/3',
minWidth: 'min-w-40',
maxWidth: 'max-w-56'
}
const slots = useSlots()
const closeDialog = inject(OnCloseKey, () => {})
const breakpoints = useBreakpoints(BREAKPOINTS)
const notMobile = breakpoints.greater('sm')
const isLeftPanelOpen = ref<boolean>(true)
const isRightPanelOpen = ref<boolean>(false)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)
watch(notMobile, (isDesktop) => {
if (!isDesktop) {
mobileMenuOpen.value = false
}
})
const showLeftPanel = computed(() => {
const shouldShow = notMobile.value
? isLeftPanelOpen.value
: mobileMenuOpen.value
return shouldShow
})
const toggleLeftPanel = () => {
if (notMobile.value) {
isLeftPanelOpen.value = !isLeftPanelOpen.value
} else {
mobileMenuOpen.value = !mobileMenuOpen.value
}
}
const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value
}
</script>
<style scoped>
.base-widget-layout {
height: 80vh;
width: 90vw;
max-width: 1280px;
aspect-ratio: 20/13;
}
@media (min-width: 1450px) {
.base-widget-layout {
max-width: 1724px;
}
}
/* Fade transition for buttons */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide transition for left panel */
.slide-panel-enter-active,
.slide-panel-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-enter-from,
.slide-panel-leave-to {
transform: translateX(-100%);
}
/* Slide transition for right panel */
.slide-panel-right-enter-active,
.slide-panel-right-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-right-enter-from,
.slide-panel-right-leave-to {
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div
class="flex items-center gap-2 px-4 py-2 text-xs rounded-md transition-colors cursor-pointer"
:class="
active
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
: 'text-neutral hover:bg-zinc-100 hover:dark-theme:bg-zinc-700/50'
"
role="button"
@click="onClick"
>
<i-lucide:folder class="text-xs text-neutral" />
<span>
<slot></slot>
</span>
</div>
</template>
<script setup lang="ts">
const { active, onClick } = defineProps<{
active?: boolean
onClick: () => void
}>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<h3
class="m-0 px-3 py-0 pt-5 text-sm font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
>
{{ title }}
</h3>
</template>
<script setup lang="ts">
const { title } = defineProps<{
title: string
}>()
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-zinc-800">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>
</template>
<slot name="header-title"></slot>
</PanelHeader>
<nav class="flex-1 px-3 py-4 flex flex-col gap-2">
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle :title="item.title" />
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:active="activeItem === item.id"
@click="activeItem = item.id"
>
{{ item.label }}
</NavItem>
</div>
</template>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NavGroupData, NavItemData } from '@/types/custom_components/navTypes'
import NavItem from '../nav/NavItem.vue'
import NavTitle from '../nav/NavTitle.vue'
import PanelHeader from './PanelHeader.vue'
const { navItems = [], modelValue } = defineProps<{
navItems?: (NavItemData | NavGroupData)[]
modelValue?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const getFirstItemId = () => {
if (!navItems || navItems.length === 0) {
return null
}
const firstEntry = navItems[0]
if ('items' in firstEntry && firstEntry.items.length > 0) {
return firstEntry.items[0].id
}
if ('id' in firstEntry) {
return firstEntry.id
}
return null
}
const activeItem = computed({
get: () => modelValue ?? getFirstItemId(),
set: (value: string | null) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,12 @@
<template>
<header class="flex items-center justify-between h-16 px-6">
<div class="flex items-center gap-2 pl-1">
<slot name="icon">
<i-lucide:puzzle class="text-neutral text-base" />
</slot>
<h2 class="font-bold text-base text-neutral">
<slot></slot>
</h2>
</div>
</header>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-zinc-800">
<slot></slot>
</div>
</template>

View File

@@ -10,14 +10,16 @@
:aria-labelledby="item.key"
>
<template #header>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<component

View File

@@ -36,6 +36,7 @@ import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useSettingStore } from '@/stores/settingStore'

View File

@@ -169,8 +169,8 @@ import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'
import _ from 'lodash'
import cloneDeep from 'lodash/cloneDeep'
import _ from 'es-toolkit/compat'
import { cloneDeep } from 'es-toolkit/compat'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown'

View File

@@ -93,7 +93,7 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import { merge } from 'es-toolkit/compat'
import Button from 'primevue/button'
import {
computed,

View File

@@ -12,7 +12,7 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('lodash', () => ({
vi.mock('es-toolkit/compat', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
}))

View File

@@ -10,7 +10,7 @@
</template>
<script setup lang="ts">
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'

View File

@@ -1,46 +0,0 @@
<template>
<div class="w-[100%] flex justify-between items-center">
<div class="flex justify-start items-center">
<div class="w-1 h-6 rounded-md" />
<div class="w-6 h-6 relative overflow-hidden">
<i class="pi pi-box text-xl text-muted" style="opacity: 0.6" />
</div>
<div class="px-3 py-2 rounded-md flex justify-start items-start gap-2.5">
<div class="text-right justify-start text-sm font-bold leading-none">
{{ $t('manager.nodePack') }}
</div>
</div>
</div>
<div class="inline-flex justify-start items-center gap-3">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2" />
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
</script>

View File

@@ -57,7 +57,7 @@
</template>
<script setup lang="ts">
import { stubTrue } from 'lodash'
import { stubTrue } from 'es-toolkit/compat'
import AutoComplete, {
AutoCompleteOptionSelectEvent
} from 'primevue/autocomplete'

View File

@@ -21,7 +21,6 @@
<MiniMap
v-if="comfyAppReady && minimapEnabled"
ref="minimapRef"
class="pointer-events-auto"
/>
</template>
@@ -71,7 +70,6 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
@@ -119,9 +117,7 @@ const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -358,13 +354,6 @@ onMounted(async () => {
}
)
whenever(
() => minimapRef.value,
(ref) => {
minimap.setMinimapRef(ref)
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {

View File

@@ -58,7 +58,7 @@
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
/>
<Button
v-tooltip.left="t('graphCanvasMenu.toggleMinimap') + ' (Alt + m)'"
v-tooltip.left="minimapTooltip"
severity="secondary"
:icon="'pi pi-map'"
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
@@ -79,15 +79,24 @@ import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const settingStore = useSettingStore()
const canvasInteractions = useCanvasInteractions()
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimapTooltip = computed(() => {
const baseText = t('graphCanvasMenu.toggleMinimap')
const keybinding = keybindingStore.getKeybindingByCommandId(
'Comfy.Canvas.ToggleMinimap'
)
return keybinding ? `${baseText} (${keybinding.combo.toString()})` : baseText
})
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
)

View File

@@ -1,33 +1,67 @@
<template>
<div
v-if="visible && initialized"
ref="containerRef"
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
:style="containerStyles"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@wheel="handleWheel"
ref="minimapRef"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
<MiniMapPanel
v-if="showOptionsPanel"
:panel-styles="panelStyles"
:node-colors="nodeColors"
:show-links="showLinks"
:show-groups="showGroups"
:render-bypass="renderBypass"
:render-error="renderError"
@update-option="updateOption"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
ref="containerRef"
class="litegraph-minimap relative"
:style="containerStyles"
>
<Button
class="absolute z-10"
size="small"
text
severity="secondary"
@click.stop="toggleOptionsPanel"
>
<template #icon>
<i-lucide:settings-2 />
</template>
</Button>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
class="absolute inset-0"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
import MiniMapPanel from './MiniMapPanel.vue'
const minimapRef = ref<HTMLDivElement>()
const {
initialized,
@@ -38,28 +72,29 @@ const {
viewportStyles,
width,
height,
init,
panelStyles,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
updateOption,
destroy,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel
} = minimap
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef
} = useMinimap()
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
const showOptionsPanel = ref(false)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
}
const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
onMounted(() => {
setMinimapRef(minimapRef.value)
})
onUnmounted(() => {

View File

@@ -0,0 +1,97 @@
<template>
<div
class="minimap-panel p-3 mr-2 flex flex-col gap-3 text-sm"
:style="panelStyles"
>
<div class="flex items-center gap-2">
<Checkbox
input-id="node-colors"
name="node-colors"
:model-value="nodeColors"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.NodeColors', value)
"
/>
<i-lucide:palette />
<label for="node-colors">{{ $t('minimap.nodeColors') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-links"
name="show-links"
:model-value="showLinks"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowLinks', value)
"
/>
<i-lucide:route />
<label for="show-links">{{ $t('minimap.showLinks') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-groups"
name="show-groups"
:model-value="showGroups"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowGroups', value)
"
/>
<i-lucide:frame />
<label for="show-groups">{{ $t('minimap.showGroups') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-bypass"
name="render-bypass"
:model-value="renderBypass"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderBypassState', value)
"
/>
<i-lucide:circle-slash-2 />
<label for="render-bypass">{{ $t('minimap.renderBypassState') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-error"
name="render-error"
:model-value="renderError"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderErrorState', value)
"
/>
<i-lucide:message-circle-warning />
<label for="render-error">{{ $t('minimap.renderErrorState') }}</label>
</div>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { MinimapOptionKey } from '@/composables/useMinimap'
defineProps<{
panelStyles: any
nodeColors: boolean
showLinks: boolean
showGroups: boolean
renderBypass: boolean
renderError: boolean
}>()
defineEmits<{
updateOption: [key: MinimapOptionKey, value: boolean]
}>()
</script>

View File

@@ -14,12 +14,13 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { provide, readonly, ref, watch } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
@@ -27,6 +28,13 @@ const { getSelectableItems } = useSelectedLiteGraphItems()
const visible = ref(false)
const showBorder = ref(false)
// Increment counter to notify child components of position/visibility change
// This does not include viewport changes.
const overlayUpdateCount = ref(0)
provide(SelectionOverlayInjectionKey, {
visible: readonly(visible),
updateCount: readonly(overlayUpdateCount)
})
const positionSelectionOverlay = () => {
const selectableItems = getSelectableItems()
@@ -52,6 +60,7 @@ whenever(
() => {
requestAnimationFrame(() => {
positionSelectionOverlay()
overlayUpdateCount.value++
canvasStore.getCanvas().state.selectionChanged = false
})
},
@@ -71,6 +80,7 @@ watch(
requestAnimationFrame(() => {
visible.value = true
positionSelectionOverlay()
overlayUpdateCount.value++
})
} else {
// Selection change update to visible state is delayed by a frame. Here
@@ -78,6 +88,7 @@ watch(
// the initial selection and dragging happens at the same time.
requestAnimationFrame(() => {
visible.value = false
overlayUpdateCount.value++
})
}
}

View File

@@ -1,6 +1,7 @@
<template>
<Panel
class="selection-toolbox absolute left-1/2 rounded-lg"
:class="{ 'animate-slide-up': shouldAnimate }"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
@@ -12,6 +13,7 @@
<BypassButton />
<PinButton />
<EditModelButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
@@ -27,7 +29,7 @@
<script setup lang="ts">
import Panel from 'primevue/panel'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
@@ -37,19 +39,28 @@ import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewerButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import { useRetriggerableAnimation } from '@/composables/element/useRetriggerableAnimation'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const selectionOverlayState = inject(SelectionOverlayInjectionKey)
const { shouldAnimate } = useRetriggerableAnimation(
selectionOverlayState?.updateCount,
{ animateOnMount: true }
)
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems
@@ -71,4 +82,20 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
.selection-toolbox {
transform: translateX(-50%) translateY(-120%);
}
/* Slide up animation using CSS animation */
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(-120%);
opacity: 1;
}
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
</style>

View File

@@ -24,7 +24,7 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useGraphMutationService } from '@/services/graphMutationService'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -42,19 +42,23 @@ const inputStyle = computed<CSSProperties>(() => ({
const titleEditorStore = useTitleEditorStore()
const canvasStore = useCanvasStore()
const previousCanvasDraggable = ref(true)
const graphMutationService = useGraphMutationService()
const onEdit = (newValue: string) => {
const onEdit = async (newValue: string) => {
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
const trimmedTitle = newValue.trim()
titleEditorStore.titleEditorTarget.title = trimmedTitle
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
const target = titleEditorStore.titleEditorTarget
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
app.graph.setDirtyCanvas(true, true)
if (target instanceof LGraphNode) {
await graphMutationService.updateNodeTitle(target.id, trimmedTitle)
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
if (target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
} else if (target instanceof LGraphGroup) {
await graphMutationService.updateGroupTitle(target.id, trimmedTitle)
}
}
showInput.value = false
titleEditorStore.titleEditorTarget = null

View File

@@ -1,6 +1,20 @@
<template>
<Button
v-show="isVisible"
v-if="isUnpackVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_UnpackSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
>
<template #icon>
<i-lucide:expand />
</template>
</Button>
<Button
v-else-if="isConvertVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
@@ -20,6 +34,7 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -27,7 +42,13 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
const isUnpackVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
const isConvertVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||

View File

@@ -0,0 +1,38 @@
<template>
<Button
v-show="is3DNode"
v-tooltip.top="{
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-pencil"
@click="open3DViewer"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const is3DNode = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isLoad3dNode) && enable3DViewer
})
const open3DViewer = () => {
void commandStore.execute('Comfy.3DViewer.Open3DViewer')
}
</script>

View File

@@ -188,16 +188,13 @@ const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
const moreItems = computed<MenuItem[]>(() => {
const allMoreItems: MenuItem[] = [
{
key: 'desktop-guide',
type: 'item',
label: t('helpCenter.desktopUserGuide'),
visible: isElectron(),
action: () => {
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
@@ -230,6 +227,19 @@ const menuItems = computed<MenuItem[]>(() => {
}
]
// Filter for visible items only
return allMoreItems.filter((item) => item.visible !== false)
})
const hasVisibleMoreItems = computed(() => {
return !!moreItems.value.length
})
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const menuItems = computed<MenuItem[]>(() => {
return [
{
key: 'docs',
@@ -276,8 +286,9 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
icon: '',
label: t('helpCenter.more'),
visible: hasVisibleMoreItems.value,
action: () => {}, // No action for more item
items: moreItems
items: moreItems.value
}
]
})

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