Merge remote-tracking branch 'origin/main' into sno-storybook--settings-panel

This commit is contained in:
snomiao
2025-08-22 04:42:26 +00:00
66 changed files with 4798 additions and 1200 deletions

View File

@@ -1,6 +1,5 @@
name: Bug Report
description: 'Report something that is not working correctly'
title: '[Bug]: '
labels: ['Potential Bug']
type: Bug

View File

@@ -1,7 +1,6 @@
name: Feature Request
description: Report a problem or limitation you're experiencing
title: '[Feature]: '
labels: ['enhancement']
labels: []
type: Feature
body:

View File

@@ -1,154 +0,0 @@
name: PR Checks
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
analyze:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check-changes.outputs.should_run }}
has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }}
has_screen_recording: ${{ steps.check-recording.outputs.has_recording }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure base branch is available
run: |
# Fetch the specific base commit to ensure it's available for git diff
git fetch origin ${{ github.event.pull_request.base.sha }}
- name: Check if significant changes exist
id: check-changes
run: |
# Get list of changed files
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
# Filter for src/ files
SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true)
if [ -z "$SRC_FILES" ]; then
echo "No src/ files changed"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Count lines changed in src files
TOTAL_LINES=0
for file in $SRC_FILES; do
if [ -f "$file" ]; then
# Count added lines (non-empty)
ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l)
# Count removed lines (non-empty)
REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l)
TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED))
fi
done
echo "Total lines changed in src/: $TOTAL_LINES"
if [ $TOTAL_LINES -gt 3 ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
fi
- name: Check browser test coverage
id: check-coverage
if: steps.check-changes.outputs.should_run == 'true'
run: |
# Check if browser tests were updated
BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true)
if [ -n "$BROWSER_TEST_CHANGES" ]; then
echo "has_browser_tests=true" >> "$GITHUB_OUTPUT"
else
echo "has_browser_tests=false" >> "$GITHUB_OUTPUT"
fi
- name: Check for screen recording
id: check-recording
if: steps.check-changes.outputs.should_run == 'true'
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Check PR body for screen recording
# Check for GitHub user attachments or YouTube links
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
echo "has_recording=true" >> "$GITHUB_OUTPUT"
else
echo "has_recording=false" >> "$GITHUB_OUTPUT"
fi
- name: Final check and create results
id: final-check
if: always()
run: |
# Initialize results
WARNINGS_JSON=""
# Only run checks if should_run is true
if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then
# Check browser test coverage
if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}"
fi
# Check screen recording
if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}"
fi
fi
# Create results JSON
if [ -n "$WARNINGS_JSON" ]; then
# Create JSON with warnings
cat > pr-check-results.json << EOF
{
"fails": [],
"warnings": [$WARNINGS_JSON],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
else
# Create JSON with success
cat > pr-check-results.json << 'EOF'
{
"fails": [],
"warnings": [],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
fi
# Write PR metadata
echo "${{ github.event.pull_request.number }}" > pr-number.txt
echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: pr-check-results-${{ github.run_id }}
path: |
pr-check-results.json
pr-number.txt
pr-sha.txt
retention-days: 1

View File

@@ -1,149 +0,0 @@
name: PR Comment
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
permissions:
pull-requests: write
issues: write
statuses: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: pr-check-results-${{ github.event.workflow_run.id }}
path: /tmp/pr-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Post results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Helper function to safely read files
function safeReadFile(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8').trim();
} catch (e) {
console.error(`Error reading ${filePath}:`, e);
return null;
}
}
// Read artifact files
const artifactDir = '/tmp/pr-artifacts';
const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt'));
const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt'));
const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json'));
// Validate PR number
if (!prNumber || isNaN(parseInt(prNumber))) {
throw new Error('Invalid or missing PR number');
}
// Parse and validate results
let results;
try {
results = JSON.parse(resultsJson || '{}');
} catch (e) {
console.error('Failed to parse check results:', e);
// Post error comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}`
});
return;
}
// Format check messages
const messages = [];
if (results.fails && results.fails.length > 0) {
messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n'));
}
if (results.warnings && results.warnings.length > 0) {
messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n'));
}
if (results.messages && results.messages.length > 0) {
messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n'));
}
if (results.markdowns && results.markdowns.length > 0) {
messages.push(...results.markdowns.map(m => m.message));
}
// Find existing bot comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber)
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- pr-checks-comment -->')
);
// Post comment if there are any messages
if (messages.length > 0) {
const body = messages.join('\n\n');
const commentBody = `<!-- pr-checks-comment -->\n${body}`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: commentBody
});
}
} else {
// No messages - delete existing comment if present
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id
});
}
}
// Set commit status based on failures
if (prSha) {
const hasFailures = results.fails && results.fails.length > 0;
const hasWarnings = results.warnings && results.warnings.length > 0;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: prSha,
state: hasFailures ? 'failure' : 'success',
context: 'pr-checks',
description: hasFailures
? `${results.fails.length} check(s) failed`
: hasWarnings
? `${results.warnings.length} warning(s)`
: 'All checks passed'
});
}

View File

@@ -4,13 +4,18 @@ on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration]
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
setup:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
sanitized-branch: ${{ steps.branch-info.outputs.sanitized }}
steps:
- name: Checkout ComfyUI
uses: actions/checkout@v4
@@ -36,6 +41,29 @@ jobs:
with:
node-version: lts/*
- name: Get current time
id: current-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Comment PR - Tests Started
if: github.event_name == 'pull_request'
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: append
body: |
<!-- PLAYWRIGHT_TEST_STATUS -->
---
<img alt='claude-loading-gif' src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
<bold>[${{ steps.current-time.outputs.time }} UTC] Preparing browser tests across multiple browsers...</bold>
---
*This comment will be updated when tests complete*
- name: Build ComfyUI_frontend
run: |
npm ci
@@ -46,6 +74,14 @@ jobs:
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Generate sanitized branch name
id: branch-info
run: |
# Get branch name and sanitize it for Cloudflare branch names
BRANCH_NAME="${{ github.head_ref || github.ref_name }}"
SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "sanitized=${SANITIZED_BRANCH}" >> $GITHUB_OUTPUT
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
@@ -57,6 +93,10 @@ jobs:
playwright-tests:
needs: setup
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
contents: read
strategy:
fail-fast: false
matrix:
@@ -78,6 +118,32 @@ jobs:
with:
python-version: '3.10'
- name: Get current time
id: current-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Set project name
id: project-name
run: |
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT
else
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT
fi
echo "branch=${{ needs.setup.outputs.sanitized-branch }}" >> $GITHUB_OUTPUT
- name: Comment PR - Browser Test Started
if: github.event_name == 'pull_request'
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: append
body: |
<img alt='claude-loading-gif' src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
<bold>${{ matrix.browser }}</bold>: Running tests...
- name: Install requirements
run: |
python -m pip install --upgrade pip
@@ -96,13 +162,193 @@ jobs:
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Install Wrangler
run: npm install -g wrangler
- name: Run Playwright tests (${{ matrix.browser }})
run: npx playwright test --project=${{ matrix.browser }}
id: playwright
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
working-directory: ComfyUI_frontend
- uses: actions/upload-artifact@v4
if: always()
if: always() # note: use always() to allow results to be upload/report even tests failed.
with:
name: playwright-report-${{ matrix.browser }}
path: ComfyUI_frontend/playwright-report/
retention-days: 30
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
id: cloudflare-deploy
if: always()
run: |
# Retry logic for wrangler deploy (3 attempts)
RETRY_COUNT=0
MAX_RETRIES=3
SUCCESS=false
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
if npx wrangler pages deploy ComfyUI_frontend/playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
SUCCESS=true
echo "Deployment successful on attempt $RETRY_COUNT"
else
echo "Deployment failed on attempt $RETRY_COUNT"
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo "Retrying in 10 seconds..."
sleep 10
fi
fi
done
if [ $SUCCESS = false ]; then
echo "All deployment attempts failed"
exit 1
fi
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Save deployment info for summary
if: always()
run: |
mkdir -p deployment-info
# Use step conclusion to determine test result
if [ "${{ steps.playwright.conclusion }}" = "success" ]; then
EXIT_CODE="0"
else
EXIT_CODE="1"
fi
DEPLOYMENT_URL="${{ steps.cloudflare-deploy.outputs.deployment-url || steps.cloudflare-deploy.outputs.url || format('https://{0}.{1}.pages.dev', steps.project-name.outputs.branch, steps.project-name.outputs.name) }}"
echo "${{ matrix.browser }}|${EXIT_CODE}|${DEPLOYMENT_URL}" > deployment-info/${{ matrix.browser }}.txt
- name: Upload deployment info
if: always()
uses: actions/upload-artifact@v4
with:
name: deployment-info-${{ matrix.browser }}
path: deployment-info/
retention-days: 1
- name: Get completion time
id: completion-time
if: always()
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Comment PR - Browser Test Complete
if: always() && github.event_name == 'pull_request'
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: append
body: |
${{ steps.playwright.conclusion == 'success' && '✅' || '❌' }} **${{ matrix.browser }}**: ${{ steps.playwright.conclusion == 'success' && 'Tests passed!' || 'Tests failed!' }} [View Report](${{ steps.cloudflare-deploy.outputs.deployment-url || format('https://{0}.{1}.pages.dev', steps.project-name.outputs.branch, steps.project-name.outputs.name) }})
comment-summary:
needs: playwright-tests
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request'
permissions:
pull-requests: write
steps:
- name: Download all deployment info
uses: actions/download-artifact@v4
with:
pattern: deployment-info-*
merge-multiple: true
path: deployment-info
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body
id: comment-body
run: |
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
echo "## 🎭 Playwright Test Results" >> comment.md
echo "" >> comment.md
# Check if all tests passed
ALL_PASSED=true
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
if [ "$exit_code" != "0" ]; then
ALL_PASSED=false
break
fi
fi
done
if [ "$ALL_PASSED" = "true" ]; then
echo "✅ **All tests passed across all browsers!**" >> comment.md
else
echo "❌ **Some tests failed!**" >> comment.md
fi
echo "" >> comment.md
echo "⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
echo "" >> comment.md
echo "### 📊 Test Reports by Browser" >> comment.md
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
url=$(echo "$info" | cut -d'|' -f3)
if [ "$exit_code" = "0" ]; then
status="✅"
else
status="❌"
fi
echo "- $status **$browser**: [View Report]($url)" >> comment.md
fi
done
echo "" >> comment.md
echo "---" >> comment.md
if [ "$ALL_PASSED" = "true" ]; then
echo "🎉 Your tests are passing across all browsers!" >> comment.md
else
echo "⚠️ Please check the test reports for details on failures." >> comment.md
fi
- name: Comment PR - Tests Complete
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md
- name: Check test results and fail if needed
run: |
# Check if all tests passed and fail the job if not
ALL_PASSED=true
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
if [ "$exit_code" != "0" ]; then
ALL_PASSED=false
break
fi
fi
done
if [ "$ALL_PASSED" = "false" ]; then
echo "❌ Tests failed in one or more browsers. Failing the CI job."
exit 1
else
echo "✅ All tests passed across all browsers!"
fi

4
.gitignore vendored
View File

@@ -13,8 +13,9 @@ bun.lockb
pnpm-lock.yaml
yarn.lock
# ESLint cache
# Cache files
.eslintcache
.prettiercache
node_modules
dist
@@ -22,6 +23,7 @@ dist-ssr
*.local
# Claude configuration
.claude/*.local.json
.claude/settings.json
# Editor directories and files
.vscode/*

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
src/types/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts

View File

@@ -93,6 +93,44 @@ export const WithVariant: Story = {
## Development Tips
## ComfyUI Storybook Guidelines
### Scope When to Create Stories
- **PrimeVue components**:
No need to create stories. Just refer to the official PrimeVue documentation.
- **Custom shared components (design system components)**:
Always create stories. These components are built in collaboration with designers, and Storybook serves as both documentation and a communication tool.
- **Container components (logic-heavy)**:
Do not create stories. Only the underlying pure UI components should be included in Storybook.
### Maintenance Philosophy
- Stories are lightweight and generally stable.
Once created, they rarely need updates unless:
- The design changes
- New props (e.g. size, color variants) are introduced
- For existing usage patterns, simply copy real code examples into Storybook to create stories.
### File Placement
- Keep `*.stories.ts` files at the **same level as the component** (similar to test files).
- This makes it easier to check usage examples without navigating to another directory.
### Developer/Designer Workflow
- **UI vs Container**: Separate pure UI components from container components.
Only UI components should live in Storybook.
- **Communication Tool**: Storybook is not just about code quality—it enables designers and developers to see:
- Which props exist
- What cases are covered
- How variants (e.g. size, colors) look in isolation
- **Example**:
`PackActionButton.vue` wraps a PrimeVue button with additional logic.
→ Only create a story for the base UI button, not for the wrapper.
### Suggested Workflow
1. Use PrimeVue docs for standard components
2. Use Storybook for **shared/custom components** that define our design system
3. Keep story files alongside components
4. When in doubt, focus on components reused across the app or those that need to be showcased to designers
### Best Practices
1. **Keep Stories Simple**: Each story should demonstrate one specific use case
@@ -135,6 +173,7 @@ export const WithLongText: Story = {
- **`main.ts`**: Core Storybook configuration and Vite integration
- **`preview.ts`**: Global decorators, parameters, and Vue app setup
- **`manager.ts`**: Storybook UI customization (if needed)
- **`preview-head.html`**: Injects custom HTML into the `<head>` of every Storybook iframe (used for global styles, fonts, or fixes for iframe-specific issues)
## Chromatic Visual Testing
@@ -170,4 +209,22 @@ This Storybook setup includes:
- PrimeVue component library integration
- Proper alias resolution for `@/` imports
For component-specific examples, see the NodePreview stories in `src/components/node/`.
## Icon Usage in Storybook
In this project, the `<i-lucide:... />` syntax from unplugin-icons is not supported in Storybook.
**Example:**
```vue
<script setup lang="ts">
import { Trophy, Settings } from 'lucide-vue-next'
</script>
<template>
<Trophy :size="16" class="text-neutral" />
<Settings :size="16" class="text-neutral" />
</template>
```
This approach ensures icons render correctly in Storybook and remain consistent with the rest of the app.

View File

@@ -392,16 +392,6 @@ Option 2 - Generate local baselines for comparison:
npx playwright test --update-snapshots
```
### Getting Test Artifacts from GitHub Actions
When tests fail in CI, you can download screenshots and traces:
1. Go to the failed workflow run in GitHub Actions
2. Scroll to "Artifacts" section at the bottom
3. Download `playwright-report` or `test-results`
4. Extract and open the HTML report locally
5. View actual vs expected screenshots and execution traces
### Creating New Screenshot Baselines
For PRs from `Comfy-Org/ComfyUI_frontend` branches:
@@ -412,6 +402,33 @@ For PRs from `Comfy-Org/ComfyUI_frontend` branches:
> **Note:** Fork PRs cannot auto-commit screenshots. A maintainer will need to commit the screenshots manually for you (don't worry, they'll do it).
## Viewing Test Reports
### Automated Test Deployment
The project automatically deploys Playwright test reports to Cloudflare Pages for every PR and push to main branches.
### Accessing Test Reports
- **From PR comments**: Click the "View Report" links for each browser
- **Direct URLs**: Reports are available at `https://[branch].comfyui-playwright-[browser].pages.dev` (branch-specific deployments)
- **From GitHub Actions**: Download artifacts from failed runs
### How It Works
1. **Test execution**: All browser tests run in parallel across multiple browsers
2. **Report generation**: HTML reports are generated for each browser configuration
3. **Cloudflare deployment**: Each browser's report deploys to its own Cloudflare Pages project with branch isolation:
- `comfyui-playwright-chromium` (with branch-specific URLs)
- `comfyui-playwright-mobile-chrome` (with branch-specific URLs)
- `comfyui-playwright-chromium-2x` (2x scale, with branch-specific URLs)
- `comfyui-playwright-chromium-0-5x` (0.5x scale, with branch-specific URLs)
4. **PR comments**: GitHub automatically updates PR comments with:
- ✅/❌ Test status for each browser
- Direct links to interactive test reports
- Real-time progress updates as tests complete
## Resources
- [Playwright UI Mode](https://playwright.dev/docs/test-ui-mode) - Interactive test debugging

15
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.5",
"version": "1.26.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.5",
"version": "1.26.6",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -84,6 +84,7 @@
"identity-obj-proxy": "^3.0.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
"storybook": "^9.1.1",
@@ -12224,6 +12225,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-vue-next": {
"version": "0.540.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.540.0.tgz",
"integrity": "sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.26.5",
"version": "1.26.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -14,8 +14,10 @@
"build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx playwright test",
"test:unit": "vitest run tests-ui/tests",
"test:component": "vitest run src/components/",
@@ -25,7 +27,8 @@
"lint:fix": "eslint src --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip",
"knip": "knip --cache",
"knip:no-cache": "knip",
"locale": "lobe-i18n locale",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
@@ -81,7 +84,8 @@
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
"zod-to-json-schema": "^3.24.1",
"lucide-vue-next": "^0.540.0"
},
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 5V3C14 2.44772 13.5523 2 13 2H11C10.4477 2 10 2.44772 10 3V5C10 5.55228 10.4477 6 11 6H13C13.5523 6 14 5.55228 14 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M6 5V3C6 2.44772 5.55228 2 5 2H3C2.44772 2 2 2.44772 2 3V5C2 5.55228 2.44772 6 3 6H5C5.55228 6 6 5.55228 6 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M14 13V11C14 10.4477 13.5523 10 13 10H11C10.4477 10 10 10.4477 10 11V13C10 13.5523 10.4477 14 11 14H13C13.5523 14 14 13.5523 14 13Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 4H6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 12H8C5.79086 12 4 10.2091 4 8V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.99999 4H6.99999M8.99999 12H7.6231C5.02081 12 3.11138 9.55445 3.74252 7.02986L3.99999 6M13.6894 3.24254L13.1894 5.24254C13.0781 5.6877 12.6781 6 12.2192 6H10.2808C9.63019 6 9.15284 5.38861 9.31062 4.75746L9.81062 2.75746C9.92192 2.3123 10.3219 2 10.7808 2H12.7192C13.3698 2 13.8471 2.61139 13.6894 3.24254ZM6.68936 3.24254L6.18936 5.24254C6.07806 5.6877 5.67808 6 5.21921 6H3.28077C2.63019 6 2.15284 5.38861 2.31062 4.75746L2.81062 2.75746C2.92191 2.3123 3.3219 2 3.78077 2H5.71921C6.36978 2 6.84714 2.61139 6.68936 3.24254ZM13.6894 11.2425L13.1894 13.2425C13.0781 13.6877 12.6781 14 12.2192 14H10.2808C9.63019 14 9.15284 13.3886 9.31062 12.7575L9.81062 10.7575C9.92192 10.3123 10.3219 10 10.7808 10H12.7192C13.3698 10 13.8471 10.6114 13.6894 11.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 890 B

After

Width:  |  Height:  |  Size: 910 B

View File

@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
const meta: Meta<typeof IconButton> = {
title: 'Components/Button/IconButton',
component: IconButton,
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: ['sm', 'md']
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent']
},
onClick: { action: 'clicked' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconButton, Trophy },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Trophy :size="16" />
</IconButton>
`
}),
args: {
type: 'primary',
size: 'md'
}
}
export const Secondary: Story = {
render: (args) => ({
components: { IconButton, Settings },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Settings :size="16" />
</IconButton>
`
}),
args: {
type: 'secondary',
size: 'md'
}
}
export const Transparent: Story = {
render: (args) => ({
components: { IconButton, X },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<X :size="16" />
</IconButton>
`
}),
args: {
type: 'transparent',
size: 'md'
}
}
export const Small: Story = {
render: (args) => ({
components: { IconButton, Bell },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Bell :size="12" />
</IconButton>
`
}),
args: {
type: 'secondary',
size: 'sm'
}
}
export const AllVariants: Story = {
render: () => ({
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconButton type="primary" size="sm" @click="() => {}">
<Trophy :size="12" />
</IconButton>
<IconButton type="primary" size="md" @click="() => {}">
<Trophy :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="secondary" size="sm" @click="() => {}">
<Settings :size="12" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Settings :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="transparent" size="sm" @click="() => {}">
<X :size="12" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<X :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="primary" size="md" @click="() => {}">
<Bell :size="16" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Heart :size="16" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<Download :size="16" />
</IconButton>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
import IconGroup from './IconGroup.vue'
const meta: Meta<typeof IconGroup> = {
title: 'Components/Button/IconGroup',
component: IconGroup,
parameters: {
layout: 'centered'
}
}
export default meta
type Story = StoryObj<typeof IconGroup>
export const Basic: Story = {
render: () => ({
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
template: `
<IconGroup>
<IconButton @click="console.log('Hello World!!')">
<Heart :size="16" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<Download :size="16" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<ExternalLink :size="16" />
</IconButton>
</IconGroup>
`
})
}

View File

@@ -0,0 +1,221 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
ChevronLeft,
ChevronRight,
Download,
Package,
Save,
Settings,
Trash2,
X
} from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
const meta: Meta<typeof IconTextButton> = {
title: 'Components/Button/IconTextButton',
component: IconTextButton,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text'
},
size: {
control: { type: 'select' },
options: ['sm', 'md']
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent']
},
iconPosition: {
control: { type: 'select' },
options: ['left', 'right']
},
onClick: { action: 'clicked' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconTextButton, Package },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Package :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Deploy',
type: 'primary',
size: 'md'
}
}
export const Secondary: Story = {
render: (args) => ({
components: { IconTextButton, Settings },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Settings :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Settings',
type: 'secondary',
size: 'md'
}
}
export const Transparent: Story = {
render: (args) => ({
components: { IconTextButton, X },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<X :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Cancel',
type: 'transparent',
size: 'md'
}
}
export const WithIconRight: Story = {
render: (args) => ({
components: { IconTextButton, ChevronRight },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<ChevronRight :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Next',
type: 'primary',
size: 'md',
iconPosition: 'right'
}
}
export const Small: Story = {
render: (args) => ({
components: { IconTextButton, Save },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Save :size="12" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Save',
type: 'primary',
size: 'sm'
}
}
export const AllVariants: Story = {
render: () => ({
components: {
IconTextButton,
Download,
Settings,
Trash2,
ChevronRight,
ChevronLeft,
Save
},
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon>
<Download :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon>
<Download :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon>
<Settings :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon>
<Settings :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon>
<Trash2 :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon>
<Trash2 :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon>
<ChevronRight :size="16" />
</template>
</IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon>
<ChevronLeft :size="16" />
</template>
</IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon>
<Save :size="16" />
</template>
</IconTextButton>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ScrollText } from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue'
const meta: Meta<typeof MoreButton> = {
title: 'Components/Button/MoreButton',
component: MoreButton,
parameters: {
layout: 'centered'
},
argTypes: {}
}
export default meta
type Story = StoryObj<typeof MoreButton>
export const Basic: Story = {
render: () => ({
components: { MoreButton, IconTextButton, Download, ScrollText },
template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton>
<template #default="{ close }">
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<Download />
</template>
</IconTextButton>
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<ScrollText />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>
`
})
}

View File

@@ -0,0 +1,83 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TextButton from './TextButton.vue'
const meta: Meta<typeof TextButton> = {
title: 'Components/Button/TextButton',
component: TextButton,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Click me'
},
size: {
control: { type: 'select' },
options: ['sm', 'md'],
defaultValue: 'md'
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent'],
defaultValue: 'primary'
},
onClick: { action: 'clicked' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
label: 'Primary Button',
type: 'primary',
size: 'md'
}
}
export const Secondary: Story = {
args: {
label: 'Secondary Button',
type: 'secondary',
size: 'md'
}
}
export const Transparent: Story = {
args: {
label: 'Transparent Button',
type: 'transparent',
size: 'md'
}
}
export const Small: Story = {
args: {
label: 'Small Button',
type: 'primary',
size: 'sm'
}
}
export const AllVariants: Story = {
render: () => ({
components: { TextButton },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<TextButton label="Primary Small" type="primary" size="sm" @click="() => {}" />
<TextButton label="Primary Medium" type="primary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Secondary Small" type="secondary" size="sm" @click="() => {}" />
<TextButton label="Secondary Medium" type="secondary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Transparent Small" type="transparent" size="sm" @click="() => {}" />
<TextButton label="Transparent Medium" type="transparent" size="md" @click="() => {}" />
</div>
</div>
`
})
}

View File

@@ -0,0 +1,665 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Folder,
Heart,
Info,
MoreVertical,
Star,
Upload
} from 'lucide-vue-next'
import { ref } from 'vue'
import IconButton from '../button/IconButton.vue'
import SquareChip from '../chip/SquareChip.vue'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
import CardDescription from './CardDescription.vue'
import CardTitle from './CardTitle.vue'
import CardTop from './CardTop.vue'
interface CardStoryArgs {
// CardContainer props
containerRatio: 'square' | 'portrait' | 'tallPortrait'
maxWidth: number
minWidth: number
// CardTop props
topRatio: 'square' | 'landscape'
// Content props
showTopLeft: boolean
showTopRight: boolean
showBottomLeft: boolean
showBottomRight: boolean
showTitle: boolean
showDescription: boolean
title: string
description: string
// Visual props
backgroundColor: string
showImage: boolean
imageUrl: string
// Tag props
tags: string[]
showFileSize: boolean
fileSize: string
showFileType: boolean
fileType: string
}
const meta: Meta<CardStoryArgs> = {
title: 'Components/Card/Card',
argTypes: {
containerRatio: {
control: 'select',
options: ['square', 'portrait', 'tallPortrait'],
description: 'Card container aspect ratio'
},
maxWidth: {
control: { type: 'range', min: 200, max: 600, step: 10 },
description: 'Maximum width in pixels'
},
minWidth: {
control: { type: 'range', min: 150, max: 400, step: 10 },
description: 'Minimum width in pixels'
},
topRatio: {
control: 'select',
options: ['square', 'landscape'],
description: 'Top section aspect ratio'
},
showTopLeft: {
control: 'boolean',
description: 'Show top-left slot content'
},
showTopRight: {
control: 'boolean',
description: 'Show top-right slot content'
},
showBottomLeft: {
control: 'boolean',
description: 'Show bottom-left slot content'
},
showBottomRight: {
control: 'boolean',
description: 'Show bottom-right slot content'
},
showTitle: {
control: 'boolean',
description: 'Show card title'
},
showDescription: {
control: 'boolean',
description: 'Show card description'
},
title: {
control: 'text',
description: 'Card title text'
},
description: {
control: 'text',
description: 'Card description text'
},
backgroundColor: {
control: 'color',
description: 'Background color for card top'
},
showImage: {
control: 'boolean',
description: 'Show image instead of color background'
},
imageUrl: {
control: 'text',
description: 'Image URL for card top'
},
tags: {
control: 'object',
description: 'Tags to display (array of strings)'
},
showFileSize: {
control: 'boolean',
description: 'Show file size tag'
},
fileSize: {
control: 'text',
description: 'File size text'
},
showFileType: {
control: 'boolean',
description: 'Show file type tag'
},
fileType: {
control: 'text',
description: 'File type text'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const createCardTemplate = (args: CardStoryArgs) => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download,
Star,
Upload,
MoreVertical
},
setup() {
const favorited = ref(false)
const toggleFavorite = () => {
favorited.value = !favorited.value
}
return {
args,
favorited,
toggleFavorite
}
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<CardContainer
:ratio="args.containerRatio"
:max-width="args.maxWidth"
:min-width="args.minWidth"
>
<template #top>
<CardTop :ratio="args.topRatio">
<template #default>
<div
v-if="!args.showImage"
class="w-full h-full"
:style="{ backgroundColor: args.backgroundColor }"
></div>
<img
v-else
:src="args.imageUrl || 'https://via.placeholder.com/400'"
class="w-full h-full object-cover"
alt="Card image"
/>
</template>
<template v-if="args.showTopLeft" #top-left>
<SquareChip label="Featured" />
</template>
<template v-if="args.showTopRight" #top-right>
<IconButton
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info clicked')"
>
<Info :size="16" />
</IconButton>
<IconButton
class="!bg-white/90"
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
@click="toggleFavorite"
>
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
</IconButton>
</template>
<template v-if="args.showBottomLeft" #bottom-left>
<SquareChip label="New" />
</template>
<template v-if="args.showBottomRight" #bottom-right>
<SquareChip v-if="args.showFileType" :label="args.fileType" />
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
</template>
</SquareChip>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
`
})
export const Default: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 300,
minWidth: 200,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Model Name',
description:
'This is a detailed description of the model that can span multiple lines',
backgroundColor: '#3b82f6',
showImage: false,
imageUrl: '',
tags: ['LoRA', 'SDXL'],
showFileSize: true,
fileSize: '1.2 MB',
showFileType: true,
fileType: 'safetensors'
}
}
export const SquareCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 400,
minWidth: 250,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Workflow Bundle',
description:
'Complete workflow for image generation with all necessary nodes',
backgroundColor: '#10b981',
showImage: false,
imageUrl: '',
tags: ['Workflow'],
showFileSize: true,
fileSize: '245 KB',
showFileType: true,
fileType: 'json'
}
}
export const TallPortraitCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 280,
minWidth: 180,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Premium Model',
description:
'High-quality photorealistic model trained on professional photography',
backgroundColor: '#8b5cf6',
showImage: false,
imageUrl: '',
tags: ['SD 1.5', 'Checkpoint'],
showFileSize: true,
fileSize: '2.1 GB',
showFileType: true,
fileType: 'ckpt'
}
}
export const ImageCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 350,
minWidth: 220,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Generated Image',
description: 'Created with DreamShaper XL',
backgroundColor: '#3b82f6',
showImage: true,
imageUrl: 'https://picsum.photos/400/400',
tags: ['Output'],
showFileSize: true,
fileSize: '856 KB',
showFileType: true,
fileType: 'png'
}
}
export const MinimalCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 300,
minWidth: 200,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: false,
title: 'Simple Card',
description: '',
backgroundColor: '#64748b',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const FullFeaturedCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 320,
minWidth: 240,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
showBottomLeft: true,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Ultimate Model Pack',
description:
'Complete collection with checkpoints, LoRAs, embeddings, and VAE models for professional use',
backgroundColor: '#ef4444',
showImage: false,
imageUrl: '',
tags: ['Bundle', 'Premium', 'SDXL'],
showFileSize: true,
fileSize: '5.4 GB',
showFileType: true,
fileType: 'pack'
}
}
export const GridOfCards: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download
},
setup() {
const cards = ref([
{
id: 1,
title: 'Realistic Vision',
description: 'Photorealistic model for portraits',
color: 'from-blue-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['SD 1.5'],
size: '2.1 GB'
},
{
id: 2,
title: 'DreamShaper XL',
description: 'Artistic style model with enhanced details',
color: 'from-purple-400 to-pink-600',
ratio: 'portrait' as const,
tags: ['SDXL'],
size: '6.5 GB'
},
{
id: 3,
title: 'Anime LoRA',
description: 'Character style LoRA',
color: 'from-green-400 to-teal-600',
ratio: 'portrait' as const,
tags: ['LoRA'],
size: '144 MB'
},
{
id: 4,
title: 'VAE Model',
description: 'Enhanced color VAE',
color: 'from-orange-400 to-red-600',
ratio: 'portrait' as const,
tags: ['VAE'],
size: '335 MB'
},
{
id: 5,
title: 'Workflow Bundle',
description: 'Complete workflow setup',
color: 'from-indigo-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['Workflow'],
size: '45 KB'
},
{
id: 6,
title: 'Embedding Pack',
description: 'Negative embeddings collection',
color: 'from-yellow-400 to-orange-600',
ratio: 'portrait' as const,
tags: ['Embedding'],
size: '2.3 MB'
}
])
return { cards }
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<CardContainer
v-for="card in cards"
:key="card.id"
:ratio="card.ratio"
:max-width="300"
:min-width="180"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-gray-600"
:class="card.color"
></div>
</template>
<template #top-right>
<IconButton
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info:', card.title)"
>
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip
v-for="tag in card.tags"
:key="tag"
:label="tag"
>
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
</template>
</SquareChip>
<SquareChip :label="card.size" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
`
})
}
export const ResponsiveGrid: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
SquareChip
},
setup() {
const generateCards = (
count: number,
ratio: 'square' | 'portrait' | 'tallPortrait'
) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
title: `Model ${i + 1}`,
description: `Description for model ${i + 1}`,
ratio,
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
}))
}
const squareCards = ref(generateCards(4, 'square'))
const portraitCards = ref(generateCards(6, 'portrait'))
const tallCards = ref(generateCards(5, 'tallPortrait'))
return {
squareCards,
portraitCards,
tallCards
}
},
template: `
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<CardContainer
v-for="card in squareCards"
:key="card.id"
:ratio="card.ratio"
:max-width="400"
:min-width="200"
>
<template #top>
<CardTop ratio="landscape">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<CardContainer
v-for="card in portraitCards"
:key="card.id"
:ratio="card.ratio"
:max-width="280"
:min-width="160"
>
<template #top>
<CardTop ratio="square">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-2">
<CardTitle>{{ card.title }}</CardTitle>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<CardContainer
v-for="card in tallCards"
:key="card.id"
:ratio="card.ratio"
:max-width="260"
:min-width="150"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</template>
<template #bottom-right>
<SquareChip :label="'#' + card.id" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -13,8 +13,8 @@ const {
maxWidth,
minWidth
} = defineProps<{
maxWidth: number
minWidth: number
maxWidth?: number
minWidth?: number
ratio?: 'square' | 'portrait' | 'tallPortrait'
}>()
@@ -31,8 +31,12 @@ const containerClasses = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}`
})
const containerStyle = computed(() => ({
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}))
const containerStyle = computed(() =>
maxWidth || minWidth
? {
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}
: {}
)
</script>

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SquareChip from './SquareChip.vue'
const meta: Meta<typeof SquareChip> = {
title: 'Components/SquareChip',
component: SquareChip,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Tag'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const TagList: Story = {
render: () => ({
components: { SquareChip },
template: `
<div class="flex flex-wrap gap-2">
<SquareChip label="JavaScript" />
<SquareChip label="TypeScript" />
<SquareChip label="Vue.js" />
<SquareChip label="React" />
<SquareChip label="Node.js" />
<SquareChip label="Python" />
<SquareChip label="Docker" />
<SquareChip label="Kubernetes" />
</div>
`
})
}

View File

@@ -12,7 +12,6 @@
<ColorPickerButton />
<BypassButton />
<PinButton />
<EditModelButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
@@ -35,7 +34,6 @@ import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'

View File

@@ -1,37 +0,0 @@
<template>
<Button
v-show="isImageOutputSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_AddEditModelStep.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-pen-to-square"
@click="() => commandStore.execute('Comfy.Canvas.AddEditModelStep')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isImageOutputOrEditModelNode = (node: unknown) =>
isLGraphNode(node) &&
(isImageNode(node) || node.type === 'workflow>FLUX.1 Kontext Image Edit')
const isImageOutputSelected = computed(
() =>
canvasStore.selectedItems.length === 1 &&
isImageOutputOrEditModelNode(canvasStore.selectedItems[0])
)
</script>

View File

@@ -0,0 +1,151 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
const meta: Meta<typeof MultiSelect> = {
title: 'Components/Input/MultiSelect',
component: MultiSelect,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text'
},
options: {
control: 'object'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref([])
const options = [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
return { selected, options, args }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
label="Select Frameworks"
v-bind="args"
/>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
`
})
}
export const WithPreselectedValues: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const options = [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
]
const selected = ref([options[0], options[1]])
return { selected, options }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
label="Select Languages"
/>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div>
</div>
`
})
}
export const MultipleSelectors: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
])
const projectOptions = ref([
{ name: 'Project A', value: 'proj-a' },
{ name: 'Project B', value: 'proj-b' },
{ name: 'Project C', value: 'proj-c' },
{ name: 'Project D', value: 'proj-d' }
])
const tagOptions = ref([
{ name: 'Frontend', value: 'frontend' },
{ name: 'Backend', value: 'backend' },
{ name: 'Database', value: 'database' },
{ name: 'DevOps', value: 'devops' },
{ name: 'Testing', value: 'testing' }
])
const selectedFrameworks = ref([])
const selectedProjects = ref([])
const selectedTags = ref([])
return {
frameworkOptions,
projectOptions,
tagOptions,
selectedFrameworks,
selectedProjects,
selectedTags
}
},
template: `
<div class="space-y-4">
<div class="flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
:options="frameworkOptions"
label="Select Frameworks"
/>
<MultiSelect
v-model="selectedProjects"
:options="projectOptions"
label="Select Projects"
/>
<MultiSelect
v-model="selectedTags"
:options="tagOptions"
label="Select Tags"
/>
</div>
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<h4 class="font-medium mb-2">Current Selection:</h4>
<div class="space-y-1 text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
</div>
`
})
}

View File

@@ -9,6 +9,41 @@
:max-selected-labels="0"
:pt="pt"
>
<template
v-if="hasSearchBox || showSelectedCount || hasClearButton"
#header
>
<div class="p-2 flex flex-col gap-y-4 pb-0">
<SearchBox
v-if="hasSearchBox"
v-model="searchQuery"
:has-border="true"
:place-holder="searchPlaceholder"
/>
<div class="flex items-center justify-between">
<span
v-if="showSelectedCount"
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
v-if="hasClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
<div class="h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
@@ -42,7 +77,7 @@
</template>
</MultiSelect>
<!-- Selected count badge (unchanged) -->
<!-- Selected count badge -->
<div
v-if="selectedCount > 0"
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
@@ -58,22 +93,41 @@ import MultiSelect, {
} from 'primevue/multiselect'
import { computed } from 'vue'
const { label, options } = defineProps<{
label?: string
options: { name: string; value: string }[]
}>()
import SearchBox from '@/components/input/SearchBox.vue'
const selectedItems = defineModel<{ name: string; value: string }[]>({
import TextButton from '../button/TextButton.vue'
type Option = { name: string; value: string }
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Static options for the multiselect (when not using async search) */
options: Option[]
/** Show search box in the panel header */
hasSearchBox?: boolean
/** Show selected count text in the panel header */
showSelectedCount?: boolean
/** Show "Clear all" action in the panel header */
hasClearButton?: boolean
/** Placeholder for the search input */
searchPlaceholder?: string
}
const {
label,
options,
hasSearchBox = false,
showSelectedCount = false,
hasClearButton = false,
searchPlaceholder = 'Search...'
} = defineProps<Props>()
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery')
const selectedCount = computed(() => selectedItems.value.length)
/**
* Pure unstyled mode using only the PrimeVue PT API.
* All PrimeVue built-in checkboxes/headers are hidden via PT (no :deep hacks).
* Visual output matches the previous version exactly.
*/
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [
@@ -97,19 +151,19 @@ const pt = computed(() => ({
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: { class: 'hidden' },
header: () => ({
class:
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden'
}),
// Overlay & list visuals unchanged
overlay:
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100',
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700',
list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
},
// Option row hover tone identical
option:
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const meta: Meta<typeof SearchBox> = {
title: 'Components/Input/SearchBox',
component: SearchBox,
tags: ['autodocs'],
argTypes: {
placeHolder: {
control: 'text'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { SearchBox },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div>
<SearchBox v-model:="searchQuery" />
</div>
`
})
}

View File

@@ -1,8 +1,6 @@
<template>
<div
class="flex w-full items-center rounded-lg px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800"
>
<i-lucide:search class="text-neutral" />
<div :class="wrapperStyle">
<i-lucide:search :class="iconColorStyle" />
<InputText
v-model="searchQuery"
:placeholder="placeHolder || 'Search...'"
@@ -15,10 +13,21 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { defineModel } from 'vue'
import { computed, defineModel } from 'vue'
const { placeHolder } = defineProps<{
const { placeHolder, hasBorder = false } = defineProps<{
placeHolder?: string
hasBorder?: boolean
}>()
const searchQuery = defineModel<string>('')
const wrapperStyle = computed(() => {
return hasBorder
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
})
const iconColorStyle = computed(() => {
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
})
</script>

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ArrowUpDown } from 'lucide-vue-next'
import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
const meta: Meta<typeof SingleSelect> = {
title: 'Components/Input/SingleSelect',
component: SingleSelect,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' }
}
}
export default meta
export type Story = StoryObj<typeof meta>
const sampleOptions = [
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'Oldest', value: 'oldest' },
{ name: 'A → Z', value: 'az' },
{ name: 'Z → A', value: 'za' }
]
export const Default: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const options = sampleOptions
return { selected, options, args }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" :label="args.label || 'Sorting Type'" />
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div>
</div>
`
}),
args: { label: 'Sorting Type' }
}
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
setup() {
const selected = ref<string | null>('popular')
const options = sampleOptions
return { selected, options }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<ArrowUpDown :size="14" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected }}</p>
</div>
</div>
`
})
}
export const Preselected: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('newest')
const options = sampleOptions
return { selected, options }
},
template: `
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
const b = ref<string | null>('popular')
const c = ref<string | null>('az')
return { options, a, b, c }
},
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<SingleSelect v-model="a" :options="options" label="No Icon" />
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<ArrowUpDown :size="14" />
</template>
</SingleSelect>
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -99,7 +99,7 @@ const pt = computed(() => ({
overlay: {
class: [
// dropdown panel
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg'
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700'
]
},
list: {

View File

@@ -224,7 +224,7 @@ const cancelledWithoutResults = computed(() => {
.task-item-details {
position: absolute;
bottom: 0;
top: 0.5rem;
padding: 0.6rem;
display: flex;
justify-content: space-between;

View File

@@ -59,6 +59,10 @@
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
class="w-[250px]"
:has-search-box="true"
:show-selected-count="true"
:has-clear-button="true"
label="Select Frameworks"
:options="frameworkOptions"
/>

View File

@@ -0,0 +1,556 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Filter,
Folder,
Info,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
Puzzle,
Scroll,
Settings,
Upload,
X
} from 'lucide-vue-next'
import { provide, ref } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseWidgetLayout from './BaseWidgetLayout.vue'
interface StoryArgs {
contentTitle: string
hasLeftPanel: boolean
hasRightPanel: boolean
hasHeader: boolean
hasContentFilter: boolean
hasHeaderRightArea: boolean
cardCount: number
}
const meta: Meta<StoryArgs> = {
title: 'Components/Widget/Layout/BaseWidgetLayout',
argTypes: {
contentTitle: {
control: 'text',
description: 'Title shown when no left panel is present'
},
hasLeftPanel: {
control: 'boolean',
description: 'Toggle left panel visibility'
},
hasRightPanel: {
control: 'boolean',
description: 'Toggle right panel visibility'
},
hasHeader: {
control: 'boolean',
description: 'Toggle header visibility'
},
hasContentFilter: {
control: 'boolean',
description: 'Toggle content filter visibility'
},
hasHeaderRightArea: {
control: 'boolean',
description: 'Toggle header right area visibility'
},
cardCount: {
control: { type: 'range', min: 0, max: 50, step: 1 },
description: 'Number of cards to display in content'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseWidgetLayout,
LeftSidePanel,
RightSidePanel,
SearchBox,
MultiSelect,
SingleSelect,
IconButton,
IconTextButton,
MoreButton,
CardContainer,
CardTop,
CardBottom,
SquareChip,
Settings,
Upload,
Download,
Scroll,
Info,
Filter,
Folder,
Puzzle,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X
},
setup() {
const t = (k: string) => k
const onClose = () => {
console.log('OnClose invoked')
}
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')
const searchQuery = ref<string>('')
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
])
const projectOptions = ref([
{ name: 'Project A', value: 'proj-a' },
{ name: 'Project B', value: 'proj-b' },
{ name: 'Project C', value: 'proj-c' }
])
const sortOptions = ref([
{ name: 'Popular', value: 'popular' },
{ name: 'Latest', value: 'latest' },
{ name: 'A → Z', value: 'az' }
])
const selectedFrameworks = ref<string[]>([])
const selectedProjects = ref<string[]>([])
const selectedSort = ref<string>('popular')
return {
args,
t,
tempNavigation,
selectedNavItem,
searchQuery,
frameworkOptions,
projectOptions,
sortOptions,
selectedFrameworks,
selectedProjects,
selectedSort
}
},
template: `
<div>
<BaseWidgetLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
</template>
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
</template>
<!-- Header Right Area -->
<template v-if="args.hasHeaderRightArea" #header-right-area>
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
</template>
</IconTextButton>
<MoreButton>
<template #default="{ close }">
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
</template>
</IconTextButton>
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>
</template>
<!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
label="Select Frameworks"
:options="frameworkOptions"
/>
<MultiSelect
v-model="selectedProjects"
label="Select Projects"
:options="projectOptions"
/>
<SingleSelect
v-model="selectedSort"
label="Sorting Type"
:options="sortOptions"
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
</template>
</SingleSelect>
</div>
</template>
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<CardContainer
v-for="i in args.cardCount"
:key="i"
ratio="square"
>
<template #top>
<CardTop ratio="landscape">
<template #default>
<div class="w-full h-full bg-blue-500"></div>
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
</template>
</SquareChip>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom />
</template>
</CardContainer>
</div>
</template>
</BaseWidgetLayout>
<BaseWidgetLayout v-else :content-title="args.contentTitle || 'Content Title'">
<!-- Same content but WITH right panel -->
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
</template>
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
</template>
<!-- Header Right Area -->
<template v-if="args.hasHeaderRightArea" #header-right-area>
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
</template>
</IconTextButton>
<MoreButton>
<template #default="{ close }">
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
</template>
</IconTextButton>
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>
</template>
<!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
label="Select Frameworks"
:options="frameworkOptions"
/>
<MultiSelect
v-model="selectedProjects"
label="Select Projects"
:options="projectOptions"
/>
<SingleSelect
v-model="selectedSort"
label="Sorting Type"
:options="sortOptions"
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
</template>
</SingleSelect>
</div>
</template>
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<CardContainer
v-for="i in args.cardCount"
:key="i"
ratio="square"
>
<template #top>
<CardTop ratio="landscape">
<template #default>
<div class="w-full h-full bg-blue-500"></div>
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
</template>
</SquareChip>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom />
</template>
</CardContainer>
</div>
</template>
<!-- Right Panel - Only when hasRightPanel is true -->
<template #rightPanel>
<RightSidePanel />
</template>
</BaseWidgetLayout>
</div>
`
})
export const Default: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const BothPanels: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const LeftPanelOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: true,
hasRightPanel: false,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const RightPanelOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: false,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const NoPanels: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const MinimalLayout: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Simple Content',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: false,
hasContentFilter: false,
hasHeaderRightArea: false,
cardCount: 6
}
}
export const NoContent: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Empty State',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 0
}
}
export const HeaderOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Header Layout',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: true,
hasContentFilter: false,
hasHeaderRightArea: true,
cardCount: 8
}
}
export const FilterOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Filter Layout',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: false,
hasContentFilter: true,
hasHeaderRightArea: false,
cardCount: 8
}
}
export const MaxContent: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Full Content',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 50
}
}

View File

@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
BarChart3,
Bell,
BookOpen,
FolderOpen,
GraduationCap,
Home,
LogOut,
MessageSquare,
Settings,
User,
Users
} from 'lucide-vue-next'
import { ref } from 'vue'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import NavItem from './NavItem.vue'
import NavTitle from './NavTitle.vue'
const meta: Meta = {
title: 'Components/Widget/Navigation',
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
export const NavigationItem: Story = {
render: () => ({
components: { NavItem },
template: `
<div class="space-y-2">
<NavItem>Dashboard</NavItem>
<NavItem>Projects</NavItem>
<NavItem>Messages</NavItem>
<NavItem>Settings</NavItem>
</div>
`
})
}
export const CustomNavigation: Story = {
render: () => ({
components: {
NavTitle,
NavItem,
Home,
FolderOpen,
BarChart3,
Users,
BookOpen,
GraduationCap,
MessageSquare,
Settings,
User,
Bell,
LogOut
},
template: `
<nav class="w-64 p-4 bg-white dark-theme:bg-zinc-800 rounded-lg">
<NavTitle title="Main Menu" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Home :size="16" class="inline mr-2" />Dashboard</NavItem>
<NavItem :hasFolderIcon="false"><FolderOpen :size="16" class="inline mr-2" />Projects</NavItem>
<NavItem :hasFolderIcon="false"><BarChart3 :size="16" class="inline mr-2" />Analytics</NavItem>
<NavItem :hasFolderIcon="false"><Users :size="16" class="inline mr-2" />Team</NavItem>
</div>
<div class="mt-6">
<NavTitle title="Resources" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><BookOpen :size="16" class="inline mr-2" />Documentation</NavItem>
<NavItem :hasFolderIcon="false"><GraduationCap :size="16" class="inline mr-2" />Tutorials</NavItem>
<NavItem :hasFolderIcon="false"><MessageSquare :size="16" class="inline mr-2" />Community</NavItem>
</div>
</div>
<div class="mt-6">
<NavTitle title="Account" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Settings :size="16" class="inline mr-2" />Settings</NavItem>
<NavItem :hasFolderIcon="false"><User :size="16" class="inline mr-2" />Profile</NavItem>
<NavItem :hasFolderIcon="false"><Bell :size="16" class="inline mr-2" />Notifications</NavItem>
<NavItem :hasFolderIcon="false"><LogOut :size="16" class="inline mr-2" />Logout</NavItem>
</div>
</div>
</nav>
`
})
}
export const LeftSidePanelDemo: Story = {
render: () => ({
components: { LeftSidePanel, FolderOpen },
setup() {
const navItems = [
{
title: 'Workspace',
items: [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'projects', label: 'Projects' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'models', label: 'Models' }
]
},
{
title: 'Tools',
items: [
{ id: 'node-editor', label: 'Node Editor' },
{ id: 'image-browser', label: 'Image Browser' },
{ id: 'queue-manager', label: 'Queue Manager' },
{ id: 'extensions', label: 'Extensions' }
]
},
{ id: 'settings', label: 'Settings' }
]
const active = ref<string | null>(null)
return { navItems, active }
},
template: `
<div class="w-full h-[560px] flex">
<div class="w-64 rounded-lg">
<LeftSidePanel v-model="active" :nav-items="navItems">
<template #header-icon>
<FolderOpen :size="14" />
</template>
<template #header-title>
Navigation
</template>
</LeftSidePanel>
</div>
<div class="flex-1 p-3 text-sm bg-gray-50 dark-theme:bg-zinc-900 border-t border-zinc-200 dark-theme:border-zinc-700">
Active: {{ active ?? 'None' }}
</div>
</div>
`
})
}

View File

@@ -4,32 +4,64 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const { copy, copied } = useClipboard()
const toast = useToast()
const showSuccessToast = () => {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
}
const showErrorToast = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
textarea.setAttribute('aria-hidden', 'true')
textarea.setAttribute('tabindex', '-1')
textarea.style.width = '1px'
textarea.style.height = '1px'
document.body.appendChild(textarea)
textarea.select()
try {
// using legacy document.execCommand for fallback for old and linux browsers
const successful = document.execCommand('copy')
if (successful) {
showSuccessToast()
} else {
showErrorToast()
}
} catch (err) {
showErrorToast()
} finally {
textarea.remove()
}
}
const copyToClipboard = async (text: string) => {
if (isSupported) {
try {
await copy(text)
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
} catch (err) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
try {
await copy(text)
if (copied.value) {
showSuccessToast()
} else {
// If VueUse copy failed, try fallback
fallbackCopy(text)
}
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorNotSupported')
})
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
}
}

View File

@@ -16,7 +16,6 @@ import {
import { Point } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { addFluxKontextGroupNode } from '@/scripts/fluxKontextEditNode'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
@@ -775,17 +774,6 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
},
{
id: 'Comfy.Canvas.AddEditModelStep',
icon: 'pi pi-pen-to-square',
label: 'Add Edit Model Step',
versionAdded: '1.23.3',
function: async () => {
const node = app.canvas.selectedItems.values().next().value
if (!(node instanceof LGraphNode)) return
await addFluxKontextGroupNode(node)
}
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',

View File

@@ -54,7 +54,10 @@ export class CanvasPointer {
* After a flick gesture is complete, the automatic wheel events are sent with
* reduced frequency, but much higher deltaX and deltaY values.
*/
static trackpadMaxGap = 200
static trackpadMaxGap = 500
/** The maximum time in milliseconds to buffer a high-res wheel event. */
static maxHighResBufferTime = 10
/** The element this PointerState should capture input against when dragging. */
element: Element
@@ -90,8 +93,23 @@ export class CanvasPointer {
/** The last pointerup event for the primary button */
eUp?: CanvasPointerEvent
/** The last pointermove event that was treated as a trackpad gesture. */
lastTrackpadEvent?: WheelEvent
/** Currently detected input device type */
detectedDevice: 'mouse' | 'trackpad' = 'mouse'
/** Timestamp of last wheel event for cooldown tracking */
lastWheelEventTime: number = 0
/** Flag to track if we've received the first wheel event */
hasReceivedWheelEvent: boolean = false
/** Buffered Linux wheel event awaiting confirmation */
bufferedLinuxEvent?: WheelEvent
/** Timestamp when Linux event was buffered */
bufferedLinuxEventTime: number = 0
/** Timer ID for Linux buffer clearing */
linuxBufferTimeoutId?: ReturnType<typeof setTimeout>
/**
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
@@ -273,33 +291,179 @@ export class CanvasPointer {
delete this.onDragStart
}
/**
* Checks if the given wheel event is part of a continued trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
*/
#isContinuationOfGesture(e: WheelEvent): boolean {
const { lastTrackpadEvent } = this
if (!lastTrackpadEvent) return false
return (
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
)
}
/**
* Checks if the given wheel event is part of a trackpad gesture.
* This method now uses the new device detection internally for improved accuracy.
* @param e The wheel event to check
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
*/
isTrackpadGesture(e: WheelEvent): boolean {
if (this.#isContinuationOfGesture(e)) {
this.lastTrackpadEvent = e
// Use the new device detection
const now = performance.now()
const timeSinceLastEvent = Math.max(0, now - this.lastWheelEventTime)
this.lastWheelEventTime = now
if (this.#isHighResWheelEvent(e, now)) {
this.detectedDevice = 'mouse'
} else if (this.#isWithinCooldown(timeSinceLastEvent)) {
if (this.#shouldBufferLinuxEvent(e)) {
this.#bufferLinuxEvent(e, now)
}
} else {
this.#updateDeviceMode(e, now)
this.hasReceivedWheelEvent = true
}
return this.detectedDevice === 'trackpad'
}
/**
* Validates buffered high res wheel events and switches to mouse mode if pattern matches.
* @returns `true` if switched to mouse mode
*/
#isHighResWheelEvent(event: WheelEvent, now: number): boolean {
if (!this.bufferedLinuxEvent || this.bufferedLinuxEventTime <= 0) {
return false
}
const timeSinceBuffer = now - this.bufferedLinuxEventTime
if (timeSinceBuffer > CanvasPointer.maxHighResBufferTime) {
this.#clearLinuxBuffer()
return false
}
if (
event.deltaX === 0 &&
this.#isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY)
) {
this.#clearLinuxBuffer()
return true
}
const threshold = CanvasPointer.trackpadThreshold
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
return false
}
/**
* Checks if we're within the cooldown period where mode switching is disabled.
*/
#isWithinCooldown(timeSinceLastEvent: number): boolean {
const isFirstEvent = !this.hasReceivedWheelEvent
const cooldownExpired = timeSinceLastEvent >= CanvasPointer.trackpadMaxGap
return !isFirstEvent && !cooldownExpired
}
/**
* Updates the device mode based on event patterns.
*/
#updateDeviceMode(event: WheelEvent, now: number): void {
if (this.#isTrackpadPattern(event)) {
this.detectedDevice = 'trackpad'
} else if (this.#isMousePattern(event)) {
this.detectedDevice = 'mouse'
} else if (
this.detectedDevice === 'trackpad' &&
this.#shouldBufferLinuxEvent(event)
) {
this.#bufferLinuxEvent(event, now)
}
}
/**
* Clears the buffered Linux wheel event and associated timer.
*/
#clearLinuxBuffer(): void {
this.bufferedLinuxEvent = undefined
this.bufferedLinuxEventTime = 0
if (this.linuxBufferTimeoutId !== undefined) {
clearTimeout(this.linuxBufferTimeoutId)
this.linuxBufferTimeoutId = undefined
}
}
/**
* Checks if the event matches trackpad input patterns.
* @param event The wheel event to check
*/
#isTrackpadPattern(event: WheelEvent): boolean {
// Two-finger panning: non-zero deltaX AND deltaY
if (event.deltaX !== 0 && event.deltaY !== 0) return true
// Pinch-to-zoom: ctrlKey with small deltaY
if (event.ctrlKey && Math.abs(event.deltaY) < 10) return true
return false
}
/**
* Checks if the event matches mouse wheel input patterns.
* @param event The wheel event to check
*/
#isMousePattern(event: WheelEvent): boolean {
const absoluteDeltaY = Math.abs(event.deltaY)
// Primary threshold for switching from trackpad to mouse
if (absoluteDeltaY > 80) return true
// Secondary threshold when already in mouse mode
return (
absoluteDeltaY >= 60 &&
event.deltaX === 0 &&
this.detectedDevice === 'mouse'
)
}
/**
* Checks if the event should be buffered as a potential Linux wheel event.
* @param event The wheel event to check
*/
#shouldBufferLinuxEvent(event: WheelEvent): boolean {
const absoluteDeltaY = Math.abs(event.deltaY)
const isInLinuxRange = absoluteDeltaY >= 10 && absoluteDeltaY < 60
const isVerticalOnly = event.deltaX === 0
const hasIntegerDelta = Number.isInteger(event.deltaY)
return (
this.detectedDevice === 'trackpad' &&
isInLinuxRange &&
isVerticalOnly &&
hasIntegerDelta
)
}
/**
* Buffers a potential Linux wheel event for later confirmation.
* @param event The event to buffer
* @param now The current timestamp
*/
#bufferLinuxEvent(event: WheelEvent, now: number): void {
if (this.linuxBufferTimeoutId !== undefined) {
clearTimeout(this.linuxBufferTimeoutId)
}
this.bufferedLinuxEvent = event
this.bufferedLinuxEventTime = now
// Set timeout to clear buffer after 10ms
this.linuxBufferTimeoutId = setTimeout(() => {
this.#clearLinuxBuffer()
}, CanvasPointer.maxHighResBufferTime)
}
/**
* Checks if two deltaY values follow a Linux wheel pattern (divisibility).
* @param deltaY1 The first deltaY value
* @param deltaY2 The second deltaY value
*/
#isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
const absolute1 = Math.abs(deltaY1)
const absolute2 = Math.abs(deltaY2)
if (absolute1 === 0 || absolute2 === 0) return false
if (absolute1 === absolute2) return true
// Check if one value is a multiple of the other
return absolute1 % absolute2 === 0 || absolute2 % absolute1 === 0
}
/**

View File

@@ -36,6 +36,7 @@ import type {
INodeSlot,
INodeSlotContextItem,
ISlotType,
LinkNetwork,
LinkSegment,
NullableProperties,
Point,
@@ -2575,16 +2576,27 @@ export class LGraphCanvas
} else if (!node.flags.collapsed) {
const { inputs, outputs } = node
function hasRelevantOutputLinks(
output: INodeOutputSlot,
network: LinkNetwork
): boolean {
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
]
return outputLinks.some(
(linkId) =>
typeof linkId === 'number' && network.getLink(linkId) !== undefined
)
}
// Outputs
if (outputs) {
for (const [i, output] of outputs.entries()) {
const link_pos = node.getOutputPos(i)
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
// Drag multiple output links
if (
e.shiftKey &&
(output.links?.length || output._floatingLinks?.size)
) {
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
linkConnector.moveOutputLink(graph, output)
this.#linkConnectorDrop()
return
@@ -3486,8 +3498,11 @@ export class LGraphCanvas
// Detect if this is a trackpad gesture or mouse wheel
const isTrackpad = this.pointer.isTrackpadGesture(e)
const isCtrlOrMacMeta =
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
if (isZoomModifier || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling

View File

@@ -1925,6 +1925,7 @@ export class LGraphNode
}
}
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
}

View File

@@ -314,6 +314,27 @@ export class LinkConnector {
this.outputLinks.push(link)
try {
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
if (!(network instanceof Subgraph)) {
console.warn(
'Subgraph output link found in non-subgraph network.'
)
continue
}
const output = network.outputs.at(link.target_slot)
if (!output) throw new Error('No subgraph output found for link.')
const renderLink = new ToOutputFromIoNodeLink(
network,
network.outputNode,
output
)
renderLink.fromDirection = LinkDirection.NONE
renderLinks.push(renderLink)
continue
}
const renderLink = new MovingOutputLink(
network,
link,

View File

@@ -187,7 +187,7 @@ export { LGraphButton, type LGraphButtonOptions } from './LGraphButton'
export { MovingOutputLink } from './canvas/MovingOutputLink'
export { ToOutputRenderLink } from './canvas/ToOutputRenderLink'
export { ToInputFromIoNodeLink } from './canvas/ToInputFromIoNodeLink'
export type { TWidgetType, IWidgetOptions } from './types/widgets'
export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
export {
findUsedSubgraphIds,
getDirectSubgraphIds,

File diff suppressed because it is too large Load Diff

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "تصفح القوالب"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "إضافة خطوة تحرير النموذج"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "حذف العناصر المحددة"
},

View File

@@ -272,6 +272,7 @@
"category": "الفئة",
"choose_file_to_upload": "اختر ملفاً للرفع",
"clear": "مسح",
"clearAll": "مسح الكل",
"clearFilters": "مسح الفلاتر",
"close": "إغلاق",
"color": "اللون",
@@ -327,6 +328,8 @@
"installed": "مثبت",
"installing": "جارٍ التثبيت",
"interrupted": "تمت المقاطعة",
"itemSelected": "تم تحديد عنصر واحد",
"itemsSelected": "تم تحديد {selectedCount} عناصر",
"keybinding": "اختصار لوحة المفاتيح",
"keybindingAlreadyExists": "الاختصار موجود بالفعل في",
"learnMore": "اعرف المزيد",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "حول ComfyUI",
"Add Edit Model Step": "إضافة خطوة تعديل النموذج",
"Bottom Panel": "لوحة سفلية",
"Browse Templates": "تصفح القوالب",
"Bypass/Unbypass Selected Nodes": "تجاوز/إلغاء تجاوز العقد المحددة",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Browse Templates"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Add Edit Model Step"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Delete Selected Items"
},

View File

@@ -137,8 +137,11 @@
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear",
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"startRecording": "Start Recording",
@@ -958,7 +961,6 @@
"Restart": "Restart",
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
"Browse Templates": "Browse Templates",
"Add Edit Model Step": "Add Edit Model Step",
"Delete Selected Items": "Delete Selected Items",
"Zoom to fit": "Zoom to fit",
"Move Selected Nodes Down": "Move Selected Nodes Down",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Explorar plantillas"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Agregar paso de edición de modelo"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Eliminar elementos seleccionados"
},

View File

@@ -272,6 +272,7 @@
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"clearAll": "Borrar todo",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"color": "Color",
@@ -327,6 +328,8 @@
"installed": "Instalado",
"installing": "Instalando",
"interrupted": "Interrumpido",
"itemSelected": "{selectedCount} elemento seleccionado",
"itemsSelected": "{selectedCount} elementos seleccionados",
"keybinding": "Combinación de teclas",
"keybindingAlreadyExists": "La combinación de teclas ya existe en",
"learnMore": "Aprende más",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "Acerca de ComfyUI",
"Add Edit Model Step": "Agregar paso de edición de modelo",
"Bottom Panel": "Panel inferior",
"Browse Templates": "Explorar plantillas",
"Bypass/Unbypass Selected Nodes": "Evitar/No evitar nodos seleccionados",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Parcourir les modèles"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Ajouter/Modifier une étape de modèle"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Supprimer les éléments sélectionnés"
},

View File

@@ -272,6 +272,7 @@
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
"clearAll": "Tout effacer",
"clearFilters": "Effacer les filtres",
"close": "Fermer",
"color": "Couleur",
@@ -327,6 +328,8 @@
"installed": "Installé",
"installing": "Installation",
"interrupted": "Interrompu",
"itemSelected": "{selectedCount} élément sélectionné",
"itemsSelected": "{selectedCount} éléments sélectionnés",
"keybinding": "Raccourci clavier",
"keybindingAlreadyExists": "Le raccourci clavier existe déjà",
"learnMore": "En savoir plus",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "À propos de ComfyUI",
"Add Edit Model Step": "Ajouter une étape dédition de modèle",
"Bottom Panel": "Panneau inférieur",
"Browse Templates": "Parcourir les modèles",
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "テンプレートを参照"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "編集モデルステップを追加"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "選択したアイテムを削除"
},

View File

@@ -272,6 +272,7 @@
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"clearAll": "すべてクリア",
"clearFilters": "フィルターをクリア",
"close": "閉じる",
"color": "色",
@@ -327,6 +328,8 @@
"installed": "インストール済み",
"installing": "インストール中",
"interrupted": "中断されました",
"itemSelected": "{selectedCount}件選択済み",
"itemsSelected": "{selectedCount}件選択済み",
"keybinding": "キーバインディング",
"keybindingAlreadyExists": "このキー割り当てはすでに存在します",
"learnMore": "詳細を学ぶ",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "ComfyUIについて",
"Add Edit Model Step": "モデル編集ステップを追加",
"Bottom Panel": "下部パネル",
"Browse Templates": "テンプレートを参照",
"Bypass/Unbypass Selected Nodes": "選択したノードのバイパス/バイパス解除",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "템플릿 탐색"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "모델 편집 단계 추가"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "선택한 항목 삭제"
},

View File

@@ -272,6 +272,7 @@
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"clearAll": "모두 지우기",
"clearFilters": "필터 지우기",
"close": "닫기",
"color": "색상",
@@ -327,6 +328,8 @@
"installed": "설치됨",
"installing": "설치 중",
"interrupted": "중단됨",
"itemSelected": "{selectedCount}개 선택됨",
"itemsSelected": "{selectedCount}개 선택됨",
"keybinding": "키 바인딩",
"keybindingAlreadyExists": "단축키가 이미 존재합니다",
"learnMore": "더 알아보기",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "ComfyUI에 대하여",
"Add Edit Model Step": "모델 편집 단계 추가",
"Bottom Panel": "하단 패널",
"Browse Templates": "템플릿 탐색",
"Bypass/Unbypass Selected Nodes": "선택한 노드 우회/우회 해제",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Просмотр шаблонов"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Добавить или изменить шаг модели"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Удалить выбранные элементы"
},

View File

@@ -272,6 +272,7 @@
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"clearAll": "Очистить всё",
"clearFilters": "Сбросить фильтры",
"close": "Закрыть",
"color": "Цвет",
@@ -327,6 +328,8 @@
"installed": "Установлено",
"installing": "Установка",
"interrupted": "Прервано",
"itemSelected": "Выбран {selectedCount} элемент",
"itemsSelected": "Выбрано {selectedCount} элементов",
"keybinding": "Привязка клавиш",
"keybindingAlreadyExists": "Горячая клавиша уже существует",
"learnMore": "Узнать больше",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "О ComfyUI",
"Add Edit Model Step": "Добавить или изменить шаг модели",
"Bottom Panel": "Нижняя панель",
"Browse Templates": "Просмотреть шаблоны",
"Bypass/Unbypass Selected Nodes": "Обойти/восстановить выбранные ноды",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "瀏覽範本"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "新增編輯模型步驟"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "刪除選取項目"
},

View File

@@ -272,6 +272,7 @@
"category": "分類",
"choose_file_to_upload": "選擇要上傳的檔案",
"clear": "清除",
"clearAll": "全部清除",
"clearFilters": "清除篩選",
"close": "關閉",
"color": "顏色",
@@ -327,6 +328,8 @@
"installed": "已安裝",
"installing": "安裝中",
"interrupted": "已中斷",
"itemSelected": "已選取 {selectedCount} 項",
"itemsSelected": "已選取 {selectedCount} 項",
"keybinding": "快捷鍵",
"keybindingAlreadyExists": "快捷鍵已存在於",
"learnMore": "了解更多",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "關於 ComfyUI",
"Add Edit Model Step": "新增編輯模型步驟",
"Bottom Panel": "底部面板",
"Browse Templates": "瀏覽範本",
"Bypass/Unbypass Selected Nodes": "繞過/取消繞過選取節點",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "浏览模板"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "添加编辑模型步骤"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "删除选定的项目"
},

View File

@@ -272,6 +272,7 @@
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"clearAll": "全部清除",
"clearFilters": "清除筛选",
"close": "关闭",
"color": "颜色",
@@ -327,6 +328,8 @@
"installed": "已安装",
"installing": "正在安装",
"interrupted": "已中断",
"itemSelected": "已选择 {selectedCount} 项",
"itemsSelected": "已选择 {selectedCount} 项",
"keybinding": "按键绑定",
"keybindingAlreadyExists": "快捷键已存在",
"learnMore": "了解更多",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "关于ComfyUI",
"Add Edit Model Step": "添加编辑模型步骤",
"Bottom Panel": "底部面板",
"Browse Templates": "浏览模板",
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",

View File

@@ -232,7 +232,13 @@ export const zComfyNodeDef = z.object({
* Comfy Org account.
* https://docs.comfy.org/tutorials/api-nodes/overview
*/
api_node: z.boolean().optional()
api_node: z.boolean().optional(),
/**
* Specifies the order of inputs for each input category.
* Used to ensure consistent widget ordering regardless of JSON serialization.
* Keys are 'required', 'optional', etc., values are arrays of input names.
*/
input_order: z.record(z.array(z.string())).optional()
})
// `/object_info`

View File

@@ -1,693 +0,0 @@
import _ from 'es-toolkit/compat'
import {
type INodeOutputSlot,
type LGraph,
type LGraphNode,
LLink,
LiteGraph,
type Point
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { parseFilePath } from '@/utils/formatUtil'
import { app } from './app'
const fluxKontextGroupNode = {
nodes: [
{
id: -1,
type: 'Reroute',
pos: [2354.87890625, -127.23468780517578],
size: [75, 26],
flags: {},
order: 20,
mode: 0,
inputs: [{ name: '', type: '*', link: null }],
outputs: [{ name: '', type: '*', links: null }],
properties: { showOutputText: false, horizontal: false },
index: 0
},
{
id: -1,
type: 'ReferenceLatent',
pos: [2730, -220],
size: [197.712890625, 46],
flags: {},
order: 22,
mode: 0,
inputs: [
{
localized_name: 'conditioning',
name: 'conditioning',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'latent',
name: 'latent',
shape: 7,
type: 'LATENT',
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
links: []
}
],
properties: {
'Node name for S&R': 'ReferenceLatent',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
index: 1
},
{
id: -1,
type: 'VAEDecode',
pos: [3270, -110],
size: [210, 46],
flags: {},
order: 25,
mode: 0,
inputs: [
{
localized_name: 'samples',
name: 'samples',
type: 'LATENT',
link: null
},
{
localized_name: 'vae',
name: 'vae',
type: 'VAE',
link: null
}
],
outputs: [
{
localized_name: 'IMAGE',
name: 'IMAGE',
type: 'IMAGE',
slot_index: 0,
links: []
}
],
properties: {
'Node name for S&R': 'VAEDecode',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
index: 2
},
{
id: -1,
type: 'KSampler',
pos: [2930, -110],
size: [315, 262],
flags: {},
order: 24,
mode: 0,
inputs: [
{
localized_name: 'model',
name: 'model',
type: 'MODEL',
link: null
},
{
localized_name: 'positive',
name: 'positive',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'negative',
name: 'negative',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'latent_image',
name: 'latent_image',
type: 'LATENT',
link: null
},
{
localized_name: 'seed',
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: null
},
{
localized_name: 'steps',
name: 'steps',
type: 'INT',
widget: { name: 'steps' },
link: null
},
{
localized_name: 'cfg',
name: 'cfg',
type: 'FLOAT',
widget: { name: 'cfg' },
link: null
},
{
localized_name: 'sampler_name',
name: 'sampler_name',
type: 'COMBO',
widget: { name: 'sampler_name' },
link: null
},
{
localized_name: 'scheduler',
name: 'scheduler',
type: 'COMBO',
widget: { name: 'scheduler' },
link: null
},
{
localized_name: 'denoise',
name: 'denoise',
type: 'FLOAT',
widget: { name: 'denoise' },
link: null
}
],
outputs: [
{
localized_name: 'LATENT',
name: 'LATENT',
type: 'LATENT',
slot_index: 0,
links: []
}
],
properties: {
'Node name for S&R': 'KSampler',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [972054013131369, 'fixed', 20, 1, 'euler', 'simple', 1],
index: 3
},
{
id: -1,
type: 'FluxGuidance',
pos: [2940, -220],
size: [211.60000610351562, 58],
flags: {},
order: 23,
mode: 0,
inputs: [
{
localized_name: 'conditioning',
name: 'conditioning',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'guidance',
name: 'guidance',
type: 'FLOAT',
widget: { name: 'guidance' },
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
slot_index: 0,
links: []
}
],
properties: {
'Node name for S&R': 'FluxGuidance',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [2.5],
index: 4
},
{
id: -1,
type: 'SaveImage',
pos: [3490, -110],
size: [985.3012084960938, 1060.3828125],
flags: {},
order: 26,
mode: 0,
inputs: [
{
localized_name: 'images',
name: 'images',
type: 'IMAGE',
link: null
},
{
localized_name: 'filename_prefix',
name: 'filename_prefix',
type: 'STRING',
widget: { name: 'filename_prefix' },
link: null
}
],
outputs: [],
properties: { cnr_id: 'comfy-core', ver: '0.3.38' },
widgets_values: ['ComfyUI'],
index: 5
},
{
id: -1,
type: 'CLIPTextEncode',
pos: [2500, -110],
size: [422.84503173828125, 164.31304931640625],
flags: {},
order: 12,
mode: 0,
inputs: [
{
localized_name: 'clip',
name: 'clip',
type: 'CLIP',
link: null
},
{
localized_name: 'text',
name: 'text',
type: 'STRING',
widget: { name: 'text' },
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
slot_index: 0,
links: []
}
],
title: 'CLIP Text Encode (Positive Prompt)',
properties: {
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: ['there is a bright light'],
color: '#232',
bgcolor: '#353',
index: 6
},
{
id: -1,
type: 'CLIPTextEncode',
pos: [2504.1435546875, 97.9598617553711],
size: [422.84503173828125, 164.31304931640625],
flags: { collapsed: true },
order: 13,
mode: 0,
inputs: [
{
localized_name: 'clip',
name: 'clip',
type: 'CLIP',
link: null
},
{
localized_name: 'text',
name: 'text',
type: 'STRING',
widget: { name: 'text' },
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
slot_index: 0,
links: []
}
],
title: 'CLIP Text Encode (Negative Prompt)',
properties: {
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [''],
color: '#322',
bgcolor: '#533',
index: 7
},
{
id: -1,
type: 'UNETLoader',
pos: [2630, -370],
size: [270, 82],
flags: {},
order: 6,
mode: 0,
inputs: [
{
localized_name: 'unet_name',
name: 'unet_name',
type: 'COMBO',
widget: { name: 'unet_name' },
link: null
},
{
localized_name: 'weight_dtype',
name: 'weight_dtype',
type: 'COMBO',
widget: { name: 'weight_dtype' },
link: null
}
],
outputs: [
{
localized_name: 'MODEL',
name: 'MODEL',
type: 'MODEL',
links: []
}
],
properties: {
'Node name for S&R': 'UNETLoader',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: ['flux1-kontext-dev.safetensors', 'default'],
color: '#223',
bgcolor: '#335',
index: 8
},
{
id: -1,
type: 'DualCLIPLoader',
pos: [2100, -290],
size: [337.76861572265625, 130],
flags: {},
order: 8,
mode: 0,
inputs: [
{
localized_name: 'clip_name1',
name: 'clip_name1',
type: 'COMBO',
widget: { name: 'clip_name1' },
link: null
},
{
localized_name: 'clip_name2',
name: 'clip_name2',
type: 'COMBO',
widget: { name: 'clip_name2' },
link: null
},
{
localized_name: 'type',
name: 'type',
type: 'COMBO',
widget: { name: 'type' },
link: null
},
{
localized_name: 'device',
name: 'device',
shape: 7,
type: 'COMBO',
widget: { name: 'device' },
link: null
}
],
outputs: [
{
localized_name: 'CLIP',
name: 'CLIP',
type: 'CLIP',
links: []
}
],
properties: {
'Node name for S&R': 'DualCLIPLoader',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [
'clip_l.safetensors',
't5xxl_fp8_e4m3fn_scaled.safetensors',
'flux',
'default'
],
color: '#223',
bgcolor: '#335',
index: 9
},
{
id: -1,
type: 'VAELoader',
pos: [2960, -370],
size: [270, 58],
flags: {},
order: 7,
mode: 0,
inputs: [
{
localized_name: 'vae_name',
name: 'vae_name',
type: 'COMBO',
widget: { name: 'vae_name' },
link: null
}
],
outputs: [
{
localized_name: 'VAE',
name: 'VAE',
type: 'VAE',
links: []
}
],
properties: {
'Node name for S&R': 'VAELoader',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: ['ae.safetensors'],
color: '#223',
bgcolor: '#335',
index: 10
}
],
links: [
[6, 0, 1, 0, 72, 'CONDITIONING'],
[0, 0, 1, 1, 66, '*'],
[3, 0, 2, 0, 69, 'LATENT'],
[10, 0, 2, 1, 76, 'VAE'],
[8, 0, 3, 0, 74, 'MODEL'],
[4, 0, 3, 1, 70, 'CONDITIONING'],
[7, 0, 3, 2, 73, 'CONDITIONING'],
[0, 0, 3, 3, 66, '*'],
[1, 0, 4, 0, 67, 'CONDITIONING'],
[2, 0, 5, 0, 68, 'IMAGE'],
[9, 0, 6, 0, 75, 'CLIP'],
[9, 0, 7, 0, 75, 'CLIP']
],
external: [],
config: {
'0': {},
'1': {},
'2': { output: { '0': { visible: true } } },
'3': {
output: { '0': { visible: true } },
input: {
denoise: { visible: false },
cfg: { visible: false }
}
},
'4': {},
'5': {},
'6': {},
'7': { input: { text: { visible: false } } },
'8': { input: { weight_dtype: { visible: false } } },
'9': { input: { type: { visible: false }, device: { visible: false } } },
'10': {}
}
}
export async function ensureGraphHasFluxKontextGroupNode(
graph: LGraph & { extra: { groupNodes?: Record<string, any> } }
) {
graph.extra ??= {}
graph.extra.groupNodes ??= {}
if (graph.extra.groupNodes['FLUX.1 Kontext Image Edit']) return
graph.extra.groupNodes['FLUX.1 Kontext Image Edit'] =
structuredClone(fluxKontextGroupNode)
// Lazy import to avoid circular dependency issues
const { GroupNodeConfig } = await import('@/extensions/core/groupNode')
await GroupNodeConfig.registerFromWorkflow(
{
'FLUX.1 Kontext Image Edit':
graph.extra.groupNodes['FLUX.1 Kontext Image Edit']
},
[]
)
}
export async function addFluxKontextGroupNode(fromNode: LGraphNode) {
const { canvas } = app
const { graph } = canvas
if (!graph) throw new TypeError('Graph is not initialized')
await ensureGraphHasFluxKontextGroupNode(graph)
const node = LiteGraph.createNode('workflow>FLUX.1 Kontext Image Edit')
if (!node) throw new TypeError('Failed to create node')
const pos = getPosToRightOfNode(fromNode)
graph.add(node)
node.pos = pos
app.canvas.processSelect(node, undefined)
connectPreviousLatent(fromNode, node)
const symb = Object.getOwnPropertySymbols(node)[0]
// @ts-expect-error It's there -- promise.
node[symb].populateWidgets()
setWidgetValues(node)
}
function setWidgetValues(node: LGraphNode) {
const seedInput = node.widgets?.find((x) => x.name === 'seed')
if (!seedInput) throw new TypeError('Seed input not found')
seedInput.value = Math.floor(Math.random() * 1_125_899_906_842_624)
const firstClip = node.widgets?.find((x) => x.name === 'clip_name1')
setPreferredValue('t5xxl_fp8_e4m3fn_scaled.safetensors', 't5xxl', firstClip)
const secondClip = node.widgets?.find((x) => x.name === 'clip_name2')
setPreferredValue('clip_l.safetensors', 'clip_l', secondClip)
const unet = node.widgets?.find((x) => x.name === 'unet_name')
setPreferredValue('flux1-dev-kontext_fp8_scaled.safetensors', 'kontext', unet)
const vae = node.widgets?.find((x) => x.name === 'vae_name')
setPreferredValue('ae.safetensors', 'ae.s', vae)
}
function setPreferredValue(
preferred: string,
match: string,
widget: IBaseWidget | undefined
): void {
if (!widget) throw new TypeError('Widget not found')
const { values } = widget.options
if (!Array.isArray(values)) return
// Match against filename portion only
const mapped = values.map((x) => parseFilePath(x).filename)
const value =
mapped.find((x) => x === preferred) ??
mapped.find((x) => x.includes?.(match))
widget.value = value ?? preferred
}
function getPosToRightOfNode(fromNode: LGraphNode) {
const nodes = app.canvas.graph?.nodes
if (!nodes) throw new TypeError('Could not get graph nodes')
const pos = [
fromNode.pos[0] + fromNode.size[0] + 100,
fromNode.pos[1]
] satisfies Point
while (nodes.find((x) => isPointTooClose(x.pos, pos))) {
pos[0] += 20
pos[1] += 20
}
return pos
}
function connectPreviousLatent(fromNode: LGraphNode, toEditNode: LGraphNode) {
const { canvas } = app
const { graph } = canvas
if (!graph) throw new TypeError('Graph is not initialized')
const l = findNearestOutputOfType([fromNode], 'LATENT')
if (!l) {
const imageOutput = findNearestOutputOfType([fromNode], 'IMAGE')
if (!imageOutput) throw new TypeError('No image output found')
const vaeEncode = LiteGraph.createNode('VAEEncode')
if (!vaeEncode) throw new TypeError('Failed to create node')
const { node: imageNode, index: imageIndex } = imageOutput
graph.add(vaeEncode)
vaeEncode.pos = getPosToRightOfNode(fromNode)
vaeEncode.pos[1] -= 200
vaeEncode.connect(0, toEditNode, 0)
imageNode.connect(imageIndex, vaeEncode, 0)
return
}
const { node, index } = l
node.connect(index, toEditNode, 0)
}
function getInputNodes(node: LGraphNode): LGraphNode[] {
return node.inputs
.map((x) => LLink.resolve(x.link, app.graph)?.outputNode)
.filter((x) => !!x)
}
function getOutputOfType(
node: LGraphNode,
type: string
): {
output: INodeOutputSlot
index: number
} {
const index = node.outputs.findIndex((x) => x.type === type)
const output = node.outputs[index]
return { output, index }
}
function findNearestOutputOfType(
nodes: Iterable<LGraphNode>,
type: string = 'LATENT',
depth: number = 0
): { node: LGraphNode; index: number } | undefined {
for (const node of nodes) {
const { output, index } = getOutputOfType(node, type)
if (output) return { node, index }
}
if (depth < 3) {
const closestNodes = new Set([...nodes].flatMap((x) => getInputNodes(x)))
const res = findNearestOutputOfType(closestNodes, type, depth + 1)
if (res) return res
}
}
function isPointTooClose(a: Point, b: Point, precision: number = 5) {
return Math.abs(a[0] - b[0]) < precision && Math.abs(a[1] - b[1]) < precision
}

View File

@@ -50,6 +50,7 @@ import {
isVideoNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
import { getOrderedInputSpecs } from '@/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService'
@@ -248,9 +249,14 @@ export const useLitegraphService = () => {
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const inputSpec of Object.values(inputs))
// Use input_order if available to ensure consistent widget ordering
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
// Create sockets and widgets in the determined order
for (const inputSpec of orderedInputSpecs)
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
for (const inputSpec of orderedInputSpecs)
this.#addInputWidget(inputSpec)
}
@@ -508,9 +514,14 @@ export const useLitegraphService = () => {
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const inputSpec of Object.values(inputs))
// Use input_order if available to ensure consistent widget ordering
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
// Create sockets and widgets in the determined order
for (const inputSpec of orderedInputSpecs)
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
for (const inputSpec of orderedInputSpecs)
this.#addInputWidget(inputSpec)
}

View File

@@ -63,6 +63,10 @@ export class ComfyNodeDefImpl
* @deprecated Use `outputs[n].tooltip` instead
*/
readonly output_tooltips?: string[]
/**
* Order of inputs for each category (required, optional, hidden)
*/
readonly input_order?: Record<string, string[]>
// V2 fields
readonly inputs: Record<string, InputSpecV2>
@@ -130,6 +134,7 @@ export class ComfyNodeDefImpl
this.output_is_list = obj.output_is_list
this.output_name = obj.output_name
this.output_tooltips = obj.output_tooltips
this.input_order = obj.input_order
// Initialize V2 fields
const defV2 = transformNodeDefV1ToV2(obj)

View File

@@ -1,13 +1,14 @@
import type { HTMLAttributes } from 'vue'
export interface BaseButtonProps {
size?: 'sm' | 'md'
size?: 'fit-content' | 'sm' | 'md'
type?: 'primary' | 'secondary' | 'transparent'
class?: HTMLAttributes['class']
}
export const getButtonSizeClasses = (size: BaseButtonProps['size'] = 'md') => {
const sizeClasses = {
'fit-content': '',
sm: 'px-2 py-1.5 text-xs',
md: 'px-2.5 py-2 text-sm'
}
@@ -31,6 +32,7 @@ export const getIconButtonSizeClasses = (
size: BaseButtonProps['size'] = 'md'
) => {
const sizeClasses = {
'fit-content': 'w-auto h-auto',
sm: 'w-6 h-6 text-xs !rounded-md',
md: 'w-8 h-8 text-sm'
}

View File

@@ -0,0 +1,108 @@
import { TWidgetValue } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
/**
* Gets an ordered array of InputSpec objects based on input_order.
* This is designed to work with V2 format used by litegraphService.
*
* @param nodeDefImpl - The ComfyNodeDefImpl containing both V1 and V2 formats
* @param inputs - The V2 format inputs (flat Record<string, InputSpec>)
* @returns Array of InputSpec objects in the correct order
*/
export function getOrderedInputSpecs(
nodeDefImpl: ComfyNodeDefImpl,
inputs: Record<string, InputSpec>
): InputSpec[] {
const orderedInputSpecs: InputSpec[] = []
// If no input_order, return default Object.values order
if (!nodeDefImpl.input_order) {
return Object.values(inputs)
}
// Process required inputs in specified order
if (nodeDefImpl.input_order.required) {
for (const name of nodeDefImpl.input_order.required) {
const inputSpec = inputs[name]
if (inputSpec && !inputSpec.isOptional) {
orderedInputSpecs.push(inputSpec)
}
}
}
// Process optional inputs in specified order
if (nodeDefImpl.input_order.optional) {
for (const name of nodeDefImpl.input_order.optional) {
const inputSpec = inputs[name]
if (inputSpec && inputSpec.isOptional) {
orderedInputSpecs.push(inputSpec)
}
}
}
// Add any remaining inputs not specified in input_order
const processedNames = new Set(orderedInputSpecs.map((spec) => spec.name))
for (const inputSpec of Object.values(inputs)) {
if (!processedNames.has(inputSpec.name)) {
orderedInputSpecs.push(inputSpec)
}
}
return orderedInputSpecs
}
/**
* Reorders widget values based on the input_order to match expected widget order.
* This is used when widgets were created in a different order than input_order specifies.
*
* @param widgetValues - The current widget values array
* @param currentWidgetOrder - The current order of widget names
* @param inputOrder - The desired order from input_order
* @returns Reordered widget values array
*/
export function sortWidgetValuesByInputOrder(
widgetValues: TWidgetValue[],
currentWidgetOrder: string[],
inputOrder: string[]
): TWidgetValue[] {
if (!inputOrder || inputOrder.length === 0) {
return widgetValues
}
// Create a map of widget name to value
const valueMap = new Map<string, TWidgetValue>()
currentWidgetOrder.forEach((name, index) => {
if (index < widgetValues.length) {
valueMap.set(name, widgetValues[index])
}
})
// Reorder based on input_order
const reordered: TWidgetValue[] = []
const usedNames = new Set<string>()
// First, add values in the order specified by input_order
for (const name of inputOrder) {
if (valueMap.has(name)) {
reordered.push(valueMap.get(name))
usedNames.add(name)
}
}
// Then add any remaining values not in input_order
for (const [name, value] of valueMap.entries()) {
if (!usedNames.has(name)) {
reordered.push(value)
}
}
// If there are extra values not in the map, append them
if (widgetValues.length > currentWidgetOrder.length) {
for (let i = currentWidgetOrder.length; i < widgetValues.length; i++) {
reordered.push(widgetValues[i])
}
}
return reordered
}

View File

@@ -0,0 +1,162 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import { sortWidgetValuesByInputOrder } from '@/utils/nodeDefOrderingUtil'
describe('LGraphNode widget ordering', () => {
let node: LGraphNode
beforeEach(() => {
node = new LGraphNode('TestNode')
})
describe('configure with widgets_values', () => {
it('should apply widget values in correct order when widgets order matches input_order', () => {
// Create node with widgets
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('number', 'seed', 0, null, {})
node.addWidget('text', 'prompt', '', null, {})
// Configure with widget values
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345, 'test prompt']
}
node.configure(info)
// Check widget values are applied correctly
expect(node.widgets![0].value).toBe(30) // steps
expect(node.widgets![1].value).toBe(12345) // seed
expect(node.widgets![2].value).toBe('test prompt') // prompt
})
it('should handle mismatched widget order with input_order', () => {
// Simulate widgets created in wrong order (e.g., from unordered Object.entries)
// but widgets_values is in the correct order according to input_order
node.addWidget('number', 'seed', 0, null, {})
node.addWidget('text', 'prompt', '', null, {})
node.addWidget('number', 'steps', 20, null, {})
// Widget values are in input_order: [steps, seed, prompt]
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345, 'test prompt']
}
// This would apply values incorrectly without proper ordering
node.configure(info)
// Without fix, values would be applied in wrong order:
// seed (widget[0]) would get 30 (should be 12345)
// prompt (widget[1]) would get 12345 (should be 'test prompt')
// steps (widget[2]) would get 'test prompt' (should be 30)
// This test demonstrates the bug - values are applied in wrong order
expect(node.widgets![0].value).toBe(30) // seed gets steps value (WRONG)
expect(node.widgets![1].value).toBe(12345) // prompt gets seed value (WRONG)
expect(node.widgets![2].value).toBe('test prompt') // steps gets prompt value (WRONG)
})
it('should skip widgets with serialize: false', () => {
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('button', 'action', 'Click', null, {})
node.widgets![1].serialize = false // button should not serialize
node.addWidget('number', 'seed', 0, null, {})
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345] // Only serializable widgets
}
node.configure(info)
expect(node.widgets![0].value).toBe(30) // steps
expect(node.widgets![1].value).toBe('Click') // button unchanged
expect(node.widgets![2].value).toBe(12345) // seed
})
})
})
describe('sortWidgetValuesByInputOrder', () => {
it('should reorder widget values based on input_order', () => {
const inputOrder = ['steps', 'seed', 'prompt']
const currentWidgetOrder = ['seed', 'prompt', 'steps']
const widgetValues = [12345, 'test prompt', 30]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should reorder to match input_order: [steps, seed, prompt]
expect(reordered).toEqual([30, 12345, 'test prompt'])
})
it('should handle widgets not in input_order', () => {
const inputOrder = ['steps', 'seed']
const currentWidgetOrder = ['seed', 'prompt', 'steps', 'cfg']
const widgetValues = [12345, 'test prompt', 30, 7.5]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should put ordered items first, then unordered
expect(reordered).toEqual([30, 12345, 'test prompt', 7.5])
})
it('should handle empty input_order', () => {
const inputOrder: string[] = []
const currentWidgetOrder = ['seed', 'prompt', 'steps']
const widgetValues = [12345, 'test prompt', 30]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should return values unchanged
expect(reordered).toEqual([12345, 'test prompt', 30])
})
it('should handle mismatched array lengths', () => {
const inputOrder = ['steps', 'seed', 'prompt']
const currentWidgetOrder = ['seed', 'prompt']
const widgetValues = [12345, 'test prompt', 30] // Extra value
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should handle gracefully, keeping extra values at the end
// Since 'steps' is not in currentWidgetOrder, it won't be reordered
// Only 'seed' and 'prompt' will be reordered based on input_order
expect(reordered).toEqual([12345, 'test prompt', 30])
})
})

View File

@@ -0,0 +1,274 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import {
getOrderedInputSpecs,
sortWidgetValuesByInputOrder
} from '@/utils/nodeDefOrderingUtil'
describe('nodeDefOrderingUtil', () => {
describe('getOrderedInputSpecs', () => {
it('should maintain order when no input_order is specified', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }]
}
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
// Should maintain Object.values order when no input_order
const names = result.map((spec) => spec.name)
expect(names).toEqual(['image', 'seed', 'steps'])
})
it('should sort inputs according to input_order', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }]
}
},
input_order: {
required: ['steps', 'seed', 'image']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
const names = result.map((spec) => spec.name)
expect(names).toEqual(['steps', 'seed', 'image'])
})
it('should handle missing inputs in input_order gracefully', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }]
}
},
input_order: {
required: ['steps', 'nonexistent', 'seed']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
// Should skip nonexistent and include image at the end
const names = result.map((spec) => spec.name)
expect(names).toEqual(['steps', 'seed', 'image'])
})
it('should handle inputs not in input_order', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }],
cfg: ['FLOAT', { default: 7.0 }]
}
},
input_order: {
required: ['steps', 'seed']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
// Should have ordered ones first, then remaining
const names = result.map((spec) => spec.name)
expect(names).toEqual(['steps', 'seed', 'image', 'cfg'])
})
it('should handle both required and optional inputs', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }]
},
optional: {
mask: ['MASK', {}],
strength: ['FLOAT', { default: 1.0 }]
}
},
input_order: {
required: ['seed', 'image'],
optional: ['strength', 'mask']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
const names = result.map((spec) => spec.name)
const optionalFlags = result.map((spec) => spec.isOptional)
expect(names).toEqual(['seed', 'image', 'strength', 'mask'])
expect(optionalFlags).toEqual([false, false, true, true])
})
it('should work with real KSampler node example', () => {
// Simulating different backend orderings
const kSamplerDefBackendA: ComfyNodeDef = {
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling',
python_module: 'nodes',
description: 'KSampler node',
output_node: false,
input: {
required: {
// Alphabetical order from backend A
cfg: ['FLOAT', { default: 8, min: 0, max: 100 }],
denoise: ['FLOAT', { default: 1, min: 0, max: 1 }],
latent_image: ['LATENT', {}],
model: ['MODEL', {}],
negative: ['CONDITIONING', {}],
positive: ['CONDITIONING', {}],
sampler_name: [['euler', 'euler_cfg_pp'], {}],
scheduler: [['simple', 'sgm_uniform'], {}],
seed: ['INT', { default: 0, min: 0, max: Number.MAX_SAFE_INTEGER }],
steps: ['INT', { default: 20, min: 1, max: 10000 }]
}
},
input_order: {
required: [
'model',
'seed',
'steps',
'cfg',
'sampler_name',
'scheduler',
'positive',
'negative',
'latent_image',
'denoise'
]
}
}
const nodeDefImpl = new ComfyNodeDefImpl(kSamplerDefBackendA)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
const names = result.map((spec) => spec.name)
// Should follow input_order, not alphabetical
expect(names).toEqual([
'model',
'seed',
'steps',
'cfg',
'sampler_name',
'scheduler',
'positive',
'negative',
'latent_image',
'denoise'
])
})
})
describe('sortWidgetValuesByInputOrder', () => {
it('should reorder widget values to match input_order', () => {
const widgetValues = [0, 'model_ref', 5, 1]
const currentWidgetOrder = ['momentum', 'model', 'norm_threshold', 'eta']
const correctOrder = ['model', 'eta', 'norm_threshold', 'momentum']
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
correctOrder
)
expect(result).toEqual(['model_ref', 1, 5, 0])
})
it('should handle missing widgets in input_order', () => {
const widgetValues = [1, 2, 3, 4]
const currentWidgetOrder = ['a', 'b', 'c', 'd']
const inputOrder = ['b', 'd'] // Only partial order
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// b=2, d=4, then a=1, c=3
expect(result).toEqual([2, 4, 1, 3])
})
it('should handle extra widget values', () => {
const widgetValues = [1, 2, 3, 4, 5] // More values than names
const currentWidgetOrder = ['a', 'b', 'c']
const inputOrder = ['c', 'a', 'b']
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// c=3, a=1, b=2, then extras 4, 5
expect(result).toEqual([3, 1, 2, 4, 5])
})
it('should return unchanged when no input_order', () => {
const widgetValues = [1, 2, 3]
const currentWidgetOrder = ['a', 'b', 'c']
const inputOrder: string[] = []
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
expect(result).toEqual([1, 2, 3])
})
})
})