Compare commits

...

59 Commits

Author SHA1 Message Date
bymyself
6fcf5c8348 feat(selection): Conditionally hide "Convert to Subgraph" button
Implements the feature from issue #4866.

The "Convert to Subgraph" button in the selection toolbox is now hidden under the following conditions:
- The selection consists only of group nodes.
- A single SubgraphNode is selected.

This prevents users from attempting to convert invalid selections into subgraphs.
2025-08-12 20:30:46 -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
206 changed files with 19514 additions and 1795 deletions

View File

@@ -210,29 +210,52 @@ 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
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/${BASE_TAG}...v${NEW_VERSION}
EOF
```
5. **CONTENT REVIEW**: Release notes clear and comprehensive with dependency details?
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 9: Create Version Bump PR
@@ -273,38 +296,12 @@ 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
@@ -592,55 +589,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: |

3
.gitignore vendored
View File

@@ -7,6 +7,9 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# ESLint cache
.eslintcache
node_modules
dist
dist-ssr

View File

@@ -9,7 +9,7 @@ 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.

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`.

View File

@@ -255,11 +255,17 @@ npm run format
- Add translations to `src/locales/en/main.json`
- Use translation keys: `const { t } = useI18n(); t('key.path')`
## Custom Icons
## 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.
The project supports three types of icons, all with automatic imports (no manual imports needed):
For detailed instructions on adding and using custom icons, see [src/assets/icons/README.md](src/assets/icons/README.md).
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

View File

@@ -75,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

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

@@ -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

@@ -1,4 +1,4 @@
import { expect } from '@playwright/test'
import { Locator, expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
@@ -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

@@ -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

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 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)
})
})
})

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(['Workflow', '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(['Workflow', '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(['Workflow', '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)
})
})

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

565
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.5",
"version": "1.26.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.5",
"version": "1.26.2",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -30,12 +30,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",
@@ -62,7 +62,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",
@@ -80,6 +79,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",
@@ -1001,6 +1001,40 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
"integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
"integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz",
"integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -3069,6 +3103,19 @@
"node": ">=18"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.3.tgz",
"integrity": "sha512-rZxtMsLwjdXkMUGC3WwsPwLNVqVqnTJT6MNIB6e+5fhMcSCPP0AOsNWuMQ5mdCq6HNjs/ZeWAEchpqeprqBD2Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.5",
"@emnapi/runtime": "^1.4.5",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3120,6 +3167,275 @@
"node": ">=8.0.0"
}
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.6.1.tgz",
"integrity": "sha512-Ma/kg29QJX1Jzelv0Q/j2iFuUad1WnjgPjpThvjqPjpOyLjCUaiFCCnshhmWjyS51Ki1Iol3fjf1qAzObf8GIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-android-arm64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.6.1.tgz",
"integrity": "sha512-xjL/FKKc5p8JkFWiH7pJWSzsewif3fRf1rw2qiRxRvq1uIa6l7Zoa14Zq2TNWEsqDjdeOrlJtfWiPNRnevK0oQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-darwin-arm64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.6.1.tgz",
"integrity": "sha512-u0yrJ3NHE0zyCjiYpIyz4Vmov21MA0yFKbhHgixDU/G6R6nvC8ZpuSFql3+7C8ttAK9p8WpqOGweepfcilH5Bw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-darwin-x64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.6.1.tgz",
"integrity": "sha512-2lox165h1EhzxcC8edUy0znXC/hnAbUPaMpYKVlzLpB2AoYmgU4/pmofFApj+axm2FXpNamjcppld8EoHo06rw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-freebsd-x64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.6.1.tgz",
"integrity": "sha512-F45MhEQ7QbHfsvZtVNuA/9obu3il7QhpXYmCMfxn7Zt9nfAOw4pQ8hlS5DroHVp3rW35u9F7x0sixk/QEAi3qQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.6.1.tgz",
"integrity": "sha512-r+3+MTTl0tD4NoWbfTIItAxJvuyIU7V0fwPDXrv7Uj64vZ3OYaiyV+lVaeU89Bk/FUUQxeUpWBwdKNKHjyRNQw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.6.1.tgz",
"integrity": "sha512-TBTZ63otsWZ72Z8ZNK2JVS0HW1w9zgOixJTFDNrYPUUW1pXGa28KAjQ1yGawj242WLAdu3lwdNIWtkxeO2BLxQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.6.1.tgz",
"integrity": "sha512-SjwhNynjSG2yMdyA0f7wz7Yvo3ppejO+ET7n2oiI7ApCXrwxMzeRWjBzQt+oVWr2HzVOfaEcDS9rMtnR83ulig==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.6.1.tgz",
"integrity": "sha512-f4EMidK6rosInBzPMnJ0Ri4RttFCvvLNUNDFUBtELW/MFkBwPTDlvbsmW0u0Mk/ruBQ2WmRfOZ6tT62kWMcX2Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.6.1.tgz",
"integrity": "sha512-1umENVKeUsrWnf5IlF/6SM7DCv8G6CoKI2LnYR6qhZuLYDPS4PBZ0Jow3UDV9Rtbv5KRPcA3/uXjI88ntWIcOQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.6.1.tgz",
"integrity": "sha512-Hjyp1FRdJhsEpIxsZq5VcDuFc8abC0Bgy8DWEa31trCKoTz7JqA7x3E2dkFbrAKsEFmZZ0NvuG5Ip3oIRARhow==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.6.1.tgz",
"integrity": "sha512-ODJOJng6f3QxpAXhLel3kyWs8rPsJeo9XIZHzA7p//e+5kLMDU7bTVk4eZnUHuxsqsB8MEvPCicJkKCEuur5Ag==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.6.1.tgz",
"integrity": "sha512-hCzRiLhqe1ZOpHTsTGKp7gnMJRORlbCthawBueer2u22RVAka74pV/+4pP1tqM07mSlQn7VATuWaDw9gCl+cVg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.6.1.tgz",
"integrity": "sha512-JansPD8ftOzMYIC3NfXJ68tt63LEcIAx44Blx6BAd7eY880KX7A0KN3hluCrelCz5aQkPaD95g8HBiJmKaEi2w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.6.1.tgz",
"integrity": "sha512-R78ES1rd4z2x5NrFPtSWb/ViR1B8wdl+QN2X8DdtoYcqZE/4tvWtn9ZTCXMEzUp23tchJ2wUB+p6hXoonkyLpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.6.1.tgz",
"integrity": "sha512-qAR3tYIf3afkij/XYunZtlz3OH2Y4ni10etmCFIJB5VRGsqJyI6Hl+2dXHHGJNwbwjXjSEH/KWJBpVroF3TxBw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.0.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.6.1.tgz",
"integrity": "sha512-QqygWygIuemGkaBA48POOTeinbVvlamqh6ucm8arGDGz/mB5O00gXWxed12/uVrYEjeqbMkla/CuL3fjL3EKvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-ia32-msvc": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.6.1.tgz",
"integrity": "sha512-N2+kkWwt/bk0JTCxhPuK8t8JMp3nd0n2OhwOkU8KO4a7roAJEa4K1SZVjMv5CqUIr5sx2CxtXRBoFDiORX5oBg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.6.1.tgz",
"integrity": "sha512-DfMg3cU9bJUbN62Prbp4fGCtLgexuwyEaQGtZAp8xmi1Ii26uflOGx0FJkFTF6lVMSFoIRFvIL8gsw5/ZdHrMw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@pinia/testing": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.5.tgz",
@@ -4486,6 +4802,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
"integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/argparse": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz",
@@ -4559,12 +4886,6 @@
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz",
"integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==",
"dev": true
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
@@ -7670,6 +7991,15 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.39.9",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.9.tgz",
"integrity": "sha512-9OtbkZmTA2Qc9groyA1PUNeb6knVTkvB2RSdr/LcJXDL8IdEakaxwXLHXa7VX/Wj0GmdMJPR3WhnPGhiP3E+qg==",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -8469,16 +8799,17 @@
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.4"
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
@@ -8551,6 +8882,16 @@
"node": ">=0.8.0"
}
},
"node_modules/fd-package-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
"integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"walk-up-path": "^4.0.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -8846,6 +9187,22 @@
"node": ">=0.4.x"
}
},
"node_modules/formatly": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.2.4.tgz",
"integrity": "sha512-lIN7GpcvX/l/i24r/L9bnJ0I8Qn01qijWpQpDDvTLL29nKqSaJJu4h20+7VJ6m2CAhQ2/En/GbxDiHCzq/0MyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fd-package-json": "^2.0.0"
},
"bin": {
"formatly": "bin/index.mjs"
},
"engines": {
"node": ">=18.3.0"
}
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
@@ -10517,6 +10874,109 @@
"node": ">=0.10.0"
}
},
"node_modules/knip": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.62.0.tgz",
"integrity": "sha512-hfTUVzmrMNMT1khlZfAYmBABeehwWUUrizLQoLamoRhSFkygsGIXWx31kaWKBgEaIVL77T3Uz7IxGvSw+CvQ6A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/webpro"
},
{
"type": "opencollective",
"url": "https://opencollective.com/knip"
},
{
"type": "polar",
"url": "https://polar.sh/webpro-nl"
}
],
"license": "ISC",
"dependencies": {
"@nodelib/fs.walk": "^1.2.3",
"fast-glob": "^3.3.3",
"formatly": "^0.2.4",
"jiti": "^2.4.2",
"js-yaml": "^4.1.0",
"minimist": "^1.2.8",
"oxc-resolver": "^11.1.0",
"picocolors": "^1.1.1",
"picomatch": "^4.0.1",
"smol-toml": "^1.3.4",
"strip-json-comments": "5.0.2",
"zod": "^3.22.4",
"zod-validation-error": "^3.0.3"
},
"bin": {
"knip": "bin/knip.js",
"knip-bun": "bin/knip-bun.js"
},
"engines": {
"node": ">=18.18.0"
},
"peerDependencies": {
"@types/node": ">=18",
"typescript": ">=5.0.4"
}
},
"node_modules/knip/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/knip/node_modules/jiti": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/knip/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/knip/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/knip/node_modules/strip-json-comments": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.2.tgz",
"integrity": "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -10992,7 +11452,8 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
@@ -12388,6 +12849,22 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-postinstall": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz",
"integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==",
"dev": true,
"license": "MIT",
"bin": {
"napi-postinstall": "lib/cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/napi-postinstall"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12741,6 +13218,41 @@
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/oxc-resolver": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.6.1.tgz",
"integrity": "sha512-WQgmxevT4cM5MZ9ioQnEwJiHpPzbvntV5nInGAKo9NQZzegcOonHvcVcnkYqld7bTG35UFHEKeF7VwwsmA3cZg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"napi-postinstall": "^0.3.0"
},
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-resolver/binding-android-arm-eabi": "11.6.1",
"@oxc-resolver/binding-android-arm64": "11.6.1",
"@oxc-resolver/binding-darwin-arm64": "11.6.1",
"@oxc-resolver/binding-darwin-x64": "11.6.1",
"@oxc-resolver/binding-freebsd-x64": "11.6.1",
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.6.1",
"@oxc-resolver/binding-linux-arm-musleabihf": "11.6.1",
"@oxc-resolver/binding-linux-arm64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-arm64-musl": "11.6.1",
"@oxc-resolver/binding-linux-ppc64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-riscv64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-riscv64-musl": "11.6.1",
"@oxc-resolver/binding-linux-s390x-gnu": "11.6.1",
"@oxc-resolver/binding-linux-x64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-x64-musl": "11.6.1",
"@oxc-resolver/binding-wasm32-wasi": "11.6.1",
"@oxc-resolver/binding-win32-arm64-msvc": "11.6.1",
"@oxc-resolver/binding-win32-ia32-msvc": "11.6.1",
"@oxc-resolver/binding-win32-x64-msvc": "11.6.1"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@@ -14804,6 +15316,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/smol-toml": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz",
"integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -17400,6 +17925,16 @@
"node": ">=14"
}
},
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.25.5",
"version": "1.26.2",
"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

@@ -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,119 @@
<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">
{{ command.label || command.id }}
</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'
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

@@ -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

@@ -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

@@ -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

@@ -1,31 +1,66 @@
<template>
<div
v-if="visible && initialized"
ref="containerRef"
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
:style="containerStyles"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
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, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import MiniMapPanel from './MiniMapPanel.vue'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
@@ -38,6 +73,13 @@ const {
viewportStyles,
width,
height,
panelStyles,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
@@ -46,6 +88,12 @@ const {
handleWheel
} = minimap
const showOptionsPanel = ref(false)
const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
watch(
() => canvasStore.canvas,
async (canvas) => {

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

@@ -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 { LGraphGroup, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -27,7 +42,31 @@ 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(() => {
const items = canvasStore.selectedItems
if (!items || items.length === 0) {
return false
}
// Unpack button takes precedence for single subgraph node
if (items.length === 1 && items[0] instanceof SubgraphNode) {
return false
}
// Hide if ALL selected items are groups
const allAreGroups = items.every((item) => item instanceof LGraphGroup)
if (allAreGroups) {
return false
}
// Otherwise, show it, assuming there's some selection
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
}
]
})

View File

@@ -58,8 +58,19 @@
@export-model="handleExportModel"
/>
<div
v-if="showRecordingControls"
v-if="enable3DViewer"
class="absolute top-12 right-2 z-20 pointer-events-auto"
>
<ViewerControls :node="node" />
</div>
<div
v-if="showRecordingControls"
class="absolute right-2 z-20 pointer-events-auto"
:class="{
'top-12': !enable3DViewer,
'top-24': enable3DViewer
}"
>
<RecordingControls
:node="node"
@@ -82,6 +93,7 @@ import { useI18n } from 'vue-i18n'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
CameraType,
@@ -91,6 +103,7 @@ import {
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
const { t } = useI18n()
@@ -121,6 +134,9 @@ const isRecording = ref(false)
const hasRecording = ref(false)
const recordingDuration = ref(0)
const showRecordingControls = ref(!inputSpec.isPreview)
const enable3DViewer = computed(() =>
useSettingStore().get('Comfy.Load3D.3DViewerEnable')
)
const showPreviewButton = computed(() => {
return !type.includes('Preview')

View File

@@ -0,0 +1,149 @@
<template>
<div
ref="viewerContentRef"
class="flex w-full"
:class="[maximized ? 'h-full' : 'h-[70vh]']"
@mouseenter="viewer.handleMouseEnter"
@mouseleave="viewer.handleMouseLeave"
>
<div ref="mainContentRef" class="flex-1 relative">
<div
ref="containerRef"
class="absolute w-full h-full comfy-load-3d-viewer"
@resize="viewer.handleResize"
/>
</div>
<div class="w-72 flex flex-col">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="p-2 space-y-4">
<SceneControls
v-model:background-color="viewer.backgroundColor.value"
v-model:show-grid="viewer.showGrid.value"
:has-background-image="viewer.hasBackgroundImage.value"
@update-background-image="viewer.handleBackgroundImageUpdate"
/>
</div>
<div class="p-2 space-y-4">
<ModelControls
v-model:up-direction="viewer.upDirection.value"
v-model:material-mode="viewer.materialMode.value"
/>
</div>
<div class="p-2 space-y-4">
<CameraControls
v-model:camera-type="viewer.cameraType.value"
v-model:fov="viewer.fov.value"
/>
</div>
<div class="p-2 space-y-4">
<LightControls
v-model:light-intensity="viewer.lightIntensity.value"
/>
</div>
<div class="p-2 space-y-4">
<ExportControls @export-model="viewer.exportModel" />
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button
icon="pi pi-times"
severity="secondary"
:label="t('g.cancel')"
@click="handleCancel"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
import CameraControls from '@/components/load3d/controls/viewer/CameraControls.vue'
import ExportControls from '@/components/load3d/controls/viewer/ExportControls.vue'
import LightControls from '@/components/load3d/controls/viewer/LightControls.vue'
import ModelControls from '@/components/load3d/controls/viewer/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/SceneControls.vue'
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
node: LGraphNode
}>()
const viewerContentRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>()
const mainContentRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
onMounted(async () => {
const source = useLoad3dService().getLoad3d(props.node)
if (source && containerRef.value) {
await viewer.initializeViewer(containerRef.value, source)
}
if (viewerContentRef.value) {
mutationObserver.value = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'maximized'
) {
maximized.value =
(mutation.target as HTMLElement).getAttribute('maximized') ===
'true'
setTimeout(() => {
viewer.refreshViewport()
}, 0)
}
})
})
mutationObserver.value.observe(viewerContentRef.value, {
attributes: true,
attributeFilter: ['maximized']
})
}
window.addEventListener('resize', viewer.handleResize)
})
const handleCancel = () => {
viewer.restoreInitialState()
useDialogStore().closeDialog()
}
onBeforeUnmount(() => {
window.removeEventListener('resize', viewer.handleResize)
if (mutationObserver.value) {
mutationObserver.value.disconnect()
mutationObserver.value = null
}
// we will manually cleanup the viewer in dialog close handler
})
</script>
<style scoped>
:deep(.p-panel-content) {
padding: 0;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="relative bg-gray-700 bg-opacity-30 rounded-lg">
<div class="flex flex-col gap-2">
<Button class="p-button-rounded p-button-text" @click="openIn3DViewer">
<i
v-tooltip.right="{
value: t('load3d.openIn3DViewer'),
showDelay: 300
}"
class="pi pi-expand text-white text-lg"
/>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const vTooltip = Tooltip
const { node } = defineProps<{
node: LGraphNode
}>()
const openIn3DViewer = () => {
const props = { node: node }
useDialogStore().showDialog({
key: 'global-load3d-viewer',
title: t('load3d.viewer.title'),
component: Load3DViewerContent,
props: props,
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true,
onClose: async () => {
await useLoad3dService().handleViewerClose(props.node)
}
}
})
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="space-y-4">
<label>
{{ t('load3d.viewer.cameraType') }}
</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
</Select>
</div>
<div v-if="showFOVButton" class="space-y-4">
<label>{{ t('load3d.fov') }}</label>
<Slider v-model="fov" :min="10" :max="150" :step="1" aria-label="fov" />
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { CameraType } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const cameras = [
{ title: t('load3d.cameraType.perspective'), value: 'perspective' },
{ title: t('load3d.cameraType.orthographic'), value: 'orthographic' }
]
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
</script>

View File

@@ -0,0 +1,37 @@
<template>
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
</Select>
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
{{ t('load3d.export') }}
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref } from 'vue'
import { t } from '@/i18n'
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
]
const exportFormat = ref('obj')
const exportModel = (format: string) => {
emit('exportModel', format)
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<label>{{ t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
/>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { t } from '@/i18n'
import { useSettingStore } from '@/stores/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
const lightIntensityMaximum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMaximum'
)
const lightIntensityMinimum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMinimum'
)
const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="space-y-4">
<div>
<label>{{ t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
:options="upDirectionOptions"
option-label="label"
option-value="value"
/>
</div>
<div>
<label>{{ t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
:options="materialModeOptions"
option-label="label"
option-value="value"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const upDirection = defineModel<UpDirection>('upDirection')
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirectionOptions = [
{ label: t('load3d.upDirections.original'), value: 'original' },
{ label: '-X', value: '-x' },
{ label: '+X', value: '+x' },
{ label: '-Y', value: '-y' },
{ label: '+Y', value: '+y' },
{ label: '-Z', value: '-z' },
{ label: '+Z', value: '+z' }
]
const materialModeOptions = computed(() => {
return [
{ label: t('load3d.materialModes.original'), value: 'original' },
{ label: t('load3d.materialModes.normal'), value: 'normal' },
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
]
})
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="space-y-4">
<div v-if="!hasBackgroundImage">
<label>
{{ t('load3d.backgroundColor') }}
</label>
<input v-model="backgroundColor" type="color" class="w-full" />
</div>
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
{{ t('load3d.showGrid') }}
</label>
</div>
<div v-if="!hasBackgroundImage">
<Button
severity="secondary"
:label="t('load3d.uploadBackgroundImage')"
icon="pi pi-image"
class="w-full"
@click="openImagePicker"
/>
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="hidden"
@change="handleImageUpload"
/>
</div>
<div v-if="hasBackgroundImage" class="space-y-2">
<Button
severity="secondary"
:label="t('load3d.removeBackgroundImage')"
icon="pi pi-times"
class="w-full"
@click="removeBackgroundImage"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import { t } from '@/i18n'
const backgroundColor = defineModel<string>('backgroundColor')
const showGrid = defineModel<boolean>('showGrid')
defineProps<{
hasBackgroundImage?: boolean
}>()
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()
const imagePickerRef = ref<HTMLInputElement | null>(null)
const openImagePicker = () => {
imagePickerRef.value?.click()
}
const handleImageUpload = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('updateBackgroundImage', input.files[0])
}
input.value = ''
}
const removeBackgroundImage = () => {
emit('updateBackgroundImage', null)
}
</script>

View File

@@ -82,7 +82,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
</template>
<script setup lang="ts">
import _ from 'lodash'
import _ from 'es-toolkit/compat'
import { computed } from 'vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'

View File

@@ -16,6 +16,7 @@
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarHelpCenterIcon />
<SidebarBottomPanelToggleButton />
<SidebarShortcutsToggleButton />
</div>
</nav>
</teleport>
@@ -32,6 +33,7 @@ import { computed } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
import { useUserStore } from '@/stores/userStore'

View File

@@ -1,7 +1,7 @@
<template>
<SidebarIcon
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.bottomPanelVisible"
:selected="bottomPanelStore.activePanel == 'terminal'"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>

View File

@@ -0,0 +1,44 @@
<template>
<SidebarIcon
:tooltip="
$t('shortcuts.keyboardShortcuts') +
' (' +
formatKeySequence(command.keybinding!.combo.getKeySequences()) +
')'
"
:selected="isShortcutsPanelVisible"
@click="toggleShortcutsPanel"
>
<template #icon>
<i-lucide:keyboard />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import SidebarIcon from './SidebarIcon.vue'
const bottomPanelStore = useBottomPanelStore()
const command = useCommandStore().getCommand(
'Workspace.ToggleBottomPanel.Shortcuts'
)
const isShortcutsPanelVisible = computed(
() => bottomPanelStore.activePanel === 'shortcuts'
)
const toggleShortcutsPanel = () => {
bottomPanelStore.togglePanel('shortcuts')
}
const formatKeySequence = (sequences: string[]): string => {
return sequences
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
.join(' + ')
}
</script>

View File

@@ -265,6 +265,14 @@ const renderTreeNode = (
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.duplicateWorkflow(workflow)
}
}
]
},

View File

@@ -1,13 +1,13 @@
<template>
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
<span
v-tooltip.bottom="{
value: workflowOption.workflow.key,
class: 'workflow-tab-tooltip',
showDelay: 512
}"
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
>
<div
ref="workflowTabRef"
class="flex p-2 gap-2 workflow-tab"
v-bind="$attrs"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<span class="workflow-label text-sm max-w-[150px] truncate inline-block">
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
@@ -22,23 +22,33 @@
/>
</div>
</div>
<WorkflowTabPopover
ref="popoverRef"
:workflow-filename="workflowOption.workflow.filename"
:thumbnail-url="thumbnailUrl"
:is-active-tab="isActiveTab"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import { useWorkflowService } from '@/services/workflowService'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
interface WorkflowOption {
value: string
workflow: ComfyWorkflow
@@ -55,6 +65,8 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
// Use computed refs to cache autosave settings
const autoSaveSetting = computed(() =>
@@ -90,6 +102,27 @@ const shouldShowStatusIndicator = computed(() => {
return false
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})
// Event handlers that delegate to the popover component
const handleMouseEnter = (event: Event) => {
popoverRef.value?.showPopover(event)
}
const handleMouseLeave = () => {
popoverRef.value?.hidePopover()
}
const handleClick = (event: Event) => {
popoverRef.value?.togglePopover(event)
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (
@@ -135,6 +168,10 @@ usePragmaticDroppable(tabGetter, {
}
}
})
onUnmounted(() => {
popoverRef.value?.hidePopover()
})
</script>
<style scoped>

View File

@@ -0,0 +1,229 @@
<template>
<div
ref="positionRef"
class="absolute left-1/2 -translate-x-1/2"
:class="positions.positioner"
></div>
<Popover
ref="popoverRef"
append-to="body"
:pt="{
root: {
class: 'workflow-popover-fade fit-content ' + positions.root,
'data-popover-id': id,
style: {
transform: positions.active
}
}
}"
@mouseenter="cancelHidePopover"
@mouseleave="hidePopover"
>
<div class="workflow-preview-content">
<div
v-if="thumbnailUrl && !isActiveTab"
class="workflow-preview-thumbnail relative"
>
<img
:src="thumbnailUrl"
class="block h-[200px] object-cover rounded-lg p-2"
:style="{ width: `${POPOVER_WIDTH}px` }"
/>
</div>
<div class="workflow-preview-footer">
<span class="workflow-preview-name">{{ workflowFilename }}</span>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, nextTick, ref, toRefs, useId } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
const POPOVER_WIDTH = 250
interface Props {
workflowFilename: string
thumbnailUrl?: string
isActiveTab: boolean
}
const props = defineProps<Props>()
const { thumbnailUrl, isActiveTab } = toRefs(props)
const settingStore = useSettingStore()
const positions = computed<{
positioner: string
root?: string
active?: string
}>(() => {
if (
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') === 'Topbar' &&
settingStore.get('Comfy.UseNewMenu') === 'Bottom'
) {
return {
positioner: 'top-0',
root: 'p-popover-flipped',
active: isActiveTab.value ? 'translateY(-100%)' : undefined
}
}
return {
positioner: 'bottom-0'
}
})
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
const positionRef = ref<HTMLElement | null>(null)
let hideTimeout: ReturnType<typeof setTimeout> | null = null
let showTimeout: ReturnType<typeof setTimeout> | null = null
const id = useId()
const showPopover = (event: Event) => {
// Clear any existing timeouts
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
// Show popover after a short delay
showTimeout = setTimeout(async () => {
if (popoverRef.value && positionRef.value) {
popoverRef.value.show(event, positionRef.value)
await nextTick()
// PrimeVue has a bug where when the tabs are scrolled, it positions the element incorrectly
// Manually set the position to the middle of the tab and prevent it from going off the left/right edge
const el = document.querySelector(
`.workflow-popover-fade[data-popover-id="${id}"]`
) as HTMLElement
if (el) {
const middle = positionRef.value!.getBoundingClientRect().left
const popoverWidth = el.getBoundingClientRect().width
const halfWidth = popoverWidth / 2
let pos = middle - halfWidth
let shift = 0
// Calculate shift when clamping is needed
if (pos < 0) {
shift = pos - 8 // Negative shift to move arrow left
pos = 8
} else if (pos + popoverWidth > window.innerWidth) {
const newPos = window.innerWidth - popoverWidth - 16
shift = pos - newPos // Positive shift to move arrow right
pos = newPos
}
if (shift + halfWidth < 0) {
shift = -halfWidth + 24
}
el.style.left = `${pos}px`
el.style.setProperty('--shift', `${shift}px`)
}
}
}, 200) // 200ms delay before showing
}
const cancelHidePopover = () => {
// Temporarily disable this functionality until we need the popover to be interactive:
/*
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
*/
}
const hidePopover = () => {
// Clear show timeout if mouse leaves before popover appears
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
hideTimeout = setTimeout(() => {
if (popoverRef.value) {
popoverRef.value.hide()
}
}, 100) // Minimal delay to allow moving to popover
}
const togglePopover = (event: Event) => {
if (popoverRef.value) {
popoverRef.value.toggle(event)
}
}
defineExpose({
showPopover,
hidePopover,
togglePopover
})
</script>
<style scoped>
.workflow-preview-content {
@apply flex flex-col rounded-xl overflow-hidden;
max-width: var(--popover-width);
background-color: var(--comfy-menu-secondary-bg);
color: var(--fg-color);
}
.workflow-preview-thumbnail {
@apply relative p-2;
}
.workflow-preview-thumbnail img {
@apply shadow-md;
background-color: color-mix(
in srgb,
var(--comfy-menu-secondary-bg) 70%,
black
);
}
.dark-theme .workflow-preview-thumbnail img {
@apply shadow-lg;
}
.workflow-preview-footer {
@apply pt-1 pb-2 px-3;
}
.workflow-preview-name {
@apply block text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap;
color: var(--fg-color);
}
</style>
<style>
.workflow-popover-fade {
--p-popover-background: transparent;
--p-popover-content-padding: 0;
@apply bg-transparent rounded-xl shadow-lg;
transition: opacity 0.15s ease-out !important;
}
.workflow-popover-fade.p-popover-flipped {
@apply -translate-y-full;
}
.dark-theme .workflow-popover-fade {
@apply shadow-2xl;
}
.workflow-popover-fade.p-popover:after,
.workflow-popover-fade.p-popover:before {
--p-popover-border-color: var(--comfy-menu-secondary-bg);
left: 50%;
transform: translateX(calc(-50% + var(--shift)));
margin-left: 0;
}
</style>

View File

@@ -0,0 +1,78 @@
import { type ComputedRef, computed } from 'vue'
import { type ComfyCommandImpl } from '@/stores/commandStore'
export type SubcategoryRule = {
pattern: string | RegExp
subcategory: string
}
export type SubcategoryConfig = {
defaultSubcategory: string
rules: SubcategoryRule[]
}
/**
* Composable for grouping commands by subcategory based on configurable rules
*/
export function useCommandSubcategories(
commands: ComputedRef<ComfyCommandImpl[]>,
config: SubcategoryConfig
) {
const subcategories = computed(() => {
const result: Record<string, ComfyCommandImpl[]> = {}
for (const command of commands.value) {
let subcategory = config.defaultSubcategory
// Find the first matching rule
for (const rule of config.rules) {
const matches =
typeof rule.pattern === 'string'
? command.id.includes(rule.pattern)
: rule.pattern.test(command.id)
if (matches) {
subcategory = rule.subcategory
break
}
}
if (!result[subcategory]) {
result[subcategory] = []
}
result[subcategory].push(command)
}
return result
})
return {
subcategories
}
}
/**
* Predefined configuration for view controls subcategories
*/
export const VIEW_CONTROLS_CONFIG: SubcategoryConfig = {
defaultSubcategory: 'view',
rules: [
{ pattern: 'Zoom', subcategory: 'view' },
{ pattern: 'Fit', subcategory: 'view' },
{ pattern: 'Panel', subcategory: 'panel-controls' },
{ pattern: 'Sidebar', subcategory: 'panel-controls' }
]
}
/**
* Predefined configuration for essentials subcategories
*/
export const ESSENTIALS_CONFIG: SubcategoryConfig = {
defaultSubcategory: 'workflow',
rules: [
{ pattern: 'Workflow', subcategory: 'workflow' },
{ pattern: 'Node', subcategory: 'node' },
{ pattern: 'Queue', subcategory: 'queue' }
]
}

View File

@@ -0,0 +1,27 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ViewControlsPanel from '@/components/bottomPanel/tabs/shortcuts/ViewControlsPanel.vue'
import { BottomPanelExtension } from '@/types/extensionTypes'
export const useShortcutsTab = (): BottomPanelExtension[] => {
const { t } = useI18n()
return [
{
id: 'shortcuts-essentials',
title: t('shortcuts.essentials'),
component: markRaw(EssentialsPanel),
type: 'vue',
targetPanel: 'shortcuts'
},
{
id: 'shortcuts-view-controls',
title: t('shortcuts.viewControls'),
component: markRaw(ViewControlsPanel),
type: 'vue',
targetPanel: 'shortcuts'
}
]
}

View File

@@ -1,7 +1,7 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import { Ref, markRaw, onMounted, onUnmounted } from 'vue'
export function useTerminal(element: Ref<HTMLElement | undefined>) {

View File

@@ -1,5 +1,5 @@
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import { readonly, ref } from 'vue'
/**

View File

@@ -0,0 +1,80 @@
import { onMounted, ref, watch } from 'vue'
import type { Ref, WatchSource } from 'vue'
/**
* A composable that manages retriggerable CSS animations.
* Provides a boolean ref that can be toggled to restart CSS animations.
*
* @param trigger - Optional reactive source that triggers the animation when it changes
* @param options - Configuration options
* @returns An object containing the animation state ref
*
* @example
* ```vue
* <template>
* <div :class="{ 'animate-slide-up': shouldAnimate }">
* Content
* </div>
* </template>
*
* <script setup>
* const { shouldAnimate } = useRetriggerableAnimation(someReactiveTrigger)
* </script>
* ```
*/
export function useRetriggerableAnimation<T = any>(
trigger?: WatchSource<T> | Ref<T>,
options: {
animateOnMount?: boolean
animationDelay?: number
} = {}
) {
const { animateOnMount = true, animationDelay = 0 } = options
const shouldAnimate = ref(false)
/**
* Retriggers the animation by removing and re-adding the animation class
*/
const retriggerAnimation = () => {
// Remove animation class
shouldAnimate.value = false
// Force browser reflow to ensure the class removal is processed
void document.body.offsetHeight
// Re-add animation class in the next frame
requestAnimationFrame(() => {
if (animationDelay > 0) {
setTimeout(() => {
shouldAnimate.value = true
}, animationDelay)
} else {
shouldAnimate.value = true
}
})
}
// Trigger animation on mount if requested
if (animateOnMount) {
onMounted(() => {
if (animationDelay > 0) {
setTimeout(() => {
shouldAnimate.value = true
}, animationDelay)
} else {
shouldAnimate.value = true
}
})
}
// Watch for trigger changes to retrigger animation
if (trigger) {
watch(trigger, () => {
retriggerAnimation()
})
}
return {
shouldAnimate,
retriggerAnimation
}
}

View File

@@ -1,4 +1,4 @@
import _ from 'lodash'
import _ from 'es-toolkit/compat'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'

View File

@@ -418,7 +418,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Pricing matrix from CSV data based on mode string content
if (modeValue.includes('v2-master')) {
if (modeValue.includes('v2-1-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run' // price is the same as for v2-master model
}
return '$1.40/Run' // price is the same as for v2-master model
} else if (modeValue.includes('v2-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run'
}
@@ -558,6 +563,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
MinimaxTextToVideoNode: {
displayPrice: '$0.43/Run'
},
MinimaxHailuoVideoNode: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!resolutionWidget || !durationWidget) {
return '$0.28-0.56/Run (varies with resolution & duration)'
}
const resolution = String(resolutionWidget.value)
const duration = String(durationWidget.value)
if (resolution.includes('768P')) {
if (duration.includes('6')) return '$0.28/Run'
if (duration.includes('10')) return '$0.56/Run'
} else if (resolution.includes('1080P')) {
if (duration.includes('6')) return '$0.49/Run'
}
return '$0.43/Run' // default median
}
},
OpenAIDalle2: {
displayPrice: (node: LGraphNode): string => {
const sizeWidget = node.widgets?.find(
@@ -1278,9 +1309,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00016/$0.0006 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-flash')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00125/$0.01 per 1K tokens'
} else if (model.includes('gemini-2.5-pro')) {
return '$0.00125/$0.01 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
@@ -1358,6 +1393,7 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIGPTImage1: ['quality', 'n'],

View File

@@ -1,4 +1,4 @@
import { groupBy } from 'lodash'
import { groupBy } from 'es-toolkit/compat'
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'

View File

@@ -21,8 +21,10 @@ import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useToastStore } from '@/stores/toastStore'
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -46,6 +48,9 @@ export function useCoreCommands(): ComfyCommand[] {
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const bottomPanelStore = useBottomPanelStore()
const { getSelectedNodes, toggleSelectedNodesMode } =
useSelectedLiteGraphItems()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
@@ -70,6 +75,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-plus',
label: 'New Blank Workflow',
menubarLabel: 'New',
category: 'essentials' as const,
function: () => workflowService.loadBlankWorkflow()
},
{
@@ -77,6 +83,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-folder-open',
label: 'Open Workflow',
menubarLabel: 'Open',
category: 'essentials' as const,
function: () => {
app.ui.loadFile()
}
@@ -92,6 +99,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-save',
label: 'Save Workflow',
menubarLabel: 'Save',
category: 'essentials' as const,
function: async () => {
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
@@ -104,6 +112,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-save',
label: 'Save Workflow As',
menubarLabel: 'Save As',
category: 'essentials' as const,
function: async () => {
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
@@ -116,6 +125,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-download',
label: 'Export Workflow',
menubarLabel: 'Export',
category: 'essentials' as const,
function: async () => {
await workflowService.exportWorkflow('workflow', 'workflow')
}
@@ -133,6 +143,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Undo',
icon: 'pi pi-undo',
label: 'Undo',
category: 'essentials' as const,
function: async () => {
await getTracker()?.undo?.()
}
@@ -141,6 +152,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Redo',
icon: 'pi pi-refresh',
label: 'Redo',
category: 'essentials' as const,
function: async () => {
await getTracker()?.redo?.()
}
@@ -149,6 +161,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.ClearWorkflow',
icon: 'pi pi-trash',
label: 'Clear Workflow',
category: 'essentials' as const,
function: () => {
const settingStore = useSettingStore()
if (
@@ -190,6 +203,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.RefreshNodeDefinitions',
icon: 'pi pi-refresh',
label: 'Refresh Node Definitions',
category: 'essentials' as const,
function: async () => {
await app.refreshComboInNodes()
}
@@ -198,6 +212,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Interrupt',
icon: 'pi pi-stop',
label: 'Interrupt',
category: 'essentials' as const,
function: async () => {
await api.interrupt(executionStore.activePromptId)
toastStore.add({
@@ -212,6 +227,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.ClearPendingTasks',
icon: 'pi pi-stop',
label: 'Clear Pending Tasks',
category: 'essentials' as const,
function: async () => {
await useQueueStore().clear(['queue'])
toastStore.add({
@@ -234,6 +250,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.ZoomIn',
icon: 'pi pi-plus',
label: 'Zoom In',
category: 'view-controls' as const,
function: () => {
const ds = app.canvas.ds
ds.changeScale(
@@ -247,6 +264,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.ZoomOut',
icon: 'pi pi-minus',
label: 'Zoom Out',
category: 'view-controls' as const,
function: () => {
const ds = app.canvas.ds
ds.changeScale(
@@ -260,6 +278,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.FitView',
icon: 'pi pi-expand',
label: 'Fit view to selected nodes',
category: 'view-controls' as const,
function: () => {
if (app.canvas.empty) {
toastStore.add({
@@ -325,6 +344,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-play',
label: 'Queue Prompt',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
await app.queuePrompt(0, batchCount)
@@ -335,6 +355,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-play',
label: 'Queue Prompt (Front)',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
await app.queuePrompt(-1, batchCount)
@@ -371,6 +392,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-cog',
label: 'Show Settings Dialog',
versionAdded: '1.3.7',
category: 'view-controls' as const,
function: () => {
dialogService.showSettingsDialog()
}
@@ -380,6 +402,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-sitemap',
label: 'Group Selected Nodes',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: () => {
const { canvas } = app
if (!canvas.selectedItems?.size) {
@@ -423,6 +446,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-volume-off',
label: 'Mute/Unmute Selected Nodes',
versionAdded: '1.3.11',
category: 'essentials' as const,
function: () => {
toggleSelectedNodesMode(LGraphEventMode.NEVER)
app.canvas.setDirty(true, true)
@@ -433,6 +457,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-shield',
label: 'Bypass/Unbypass Selected Nodes',
versionAdded: '1.3.11',
category: 'essentials' as const,
function: () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true)
@@ -443,6 +468,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-pin',
label: 'Pin/Unpin Selected Nodes',
versionAdded: '1.3.11',
category: 'essentials' as const,
function: () => {
getSelectedNodes().forEach((node) => {
node.pin(!node.pinned)
@@ -516,8 +542,9 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-list',
label: 'Toggle Bottom Panel',
versionAdded: '1.3.22',
category: 'view-controls' as const,
function: () => {
useBottomPanelStore().toggleBottomPanel()
bottomPanelStore.toggleBottomPanel()
}
},
{
@@ -525,6 +552,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-eye',
label: 'Toggle Focus Mode',
versionAdded: '1.3.27',
category: 'view-controls' as const,
function: () => {
useWorkspaceStore().toggleFocusMode()
}
@@ -750,6 +778,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-sitemap',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
@@ -768,6 +797,47 @@ export function useCoreCommands(): ComfyCommand[] {
const { node } = res
canvas.select(node)
}
},
{
id: 'Comfy.Graph.UnpackSubgraph',
icon: 'pi pi-sitemap',
label: 'Unpack the selected Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const subgraphNode = app.canvas.selectedItems.values().next().value
useNodeOutputStore().revokeSubgraphPreviews(subgraphNode)
graph.unpackSubgraph(subgraphNode)
}
},
{
id: 'Workspace.ToggleBottomPanel.Shortcuts',
icon: 'pi pi-key',
label: 'Show Keybindings Dialog',
versionAdded: '1.24.1',
category: 'view-controls' as const,
function: () => {
bottomPanelStore.togglePanel('shortcuts')
}
},
{
id: 'Comfy.Graph.ExitSubgraph',
icon: 'pi pi-arrow-up',
label: 'Exit Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = useCanvasStore().getCanvas()
const navigationStore = useSubgraphNavigationStore()
if (!canvas.graph) return
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
}
]

View File

@@ -0,0 +1,376 @@
import { ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore'
interface Load3dViewerState {
backgroundColor: string
showGrid: boolean
cameraType: CameraType
fov: number
lightIntensity: number
cameraState: any
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold: number
}
export const useLoad3dViewer = (node: LGraphNode) => {
const backgroundColor = ref('')
const showGrid = ref(true)
const cameraType = ref<CameraType>('perspective')
const fov = ref(75)
const lightIntensity = ref(1)
const backgroundImage = ref('')
const hasBackgroundImage = ref(false)
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const needApplyChanges = ref(true)
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
showGrid: true,
cameraType: 'perspective',
fov: 75,
lightIntensity: 1,
cameraState: null,
backgroundImage: '',
upDirection: 'original',
materialMode: 'original',
edgeThreshold: 85
})
watch(backgroundColor, (newColor) => {
if (!load3d) return
try {
load3d.setBackgroundColor(newColor)
} catch (error) {
console.error('Error updating background color:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateBackgroundColor', { color: newColor })
)
}
})
watch(showGrid, (newValue) => {
if (!load3d) return
try {
load3d.toggleGrid(newValue)
} catch (error) {
console.error('Error toggling grid:', error)
useToastStore().addAlert(
t('toastMessages.failedToToggleGrid', { show: newValue ? 'on' : 'off' })
)
}
})
watch(cameraType, (newCameraType) => {
if (!load3d) return
try {
load3d.toggleCamera(newCameraType)
} catch (error) {
console.error('Error toggling camera:', error)
useToastStore().addAlert(
t('toastMessages.failedToToggleCamera', { camera: newCameraType })
)
}
})
watch(fov, (newFov) => {
if (!load3d) return
try {
load3d.setFOV(Number(newFov))
} catch (error) {
console.error('Error updating FOV:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateFOV', { fov: newFov })
)
}
})
watch(lightIntensity, (newValue) => {
if (!load3d) return
try {
load3d.setLightIntensity(Number(newValue))
} catch (error) {
console.error('Error updating light intensity:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateLightIntensity', { intensity: newValue })
)
}
})
watch(backgroundImage, async (newValue) => {
if (!load3d) return
try {
await load3d.setBackgroundImage(newValue)
hasBackgroundImage.value = !!newValue
} catch (error) {
console.error('Error updating background image:', error)
useToastStore().addAlert(t('toastMessages.failedToUpdateBackgroundImage'))
}
})
watch(upDirection, (newValue) => {
if (!load3d) return
try {
load3d.setUpDirection(newValue)
} catch (error) {
console.error('Error updating up direction:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateUpDirection', { direction: newValue })
)
}
})
watch(materialMode, (newValue) => {
if (!load3d) return
try {
load3d.setMaterialMode(newValue)
} catch (error) {
console.error('Error updating material mode:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateMaterialMode', { mode: newValue })
)
}
})
watch(edgeThreshold, (newValue) => {
if (!load3d) return
try {
load3d.setEdgeThreshold(Number(newValue))
} catch (error) {
console.error('Error updating edge threshold:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateEdgeThreshold', { threshold: newValue })
)
}
})
const initializeViewer = async (
containerRef: HTMLElement,
source: Load3d
) => {
if (!containerRef) return
sourceLoad3d = source
try {
load3d = new Load3d(containerRef, {
node: node,
disablePreview: true,
isViewerMode: true
})
await useLoad3dService().copyLoad3dState(source, load3d)
const sourceCameraType = source.getCurrentCameraType()
const sourceCameraState = source.getCameraState()
cameraType.value = sourceCameraType
backgroundColor.value = source.sceneManager.currentBackgroundColor
showGrid.value = source.sceneManager.gridHelper.visible
lightIntensity.value = (node.properties['Light Intensity'] as number) || 1
const backgroundInfo = source.sceneManager.getCurrentBackgroundInfo()
if (
backgroundInfo.type === 'image' &&
node.properties['Background Image']
) {
backgroundImage.value = node.properties['Background Image'] as string
hasBackgroundImage.value = true
} else {
backgroundImage.value = ''
hasBackgroundImage.value = false
}
if (sourceCameraType === 'perspective') {
fov.value = source.cameraManager.perspectiveCamera.fov
}
upDirection.value = source.modelManager.currentUpDirection
materialMode.value = source.modelManager.materialMode
edgeThreshold.value = (node.properties['Edge Threshold'] as number) || 85
initialState.value = {
backgroundColor: backgroundColor.value,
showGrid: showGrid.value,
cameraType: cameraType.value,
fov: fov.value,
lightIntensity: lightIntensity.value,
cameraState: sourceCameraState,
backgroundImage: backgroundImage.value,
upDirection: upDirection.value,
materialMode: materialMode.value,
edgeThreshold: edgeThreshold.value
}
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (width && height) {
load3d.setTargetSize(
toRaw(width).value as number,
toRaw(height).value as number
)
}
} catch (error) {
console.error('Error initializing Load3d viewer:', error)
useToastStore().addAlert(
t('toastMessages.failedToInitializeLoad3dViewer')
)
}
}
const exportModel = async (format: string) => {
if (!load3d) return
try {
await load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', { format: format.toUpperCase() })
)
}
}
const handleResize = () => {
load3d?.handleResize()
}
const handleMouseEnter = () => {
load3d?.updateStatusMouseOnViewer(true)
}
const handleMouseLeave = () => {
load3d?.updateStatusMouseOnViewer(false)
}
const restoreInitialState = () => {
const nodeValue = node
needApplyChanges.value = false
if (nodeValue.properties) {
nodeValue.properties['Background Color'] =
initialState.value.backgroundColor
nodeValue.properties['Show Grid'] = initialState.value.showGrid
nodeValue.properties['Camera Type'] = initialState.value.cameraType
nodeValue.properties['FOV'] = initialState.value.fov
nodeValue.properties['Light Intensity'] =
initialState.value.lightIntensity
nodeValue.properties['Camera Info'] = initialState.value.cameraState
nodeValue.properties['Background Image'] =
initialState.value.backgroundImage
}
}
const applyChanges = async () => {
if (!sourceLoad3d || !load3d) return false
const viewerCameraState = load3d.getCameraState()
const nodeValue = node
if (nodeValue.properties) {
nodeValue.properties['Background Color'] = backgroundColor.value
nodeValue.properties['Show Grid'] = showGrid.value
nodeValue.properties['Camera Type'] = cameraType.value
nodeValue.properties['FOV'] = fov.value
nodeValue.properties['Light Intensity'] = lightIntensity.value
nodeValue.properties['Camera Info'] = viewerCameraState
nodeValue.properties['Background Image'] = backgroundImage.value
}
await useLoad3dService().copyLoad3dState(load3d, sourceLoad3d)
if (backgroundImage.value) {
await sourceLoad3d.setBackgroundImage(backgroundImage.value)
}
sourceLoad3d.forceRender()
if (nodeValue.graph) {
nodeValue.graph.setDirtyCanvas(true, true)
}
return true
}
const refreshViewport = () => {
useLoad3dService().handleViewportRefresh(load3d)
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
backgroundImage.value = ''
hasBackgroundImage.value = false
return
}
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadPath = await Load3dUtils.uploadFile(file, subfolder)
if (uploadPath) {
backgroundImage.value = uploadPath
hasBackgroundImage.value = true
}
} catch (error) {
console.error('Error uploading background image:', error)
useToastStore().addAlert(t('toastMessages.failedToUploadBackgroundImage'))
}
}
const cleanup = () => {
load3d?.remove()
load3d = null
sourceLoad3d = null
}
return {
// State
backgroundColor,
showGrid,
cameraType,
fov,
lightIntensity,
backgroundImage,
hasBackgroundImage,
upDirection,
materialMode,
edgeThreshold,
needApplyChanges,
// Methods
initializeViewer,
exportModel,
handleResize,
handleMouseEnter,
handleMouseLeave,
restoreInitialState,
applyChanges,
refreshViewport,
handleBackgroundImageUpdate,
cleanup
}
}

View File

@@ -2,13 +2,14 @@ import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
@@ -16,6 +17,13 @@ interface GraphCallbacks {
onConnectionChange?: (node: LGraphNode) => void
}
export type MinimapOptionKey =
| 'Comfy.Minimap.NodeColors'
| 'Comfy.Minimap.ShowLinks'
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
@@ -27,6 +35,27 @@ export function useMinimap() {
const visible = ref(true)
const nodeColors = computed(() =>
settingStore.get('Comfy.Minimap.NodeColors')
)
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
const showGroups = computed(() =>
settingStore.get('Comfy.Minimap.ShowGroups')
)
const renderBypass = computed(() =>
settingStore.get('Comfy.Minimap.RenderBypassState')
)
const renderError = computed(() =>
settingStore.get('Comfy.Minimap.RenderErrorState')
)
const updateOption = async (key: MinimapOptionKey, value: boolean) => {
await settingStore.set(key, value)
needsFullRedraw.value = true
updateMinimap()
}
const initialized = ref(false)
const bounds = ref({
minX: 0,
@@ -63,10 +92,19 @@ export function useMinimap() {
const nodeColor = computed(
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
)
const nodeColorDefault = computed(
() => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting
)
const linkColor = computed(
() => (isLightTheme.value ? '#FFB347' : '#F99614') // lighter orange for light theme
() => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme
)
const slotColor = computed(() => linkColor.value)
const groupColor = computed(() =>
isLightTheme.value ? '#A2D3EC' : '#1F547A'
)
const bypassColor = computed(() =>
isLightTheme.value ? '#DBDBDB' : '#4B184B'
)
const containerRect = ref({
left: 0,
@@ -116,6 +154,14 @@ export function useMinimap() {
borderRadius: '8px'
}))
const panelStyles = computed(() => ({
width: `210px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
const viewportStyles = computed(() => ({
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
@@ -189,6 +235,25 @@ export function useMinimap() {
return Math.min(scaleX, scaleY) * 0.9
}
const renderGroups = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._groups || g._groups.length === 0) return
for (const group of g._groups) {
const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = group.size[0] * scale.value
const h = group.size[1] * scale.value
ctx.fillStyle = groupColor.value
ctx.fillRect(x, y, w, h)
}
}
const renderNodes = (
ctx: CanvasRenderingContext2D,
offsetX: number,
@@ -203,9 +268,29 @@ export function useMinimap() {
const w = node.size[0] * scale.value
const h = node.size[1] * scale.value
let color = nodeColor.value
if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) {
color = bypassColor.value
} else if (nodeColors.value) {
color = nodeColorDefault.value
if (node.bgcolor) {
color = isLightTheme.value
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
// Render solid node blocks
ctx.fillStyle = nodeColor.value
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
if (renderError.value && node.has_errors) {
ctx.strokeStyle = '#FF0000'
ctx.lineWidth = 0.3
ctx.strokeRect(x, y, w, h)
}
}
}
@@ -218,9 +303,9 @@ export function useMinimap() {
if (!g) return
ctx.strokeStyle = linkColor.value
ctx.lineWidth = 1.4
ctx.lineWidth = 0.3
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale
const connections: Array<{
x1: number
y1: number
@@ -304,8 +389,15 @@ export function useMinimap() {
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
if (showGroups.value) {
renderGroups(ctx, offsetX, offsetY)
}
if (showLinks.value) {
renderConnections(ctx, offsetX, offsetY)
}
renderNodes(ctx, offsetX, offsetY)
renderConnections(ctx, offsetX, offsetY)
needsFullRedraw.value = false
updateFlags.value.nodes = false
@@ -690,16 +782,25 @@ export function useMinimap() {
canvasRef,
containerStyles,
viewportStyles,
panelStyles,
width,
height,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
init,
destroy,
toggle,
renderMinimap,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef
setMinimapRef,
updateOption
}
}

View File

@@ -1,5 +1,5 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'lodash'
import { orderBy } from 'es-toolkit/compat'
import { computed, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'

View File

@@ -1,3 +1,4 @@
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, watch } from 'vue'
import { api } from '@/scripts/api'
@@ -88,6 +89,11 @@ export function useWorkflowPersistence() {
)
api.addEventListener('graphChanged', persistCurrentWorkflow)
// Clean up event listener when component unmounts
tryOnScopeDispose(() => {
api.removeEventListener('graphChanged', persistCurrentWorkflow)
})
// Restore workflow tabs states
const openWorkflows = computed(() => workflowStore.openWorkflows)
const activeWorkflow = computed(() => workflowStore.activeWorkflow)

View File

@@ -0,0 +1,108 @@
import { ref } from 'vue'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useMinimap } from './useMinimap'
// Store thumbnails for each workflow
const workflowThumbnails = ref<Map<string, string>>(new Map())
// Shared minimap instance
let minimap: ReturnType<typeof useMinimap> | null = null
export const useWorkflowThumbnail = () => {
/**
* Capture a thumbnail of the canvas
*/
const createMinimapPreview = (): Promise<string | null> => {
try {
if (!minimap) {
minimap = useMinimap()
minimap.canvasRef.value = document.createElement('canvas')
minimap.canvasRef.value.width = minimap.width
minimap.canvasRef.value.height = minimap.height
}
minimap.renderMinimap()
return new Promise((resolve) => {
minimap!.canvasRef.value!.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob))
} else {
resolve(null)
}
})
})
} catch (error) {
console.error('Failed to capture canvas thumbnail:', error)
return Promise.resolve(null)
}
}
/**
* Store a thumbnail for a workflow
*/
const storeThumbnail = async (workflow: ComfyWorkflow) => {
const thumbnail = await createMinimapPreview()
if (thumbnail) {
// Clean up existing thumbnail if it exists
const existingThumbnail = workflowThumbnails.value.get(workflow.key)
if (existingThumbnail) {
URL.revokeObjectURL(existingThumbnail)
}
workflowThumbnails.value.set(workflow.key, thumbnail)
}
}
/**
* Get a thumbnail for a workflow
*/
const getThumbnail = (workflowKey: string): string | undefined => {
return workflowThumbnails.value.get(workflowKey)
}
/**
* Clear a thumbnail for a workflow
*/
const clearThumbnail = (workflowKey: string) => {
const thumbnail = workflowThumbnails.value.get(workflowKey)
if (thumbnail) {
URL.revokeObjectURL(thumbnail)
}
workflowThumbnails.value.delete(workflowKey)
}
/**
* Clear all thumbnails
*/
const clearAllThumbnails = () => {
for (const thumbnail of workflowThumbnails.value.values()) {
URL.revokeObjectURL(thumbnail)
}
workflowThumbnails.value.clear()
}
/**
* Move a thumbnail from one workflow key to another (useful for workflow renaming)
*/
const moveWorkflowThumbnail = (oldKey: string, newKey: string) => {
// Don't do anything if moving to the same key
if (oldKey === newKey) return
const thumbnail = workflowThumbnails.value.get(oldKey)
if (thumbnail) {
workflowThumbnails.value.set(newKey, thumbnail)
workflowThumbnails.value.delete(oldKey)
}
}
return {
createMinimapPreview,
storeThumbnail,
getThumbnail,
clearThumbnail,
clearAllThumbnails,
moveWorkflowThumbnail,
workflowThumbnails
}
}

View File

@@ -1,4 +1,4 @@
import _ from 'lodash'
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'

View File

@@ -182,5 +182,19 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
alt: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
},
{
combo: {
ctrl: true,
shift: true,
key: 'k'
},
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
},
{
combo: {
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
}
]

View File

@@ -300,7 +300,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' }
{ value: 'es', text: 'Español' },
{ value: 'ar', text: 'عربي' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},
@@ -830,6 +831,41 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.Minimap.NodeColors',
name: 'Display node with its original color on minimap',
type: 'hidden',
defaultValue: false,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowLinks',
name: 'Display links on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowGroups',
name: 'Display node groups on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderBypassState',
name: 'Render bypass state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderErrorState',
name: 'Render error state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Workflow.AutoSaveDelay',
name: 'Auto Save Delay (ms)',

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