mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 23:20:07 +00:00
Merge remote-tracking branch 'origin/main' into sno-storybook--settings-panel
This commit is contained in:
1
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Bug Report
|
||||
description: 'Report something that is not working correctly'
|
||||
title: '[Bug]: '
|
||||
labels: ['Potential Bug']
|
||||
type: Bug
|
||||
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
3
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Feature Request
|
||||
description: Report a problem or limitation you're experiencing
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
labels: []
|
||||
type: Feature
|
||||
|
||||
body:
|
||||
|
||||
154
.github/workflows/pr-checks.yml
vendored
154
.github/workflows/pr-checks.yml
vendored
@@ -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
|
||||
149
.github/workflows/pr-comment.yml
vendored
149
.github/workflows/pr-comment.yml
vendored
@@ -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'
|
||||
});
|
||||
}
|
||||
252
.github/workflows/test-ui.yaml
vendored
252
.github/workflows/test-ui.yaml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
src/types/comfyRegistryTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
14
package.json
14
package.json
@@ -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",
|
||||
|
||||
@@ -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 |
145
src/components/button/IconButton.stories.ts
Normal file
145
src/components/button/IconButton.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
35
src/components/button/IconGroup.stories.ts
Normal file
35
src/components/button/IconGroup.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
221
src/components/button/IconTextButton.stories.ts
Normal file
221
src/components/button/IconTextButton.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
50
src/components/button/MoreButton.stories.ts
Normal file
50
src/components/button/MoreButton.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
83
src/components/button/TextButton.stories.ts
Normal file
83
src/components/button/TextButton.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
665
src/components/card/Card.stories.ts
Normal file
665
src/components/card/Card.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
36
src/components/chip/SquareChip.stories.ts
Normal file
36
src/components/chip/SquareChip.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
151
src/components/input/MultiSelect.stories.ts
Normal file
151
src/components/input/MultiSelect.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
33
src/components/input/SearchBox.stories.ts
Normal file
33
src/components/input/SearchBox.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
116
src/components/input/SingleSelect.stories.ts
Normal file
116
src/components/input/SingleSelect.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
556
src/components/widget/layout/BaseWidget.stories.ts
Normal file
556
src/components/widget/layout/BaseWidget.stories.ts
Normal 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
|
||||
}
|
||||
}
|
||||
138
src/components/widget/nav/Navigation.stories.ts
Normal file
138
src/components/widget/nav/Navigation.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1925,6 +1925,7 @@ export class LGraphNode
|
||||
}
|
||||
}
|
||||
|
||||
widget.onRemove?.()
|
||||
this.widgets.splice(widgetIndex, 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
1220
src/lib/litegraph/test/CanvasPointer.deviceDetection.test.ts
Normal file
1220
src/lib/litegraph/test/CanvasPointer.deviceDetection.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "تصفح القوالب"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "إضافة خطوة تحرير النموذج"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "حذف العناصر المحددة"
|
||||
},
|
||||
|
||||
@@ -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": "تجاوز/إلغاء تجاوز العقد المحددة",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Browse Templates"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Add Edit Model Step"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Delete Selected Items"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "テンプレートを参照"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "編集モデルステップを追加"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "選択したアイテムを削除"
|
||||
},
|
||||
|
||||
@@ -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": "選択したノードのバイパス/バイパス解除",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "템플릿 탐색"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "모델 편집 단계 추가"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "선택한 항목 삭제"
|
||||
},
|
||||
|
||||
@@ -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": "선택한 노드 우회/우회 해제",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Просмотр шаблонов"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Добавить или изменить шаг модели"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Удалить выбранные элементы"
|
||||
},
|
||||
|
||||
@@ -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": "Обойти/восстановить выбранные ноды",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "瀏覽範本"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "新增編輯模型步驟"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "刪除選取項目"
|
||||
},
|
||||
|
||||
@@ -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": "繞過/取消繞過選取節點",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "浏览模板"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "添加编辑模型步骤"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "删除选定的项目"
|
||||
},
|
||||
|
||||
@@ -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": "忽略/取消忽略选定节点",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
108
src/utils/nodeDefOrderingUtil.ts
Normal file
108
src/utils/nodeDefOrderingUtil.ts
Normal 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
|
||||
}
|
||||
162
tests-ui/tests/litegraph/core/LGraphNode.widgetOrder.test.ts
Normal file
162
tests-ui/tests/litegraph/core/LGraphNode.widgetOrder.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
274
tests-ui/tests/utils/nodeDefOrderingUtil.test.ts
Normal file
274
tests-ui/tests/utils/nodeDefOrderingUtil.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user