Compare commits

..

10 Commits

Author SHA1 Message Date
huchenlei
5da195c925 nit 2025-08-10 14:06:52 -04:00
huchenlei
d3398944d5 Fix test 2025-08-10 14:05:40 -04:00
huchenlei
b70b2c89b2 Fix highlight 2025-08-10 12:53:37 -04:00
huchenlei
c0303a6553 tailwind style 2025-08-10 12:49:03 -04:00
huchenlei
1e6803fd65 Support i18n 2025-08-10 12:36:40 -04:00
huchenlei
38a77abecb nit 2025-08-10 12:17:18 -04:00
github-actions
fcffa51a24 Update locales [skip ci] 2025-08-10 16:16:10 +00:00
huchenlei
d0ef1bb81a nit 2025-08-10 11:01:46 -04:00
huchenlei
34b7fe14c4 Fix mode switch 2025-08-09 22:50:03 -04:00
huchenlei
a4d7b4dd55 Basic commandbox 2025-08-09 22:50:03 -04:00
237 changed files with 2878 additions and 21561 deletions

View File

@@ -111,7 +111,50 @@ echo "Last stable release: $LAST_STABLE"
```
7. **HUMAN ANALYSIS**: Review change summary and verify scope
### Step 3: Breaking Change Analysis
### Step 3: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 4: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 5: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 6: Breaking Change Analysis
1. Analyze API changes in:
- Public TypeScript interfaces
@@ -126,7 +169,7 @@ echo "Last stable release: $LAST_STABLE"
3. Generate breaking change summary
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
### Step 4: Analyze Dependency Updates
### Step 7: Analyze Dependency Updates
1. **Check significant dependency updates:**
```bash
@@ -152,117 +195,7 @@ echo "Last stable release: $LAST_STABLE"
done
```
### Step 5: Generate GTM Feature Summary
1. **Collect PR data for analysis:**
```bash
# Get list of PR numbers from commits
PR_NUMBERS=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | \
grep -oE "#[0-9]+" | tr -d '#' | sort -u)
# Save PR data for each PR
echo "[" > prs-${NEW_VERSION}.json
first=true
for PR in $PR_NUMBERS; do
[[ "$first" == true ]] && first=false || echo "," >> prs-${NEW_VERSION}.json
gh pr view $PR --json number,title,author,body,labels 2>/dev/null >> prs-${NEW_VERSION}.json || echo "{}" >> prs-${NEW_VERSION}.json
done
echo "]" >> prs-${NEW_VERSION}.json
```
2. **Analyze for GTM-worthy features:**
```
<task>
Review these PRs to identify features worthy of marketing attention.
A feature is GTM-worthy if it meets ALL of these criteria:
- Introduces a NEW capability users didn't have before (not just improvements)
- Would be a compelling reason for users to upgrade to this version
- Can be demonstrated visually or has clear before/after comparison
- Affects a significant portion of the user base
NOT GTM-worthy:
- Bug fixes (even important ones)
- Minor UI tweaks or color changes
- Performance improvements without user-visible impact
- Internal refactoring
- Small convenience features
- Features that only improve existing functionality marginally
For each GTM-worthy feature, note:
- PR number, title, and author
- Media links from the PR description
- One compelling sentence on why users should care
If there are no GTM-worthy features, just say "No marketing-worthy features in this release."
</task>
PR data: [contents of prs-${NEW_VERSION}.json]
```
3. **Generate GTM notification:**
```bash
# Save to gtm-summary-${NEW_VERSION}.md based on analysis
# If GTM-worthy features exist, include them with testing instructions
# If not, note that this is a maintenance/bug fix release
# Check if notification is needed
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
echo "✅ No GTM notification needed for this release"
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
else
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
echo "📤 Share this file in #gtm channel to notify the team"
fi
```
### Step 6: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number based on analysis:
- Major version if breaking changes detected
- Minor version if new features added
- Patch version if only bug fixes
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 7: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 8: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 9: Generate Comprehensive Release Notes
### Step 8: Generate Comprehensive Release Notes
1. Extract commit messages since base release:
```bash
@@ -277,54 +210,31 @@ echo "Last stable release: $LAST_STABLE"
echo "WARNING: PR #$PR not on main branch!"
done
```
3. Create standardized release notes using this exact template:
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:**
```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/${BASE_TAG}...v${NEW_VERSION}
EOF
# Save release notes for PR and GitHub release
echo "$RELEASE_NOTES" > release-notes-${NEW_VERSION}.md
```
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?
5. **CONTENT REVIEW**: Release notes clear and comprehensive with dependency details?
### Step 10: Create Version Bump PR
### Step 9: Create Version Bump PR
**For standard version bumps (patch/minor/major):**
```bash
@@ -363,14 +273,40 @@ echo "Workflow triggered. Waiting for PR creation..."
--body-file release-notes-${NEW_VERSION}.md \
--label "Release"
```
3. **Update PR with release notes:**
3. **Add required sections to PR body:**
```bash
# For workflow-created PRs, update the body with our release notes
gh pr edit ${PR_NUMBER} --body-file release-notes-${NEW_VERSION}.md
```
4. **PR REVIEW**: Version bump PR created with standardized release notes?
# Create PR body with release notes plus required sections
cat > pr-body.md << EOF
${RELEASE_NOTES}
### Step 11: Critical Release PR Verification
## 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
```
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?
### Step 10: Critical Release PR Verification
1. **CRITICAL**: Verify PR has "Release" label:
```bash
@@ -392,7 +328,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 12: Pre-Merge Validation
### Step 11: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
@@ -400,7 +336,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 13: Execute Release
### Step 12: Execute Release
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
2. Merge the Release PR:
@@ -433,7 +369,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh run watch ${WORKFLOW_RUN_ID}
```
### Step 14: Enhance GitHub Release
### Step 13: Enhance GitHub Release
1. Wait for automatic release creation:
```bash
@@ -461,7 +397,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh release view v${NEW_VERSION}
```
### Step 15: Verify Multi-Channel Distribution
### Step 14: Verify Multi-Channel Distribution
1. **GitHub Release:**
```bash
@@ -499,7 +435,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
### Step 16: Post-Release Monitoring Setup
### Step 15: Post-Release Monitoring Setup
1. **Monitor immediate release health:**
```bash
@@ -569,49 +505,11 @@ echo "Workflow triggered. Waiting for PR creation..."
## Files Generated
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
EOF
```
4. **RELEASE COMPLETION**: All post-release setup completed?
### Step 17: Create Release Summary
1. **Create comprehensive release summary:**
```bash
cat > release-summary-${NEW_VERSION}.md << EOF
# Release Summary: ComfyUI Frontend v${NEW_VERSION}
**Released:** $(date)
**Type:** ${VERSION_TYPE}
**Duration:** ~${RELEASE_DURATION} minutes
**Release Commit:** ${RELEASE_COMMIT}
## Metrics
- **Commits Included:** ${COMMITS_COUNT}
- **Contributors:** ${CONTRIBUTORS_COUNT}
- **Files Changed:** ${FILES_CHANGED}
- **Lines Added/Removed:** +${LINES_ADDED}/-${LINES_REMOVED}
## Distribution Status
- ✅ GitHub Release: Published
- ✅ PyPI Package: Available
- ✅ npm Types: Available
## Next Steps
- Monitor for 24-48 hours
- Address any critical issues immediately
- Plan next release cycle
## Files Generated
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
EOF
```
2. **RELEASE COMPLETION**: All steps completed successfully?
## Advanced Safety Features
### Rollback Procedures
@@ -694,46 +592,55 @@ The command implements multiple quality gates:
- Draft release status
- Python package specs require that prereleases use alpha/beta/rc as the preid
## Critical Implementation Notes
## Common Issues and Solutions
When executing this release process, pay attention to these key aspects:
### 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.
### 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
### Issue: Wrong Commit Count
**Problem**: Changelog includes commits from other branches
**Solution**: Always use `--first-parent` flag with git log
### 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
```
**Update**: Sometimes update-locales doesn't add [skip ci] - always verify!
### 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: 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
```
### 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: 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
### 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
### 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
### 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
**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

View File

@@ -138,50 +138,14 @@ For each commit:
```bash
gh pr create --base core/X.Y --head release/1.23.5 \
--title "[Release] v1.23.5" \
--body "Release notes will be added shortly..." \
--body "..." \
--label "Release"
```
3. **CRITICAL**: Verify "Release" label is added
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
```
4. PR description should include:
- Version: `1.23.4` → `1.23.5`
- Included fixes (link to previous PR)
- Release notes for users
5. **CONFIRMATION REQUIRED**: Release PR has "Release" label?
### Step 11: Monitor Release Process

View File

@@ -1,131 +0,0 @@
# 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 es-toolkit for utility functions
2. Use lodash 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 es-toolkit for utility functions
Use lodash for utility functions
Use TypeScript for type safety

View File

@@ -1,20 +0,0 @@
## 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

@@ -56,7 +56,6 @@ jobs:
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

View File

@@ -61,11 +61,6 @@ 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: |

View File

@@ -61,11 +61,6 @@ 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: |

15
.gitignore vendored
View File

@@ -7,15 +7,6 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Package manager lockfiles (allow users to use different package managers)
bun.lock
bun.lockb
pnpm-lock.yaml
yarn.lock
# ESLint cache
.eslintcache
node_modules
dist
dist-ssr
@@ -50,6 +41,7 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/**/*-darwin.png
.env
@@ -67,8 +59,5 @@ dist.zip
# Temporary repository directory
templates_repo/
# Vite's timestamped config modules
# Vites timestamped config modules
vite.config.mts.timestamp-*.mjs
# Linux core dumps
./core

View File

@@ -9,14 +9,10 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
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.
IMPORTANT Chinese Translation Guidelines:
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
`
});

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, Korean, or Arabic. This native
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
More details available here: https://blog.comfy.org/p/native-localization-support-i18n

View File

@@ -1,259 +0,0 @@
{
"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

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

View File

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

View File

@@ -0,0 +1,161 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Command search box', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
})
test('Can trigger command mode with ">" prefix', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Type ">" to enter command mode
await comfyPage.searchBox.input.fill('>')
// Verify filter button is hidden in command mode
const filterButton = comfyPage.page.locator('.filter-button')
await expect(filterButton).not.toBeVisible()
// Verify placeholder text changes
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Commands')
)
})
test('Shows command list when entering command mode', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>')
// Wait for dropdown to appear
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
// Check that commands are shown
const firstItem = comfyPage.searchBox.dropdown.locator('li').first()
await expect(firstItem).toBeVisible()
// Verify it shows a command item with icon
const commandIcon = firstItem.locator('.item-icon')
await expect(commandIcon).toBeVisible()
})
test('Can search and filter commands', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>save')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500) // Wait for search to complete
// Get all visible command items
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
// Should have filtered results
expect(count).toBeGreaterThan(0)
expect(count).toBeLessThan(10) // Should be filtered, not showing all
// Verify first result contains "save"
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
test('Shows keybindings for commands that have them', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>undo')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Find the undo command
const undoItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'Undo' })
.first()
// Check if keybinding is shown (if configured)
const keybinding = undoItem.locator('.item-keybinding')
const keybindingCount = await keybinding.count()
// Keybinding might or might not be present depending on configuration
if (keybindingCount > 0) {
await expect(keybinding).toBeVisible()
}
})
test('Executes command on selection', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>new blank')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Count nodes before
const nodesBefore = await comfyPage.page
.locator('.litegraph.litenode')
.count()
// Select the new blank workflow command
const newBlankItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'New Blank Workflow' })
.first()
await newBlankItem.click()
// Search box should close
await expect(comfyPage.searchBox.input).not.toBeVisible()
// Verify workflow was cleared (no nodes)
const nodesAfter = await comfyPage.page
.locator('.litegraph.litenode')
.count()
expect(nodesAfter).toBe(0)
})
test('Returns to node search when removing ">"', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Enter command mode
await comfyPage.searchBox.input.fill('>')
await expect(comfyPage.page.locator('.filter-button')).not.toBeVisible()
// Return to node search by filling with empty string to trigger search
await comfyPage.searchBox.input.fill('')
// Small wait for UI update
await comfyPage.page.waitForTimeout(200)
// Filter button should be visible again
await expect(comfyPage.page.locator('.filter-button')).toBeVisible()
// Placeholder should change back
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Nodes')
)
})
test('Command search is case insensitive', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Search with lowercase
await comfyPage.searchBox.input.fill('>SAVE')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Should find save commands
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
expect(count).toBeGreaterThan(0)
// Verify it found save-related commands
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
})

View File

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

View File

@@ -684,7 +684,7 @@ test.describe('Load workflow', () => {
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage to persist the workflow paths before reloading

View File

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

View File

@@ -24,14 +24,8 @@ test.describe('Minimap', () => {
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
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')
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {

View File

@@ -18,7 +18,7 @@ test.describe('Reroute Node', () => {
[workflowName]: workflowName
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
// Insert the workflow
const workflowsTab = comfyPage.menu.workflowsTab
@@ -100,29 +100,4 @@ 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'
)
})
})

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -317,25 +317,6 @@ 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

@@ -704,103 +704,4 @@ 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

@@ -63,7 +63,7 @@ test.describe('Workflow Tab Thumbnails', () => {
test('Should show thumbnail when hovering over a non-active tab', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
0,
@@ -73,7 +73,7 @@ test.describe('Workflow Tab Thumbnails', () => {
})
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
1,
@@ -105,7 +105,7 @@ test.describe('Workflow Tab Thumbnails', () => {
await comfyPage.nextFrame()
// Create a new workflow (tab 1) which will be empty
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
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)

View File

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

View File

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

1103
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.26.4",
"version": "1.26.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -21,11 +21,8 @@
"test:component": "vitest run src/components/",
"prepare": "husky || true",
"preview": "vite preview",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"locale": "lobe-i18n locale",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts"
@@ -41,6 +38,7 @@
"@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",
@@ -58,7 +56,6 @@
"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",
@@ -99,12 +96,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

@@ -51,7 +51,7 @@ const template = await fetch('/templates/default.json')
## General Guidelines
- Use es-toolkit for utility functions
- Use lodash 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,8 +616,7 @@ 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-load-3d-viewer canvas{
.comfy-preview-3d-animation canvas{
display: flex;
width: 100% !important;
height: 100% !important;

View File

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

Before

Width:  |  Height:  |  Size: 405 B

View File

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

Before

Width:  |  Height:  |  Size: 970 B

View File

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

View File

@@ -20,7 +20,7 @@
>
<div class="shortcut-info flex-grow pr-4">
<div class="shortcut-name text-sm font-medium">
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
{{ command.getTranslatedLabel() }}
</div>
</div>
@@ -50,7 +50,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()

View File

@@ -32,6 +32,7 @@
</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'
@@ -97,6 +98,18 @@ const home = computed(() => ({
}
}))
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {

View File

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

View File

@@ -0,0 +1,75 @@
<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

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

View File

@@ -42,7 +42,7 @@
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { debounce } from 'es-toolkit/compat'
import { debounce } from 'lodash'
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 'es-toolkit/compat'
import { clamp, debounce } from 'lodash'
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
type GridState = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,6 @@ 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 'es-toolkit/compat'
import { cloneDeep } from 'es-toolkit/compat'
import _ from 'lodash'
import cloneDeep from 'lodash/cloneDeep'
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 'es-toolkit/compat'
import { merge } from 'lodash'
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('es-toolkit/compat', () => ({
vi.mock('lodash', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
}))

View File

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

View File

@@ -0,0 +1,46 @@
<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 'es-toolkit/compat'
import { stubTrue } from 'lodash'
import AutoComplete, {
AutoCompleteOptionSelectEvent
} from 'primevue/autocomplete'

View File

@@ -139,7 +139,6 @@ import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import { useKeybindingService } from '@/services/keybindingService'
@@ -149,7 +148,6 @@ import {
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
@@ -161,7 +159,6 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const commandStore = useCommandStore()
const { t } = useI18n()
interface ICommandData {
id: string
@@ -173,10 +170,7 @@ interface ICommandData {
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
label: t(
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
label: command.getTranslatedLabel(),
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
source: command.source
}))

View File

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

View File

@@ -1,67 +1,39 @@
<template>
<div
v-if="visible && initialized"
ref="minimapRef"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
ref="containerRef"
class="litegraph-minimap absolute right-[90px] z-[1000]"
:class="{
'bottom-[20px]': !bottomPanelStore.bottomPanelVisible,
'bottom-[280px]': bottomPanelStore.bottomPanelVisible
}"
:style="containerStyles"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
>
<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"
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<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 class="minimap-viewport" :style="viewportStyles" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import MiniMapPanel from './MiniMapPanel.vue'
const minimapRef = ref<HTMLDivElement>()
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const bottomPanelStore = useBottomPanelStore()
const {
initialized,
@@ -72,29 +44,28 @@ const {
viewportStyles,
width,
height,
panelStyles,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef
} = useMinimap()
handleWheel
} = minimap
const showOptionsPanel = ref(false)
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
onMounted(() => {
setMinimapRef(minimapRef.value)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
}
})
onUnmounted(() => {

View File

@@ -1,97 +0,0 @@
<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

@@ -13,7 +13,6 @@
<BypassButton />
<PinButton />
<EditModelButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
@@ -39,7 +38,6 @@ 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'

View File

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

View File

@@ -1,20 +1,6 @@
<template>
<Button
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-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
@@ -34,7 +20,6 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -42,13 +27,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isUnpackVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
const isConvertVisible = computed(() => {
const isVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||

View File

@@ -1,38 +0,0 @@
<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

@@ -57,20 +57,9 @@
@update-edge-threshold="handleUpdateEdgeThreshold"
@export-model="handleExportModel"
/>
<div
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
}"
class="absolute top-12 right-2 z-20 pointer-events-auto"
>
<RecordingControls
:node="node"
@@ -93,7 +82,6 @@ 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,
@@ -103,7 +91,6 @@ 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()
@@ -134,9 +121,6 @@ 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

@@ -1,149 +0,0 @@
<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

@@ -1,52 +0,0 @@
<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

@@ -1,37 +0,0 @@
<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

@@ -1,37 +0,0 @@
<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

@@ -1,30 +0,0 @@
<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

@@ -1,52 +0,0 @@
<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

@@ -1,82 +0,0 @@
<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 'es-toolkit/compat'
import _ from 'lodash'
import { computed } from 'vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex items-center gap-3 px-3 py-2 w-full">
<span
class="flex-shrink-0 w-5 text-center text-muted item-icon"
:class="command.icon ?? 'pi pi-chevron-right'"
/>
<span
class="flex-grow overflow-hidden text-ellipsis whitespace-nowrap item-label"
>
<span
v-html="highlightQuery(command.getTranslatedLabel(), currentQuery)"
/>
</span>
<span
v-if="command.keybinding"
class="flex-shrink-0 text-xs px-1.5 py-0.5 border rounded font-mono keybinding-badge"
>
{{ command.keybinding.combo.toString() }}
</span>
</div>
</template>
<script setup lang="ts">
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { highlightQuery } from '@/utils/formatUtil'
const { command, currentQuery } = defineProps<{
command: ComfyCommandImpl
currentQuery: string
}>()
</script>
<style scoped>
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
.keybinding-badge {
border-color: var(--p-content-border-color);
background-color: var(--p-content-hover-background);
color: var(--p-text-muted-color);
}
</style>

View File

@@ -3,7 +3,7 @@
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
>
<div
v-if="enableNodePreview"
v-if="enableNodePreview && !isCommandMode"
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
>
<NodePreview
@@ -14,6 +14,7 @@
</div>
<Button
v-if="!isCommandMode"
icon="pi pi-filter"
severity="secondary"
class="filter-button z-10"
@@ -49,13 +50,24 @@
auto-option-focus
force-selection
multiple
:option-label="'display_name'"
:option-label="getOptionLabel"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@option-select="onOptionSelect($event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@input="handleInput"
>
<template #option="{ option }">
<NodeSearchItem :node-def="option" :current-query="currentQuery" />
<!-- Command search item, Remove the '>' prefix from the query -->
<CommandSearchItem
v-if="isCommandMode"
:command="option"
:current-query="currentQuery.substring(1)"
/>
<NodeSearchItem
v-else
:node-def="option"
:current-query="currentQuery"
/>
</template>
<!-- FilterAndValue -->
<template #chip="{ value }">
@@ -80,13 +92,16 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NodePreview from '@/components/node/NodePreview.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import CommandSearchItem from '@/components/searchbox/CommandSearchItem.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { CommandSearchService } from '@/services/commandSearchService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
@@ -99,6 +114,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const commandStore = useCommandStore()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -111,18 +127,50 @@ const { filters, searchLimit = 64 } = defineProps<{
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[]>([])
const suggestions = ref<ComfyNodeDefImpl[] | ComfyCommandImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const currentQuery = ref('')
const isCommandMode = ref(false)
// Initialize command search service
const commandSearchService = ref<CommandSearchService | null>(null)
const placeholder = computed(() => {
if (isCommandMode.value) {
return t('g.searchCommands', 'Search commands') + '...'
}
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
// Initialize command search service with commands
watch(
() => commandStore.commands,
(commands) => {
commandSearchService.value = new CommandSearchService(commands)
},
{ immediate: true }
)
const search = (query: string) => {
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
// Check if we're in command mode (query starts with ">")
if (query.startsWith('>')) {
isCommandMode.value = true
if (commandSearchService.value) {
suggestions.value = commandSearchService.value.searchCommands(query, {
limit: searchLimit
})
}
return
}
// Normal node search mode
isCommandMode.value = false
const queryIsEmpty = query === '' && filters.length === 0
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
@@ -132,7 +180,18 @@ const search = (query: string) => {
]
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const emit = defineEmits<{
(
e: 'addFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(
e: 'removeFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(e: 'addNode', nodeDef: ComfyNodeDefImpl): void
(e: 'executeCommand', command: ComfyCommandImpl): void
}>()
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
@@ -160,11 +219,47 @@ const onRemoveFilter = async (
await reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1) {
if (index === -1 || isCommandMode.value) {
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index]
const value = suggestions.value[index] as ComfyNodeDefImpl
hoveredSuggestion.value = value
}
const onOptionSelect = (option: ComfyNodeDefImpl | ComfyCommandImpl) => {
if (isCommandMode.value) {
emit('executeCommand', option as ComfyCommandImpl)
} else {
emit('addNode', option as ComfyNodeDefImpl)
}
}
const getOptionLabel = (
option: ComfyNodeDefImpl | ComfyCommandImpl
): string => {
if ('display_name' in option) {
return option.display_name
}
return option.label || option.id
}
/**
* Handles direct input changes on the AutoCompletePlus component.
* This ensures search mode switching works properly when users clear the input
* or modify it directly, as the @complete event may not always trigger.
*
* @param event - The input event from the AutoCompletePlus component
* @note Known issue on empty input complete state:
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/4887
*/
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const inputValue = target.value
// Trigger search to handle mode switching between node and command search
if (inputValue === '') {
search('')
}
}
</script>

View File

@@ -26,6 +26,7 @@
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@execute-command="executeCommand"
/>
</template>
</Dialog>
@@ -46,6 +47,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useLitegraphService } from '@/services/litegraphService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -62,6 +64,7 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const litegraphService = useLitegraphService()
const commandStore = useCommandStore()
const { visible } = storeToRefs(useSearchBoxStore())
const dismissable = ref(true)
@@ -90,16 +93,18 @@ const closeDialog = () => {
const canvasStore = useCanvasStore()
const addNode = (nodeDef: ComfyNodeDefImpl) => {
if (!triggerEvent) {
console.warn('The trigger event was undefined when addNode was called.')
return
}
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
if (disconnectOnReset && triggerEvent) {
if (disconnectOnReset) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
} else if (!triggerEvent) {
console.warn('The trigger event was undefined when addNode was called.')
}
disconnectOnReset = false
// Notify changeTracker - new step should be added
@@ -107,6 +112,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
window.requestAnimationFrame(closeDialog)
}
const executeCommand = async (command: ComfyCommandImpl) => {
// Close the dialog immediately
closeDialog()
// Execute the command
await commandStore.execute(command.id)
}
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)

View File

@@ -8,13 +8,10 @@
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarHelpCenterIcon />
@@ -46,7 +43,6 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -90,7 +86,7 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
box-shadow: var(--bar-shadow);
--sidebar-width: 4rem;
--sidebar-icon-size: 1rem;
--sidebar-icon-size: 1.5rem;
}
.side-tool-bar-container.small-sidebar {

View File

@@ -58,12 +58,11 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -71,9 +70,8 @@ import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const isHelpCenterVisible = ref(false)
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
@@ -82,11 +80,11 @@ const sidebarLocation = computed(() =>
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
const toggleHelpCenter = () => {
helpCenterStore.toggle()
isHelpCenterVisible.value = !isHelpCenterVisible.value
}
const closeHelpCenter = () => {
helpCenterStore.hide()
isHelpCenterVisible.value = false
}
// Initialize release store on mount
@@ -132,7 +130,6 @@ onMounted(async () => {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);

View File

@@ -19,29 +19,12 @@
@click="emit('click', $event)"
>
<template #icon>
<div class="side-bar-button-content">
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i
v-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component :is="icon" v-else class="side-bar-button-icon" />
</OverlayBadge>
<i
v-else-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
}}</span>
</div>
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
</slot>
</template>
</Button>
</template>
@@ -50,7 +33,6 @@
import Button from 'primevue/button'
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -59,17 +41,13 @@ const {
selected = false,
tooltip = '',
tooltipSuffix = '',
iconBadge = '',
label = '',
isSmall = false
iconBadge = ''
} = defineProps<{
icon?: string | Component
icon?: string
selected?: boolean
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
label?: string
isSmall?: boolean
}>()
const emit = defineEmits<{
@@ -96,21 +74,8 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
<style scoped>
.side-bar-button {
width: var(--sidebar-width);
height: calc(var(--sidebar-width) + 0.5rem);
border-radius: 0;
}
.side-tool-bar-end .side-bar-button {
height: var(--sidebar-width);
}
.side-bar-button-content {
@apply flex flex-col items-center gap-2;
}
.side-bar-button-label {
@apply text-[10px] text-center whitespace-nowrap;
line-height: 1;
border-radius: 0;
}
.comfyui-body-left .side-bar-button.side-bar-button-selected,

View File

@@ -1,35 +0,0 @@
<template>
<SidebarIcon
:icon="TemplateIcon"
:tooltip="$t('sideToolbar.templates')"
:label="$t('sideToolbar.labels.templates')"
:is-small="isSmall"
class="templates-tab-button"
@click="openTemplates"
/>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, markRaw } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import SidebarIcon from './SidebarIcon.vue'
// Import the custom template icon
const TemplateIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/template'))
)
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
)
const openTemplates = () => {
void commandStore.execute('Comfy.BrowseTemplates')
}
</script>

View File

@@ -30,17 +30,10 @@
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-filter-slash"
text
severity="secondary"
@click="resetOrganization"
/>
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
text
severity="secondary"
@click="() => commandStore.execute('Comfy.RefreshNodeDefinitions')"
@click="resetOrganization"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
@@ -146,7 +139,6 @@ import {
DEFAULT_SORTING_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
@@ -163,7 +155,6 @@ import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeHelpStore = useNodeHelpStore()
const commandStore = useCommandStore()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)

View File

@@ -265,14 +265,6 @@ 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

@@ -55,30 +55,9 @@
v-bind="props.action"
:href="item.url"
target="_blank"
:class="typeof item.class === 'function' ? item.class() : item.class"
@mousedown="
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
"
@click="handleItemClick(item, $event)"
>
<i
v-if="hasActiveStateSiblings(item)"
class="p-menubar-item-icon pi pi-check text-sm"
:class="{ invisible: !item.comfyCommand?.active?.() }"
/>
<span
v-else-if="
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
"
class="p-menubar-item-icon"
:class="item.icon"
/>
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<i
v-if="item.comfyCommand?.id === 'Comfy.NewBlankWorkflow'"
class="ml-auto"
:class="item.icon"
/>
<span
v-if="item?.comfyCommand?.keybinding"
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
@@ -115,7 +94,6 @@ import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
const colorPaletteStore = useColorPaletteStore()
const menuItemsStore = useMenuItemStore()
@@ -185,22 +163,16 @@ const extraMenuItems: MenuItem[] = [
},
{ separator: true },
{
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'pi pi-folder-open',
command: () => commandStore.execute('Comfy.BrowseTemplates')
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => showSettings()
},
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
}
]
@@ -265,44 +237,6 @@ const onMenuShow = () => {
}
})
}
const isZoomCommand = (item: MenuItem) => {
return (
item.comfyCommand?.id === 'Comfy.Canvas.ZoomIn' ||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomOut'
)
}
const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
if (item.comfyCommand) {
whileMouseDown(
event,
async () => {
await commandStore.execute(item.comfyCommand!.id)
},
50
)
}
}
const handleItemClick = (item: MenuItem, event: MouseEvent) => {
// Prevent the menu from closing for zoom commands or commands that have active state
if (isZoomCommand(item) || item.comfyCommand?.active) {
event.preventDefault()
event.stopPropagation()
if (item.comfyCommand?.active) {
item.command?.({
item,
originalEvent: event
})
}
return false
}
}
const hasActiveStateSiblings = (item: MenuItem): boolean => {
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
}
</script>
<style scoped>

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 'es-toolkit/compat'
import { debounce } from 'lodash'
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 'es-toolkit/compat'
import { debounce } from 'lodash'
import { readonly, ref } from 'vue'
/**

View File

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

View File

@@ -261,10 +261,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.14-2.80/Run (varies with model, mode & duration)'
const modelValue = String(modelWidget.value)
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
if (modelValue.includes('v2-master')) {
return '$1.40/Run'
} else if (
modelValue.includes('v1-6') ||
@@ -283,19 +280,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
console.log('durationValue', durationValue)
// Same pricing matrix as KlingTextToVideoNode
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
if (modelValue.includes('v2-master')) {
if (durationValue.includes('10')) {
return '$2.80/Run'
}
return '$1.40/Run' // 5s default
} else if (
modelValue.includes('v2-1') ||
modelValue.includes('v1-6') ||
modelValue.includes('v1-5')
) {
} else if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) {
if (modeValue.includes('pro')) {
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
} else {
@@ -428,12 +418,7 @@ 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-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('v2-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run'
}
@@ -573,32 +558,6 @@ 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(
@@ -1319,13 +1278,9 @@ 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-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.00016/$0.0006 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.00125/$0.01 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
@@ -1362,27 +1317,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
} else if (model.includes('gpt-5-nano')) {
return '$0.00005/$0.0004 per 1K tokens'
} else if (model.includes('gpt-5-mini')) {
return '$0.00025/$0.002 per 1K tokens'
} else if (model.includes('gpt-5')) {
return '$0.00125/$0.01 per 1K tokens'
}
return 'Token-based'
}
},
ViduTextToVideoNode: {
displayPrice: '$0.4/Run'
},
ViduImageToVideoNode: {
displayPrice: '$0.4/Run'
},
ViduReferenceVideoNode: {
displayPrice: '$0.4/Run'
},
ViduStartEndToVideoNode: {
displayPrice: '$0.4/Run'
}
}
@@ -1421,7 +1358,6 @@ 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 'es-toolkit/compat'
import { groupBy } from 'lodash'
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'

View File

@@ -1,21 +1,16 @@
import { defineAsyncComponent, markRaw } from 'vue'
import { markRaw } from 'vue'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'
const AiModelIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/ai-model'))
)
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'model-library',
icon: AiModelIcon,
icon: 'pi pi-box',
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {

View File

@@ -1,19 +1,14 @@
import { defineAsyncComponent, markRaw } from 'vue'
import { markRaw } from 'vue'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const NodeIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/node'))
)
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'node-library',
icon: NodeIcon,
icon: 'pi pi-book',
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
label: 'sideToolbar.labels.nodes',
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}

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