Merge remote-tracking branch 'origin/main' into bl-update-slots

This commit is contained in:
Benjamin Lu
2025-09-11 16:36:45 -07:00
115 changed files with 3449 additions and 1709 deletions

View File

@@ -33,3 +33,4 @@ DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager. # Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8 ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579 ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579

View File

@@ -47,6 +47,7 @@ jobs:
needs: wait-for-ci needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true' if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -69,19 +70,17 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review - name: Run Claude PR Review
uses: anthropics/claude-code-action@main uses: anthropics/claude-code-action@v1.0.6
with: with:
label_trigger: "claude-review" label_trigger: "claude-review"
direct_prompt: | prompt: |
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly. Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
CRITICAL: You must post individual inline comments using the gh api commands shown in the file. CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
DO NOT create a summary comment. DO NOT create a summary comment.
Each issue must be posted as a separate inline comment on the specific line of code. Each issue must be posted as a separate inline comment on the specific line of code.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
max_turns: 256 claude_args: "--max-turns 256 --allowedTools 'Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch'"
timeout_minutes: 30
allowed_tools: "Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch"
env: env:
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: PR Playwright Deploy and Comment name: PR Playwright Deploy (Forks)
on: on:
workflow_run: workflow_run:
@@ -9,272 +9,84 @@ env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p' DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs: jobs:
deploy-reports: deploy-and-comment-forked-pr:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed' if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
permissions: permissions:
pull-requests: write
actions: read actions: read
strategy:
fail-fast: false
matrix:
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
steps: steps:
- name: Get PR info - name: Log workflow trigger info
id: pr-info run: |
echo "Repository: ${{ github.repository }}"
echo "Event: ${{ github.event.workflow_run.event }}"
echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name || 'null' }}"
echo "Base repo: ${{ github.event.workflow_run.repository.full_name || 'null' }}"
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v4
- name: Get PR Number
id: pr
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
script: | script: |
const { data: pullRequests } = await github.rest.pulls.list({ const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
state: 'open', state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
}); });
if (pullRequests.length === 0) { const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
console.log('No open PR found for this branch');
return { number: null, sanitized_branch: null }; if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
} }
const pr = pullRequests[0]; console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
const branchName = context.payload.workflow_run.head_branch; return pr.number;
const sanitizedBranch = branchName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '');
return {
number: pr.number,
sanitized_branch: sanitizedBranch
};
- name: Set project name - name: Handle Test Start
if: fromJSON(steps.pr-info.outputs.result).number != null if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
id: project-name env:
GITHUB_TOKEN: ${{ github.token }}
run: | run: |
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
else "${{ steps.pr.outputs.result }}" \
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT "${{ github.event.workflow_run.head_branch }}" \
fi "starting" \
echo "branch=${{ fromJSON(steps.pr-info.outputs.result).sanitized_branch }}" >> $GITHUB_OUTPUT "$(date -u '${{ env.DATE_FORMAT }}')"
- name: Download playwright report - name: Download and Deploy Reports
if: fromJSON(steps.pr-info.outputs.result).number != null if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }} run-id: ${{ github.event.workflow_run.id }}
name: playwright-report-${{ matrix.browser }} pattern: playwright-report-*
path: playwright-report path: reports
- name: Install Wrangler
if: fromJSON(steps.pr-info.outputs.result).number != null
run: npm install -g wrangler
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
if: fromJSON(steps.pr-info.outputs.result).number != null
id: cloudflare-deploy
continue-on-error: true
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 - name: Handle Test Completion
RETRY_COUNT=$((RETRY_COUNT + 1)) if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
if npx wrangler pages deploy 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: env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
comment-tests-starting:
runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'requested'
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body for start
if: steps.pr.outputs.result != 'null'
id: comment-body-start
run: | run: |
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md # Rename merged report if exists
echo "## 🎭 Playwright Test Results" >> comment.md [ -d "reports/playwright-report-chromium-merged" ] && \
echo "" >> comment.md mv reports/playwright-report-chromium-merged reports/playwright-report-chromium
echo "<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Tests are starting...** " >> comment.md
echo "" >> comment.md chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
echo "⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
echo "" >> comment.md "${{ steps.pr.outputs.result }}" \
echo "### 🚀 Running Tests" >> comment.md "${{ github.event.workflow_run.head_branch }}" \
echo "- 🧪 **chromium**: Running tests..." >> comment.md "completed"
echo "- 🧪 **chromium-0.5x**: Running tests..." >> comment.md
echo "- 🧪 **chromium-2x**: Running tests..." >> comment.md
echo "- 🧪 **mobile-chrome**: Running tests..." >> comment.md
echo "" >> comment.md
echo "---" >> comment.md
echo "⏱️ Please wait while tests are running across all browsers..." >> comment.md
- name: Comment PR - Tests Started
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md
comment-tests-completed:
runs-on: ubuntu-latest
needs: deploy-reports
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed' && always()
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Download all deployment info
if: steps.pr.outputs.result != 'null'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
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 for completion
if: steps.pr.outputs.result != 'null'
id: comment-body-completed
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)
# Validate URLs before using them in comments
sanitized_url=$(echo "$url" | grep -E '^https://[a-z0-9.-]+\.pages\.dev(/.*)?$' || echo "INVALID_URL")
if [ "$sanitized_url" = "INVALID_URL" ]; then
echo "Invalid deployment URL detected: $url"
url="#" # Use safe fallback
fi
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
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md

View File

@@ -284,3 +284,65 @@ jobs:
name: playwright-report-chromium name: playwright-report-chromium
path: ComfyUI_frontend/playwright-report/ path: ComfyUI_frontend/playwright-report/
retention-days: 30 retention-days: 30
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get start time
id: start-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"${{ steps.start-time.outputs.time }}"
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all playwright reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
path: reports
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
- name: Deploy reports and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
run: |
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
#### END Deployment and commenting (non-forked PRs only)

1
.gitignore vendored
View File

@@ -51,6 +51,7 @@ tests-ui/workflows/examples
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
browser_tests/**/*-win32.png browser_tests/**/*-win32.png
browser-tests/local/
.env .env

View File

@@ -57,9 +57,8 @@
/* Override Storybook's problematic & selector styles */ /* Override Storybook's problematic & selector styles */
/* Reset only the specific properties that Storybook injects */ /* Reset only the specific properties that Storybook injects */
#storybook-root li+li, li+li {
#storybook-docs li+li { margin: 0;
margin: inherit; padding: revert-layer;
padding: inherit;
} }
</style> </style>

View File

@@ -36,6 +36,10 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing/missing_nodes') await comfyPage.loadWorkflow('missing/missing_nodes')
await comfyPage.closeDialog() await comfyPage.closeDialog()
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(100)
// Make a change to the graph // Make a change to the graph
await comfyPage.doubleClickCanvas() await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler') await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -149,7 +149,7 @@ test.describe('Selection Toolbox', () => {
// Node should have the selected color class/style // Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes // Note: Exact verification method depends on how color is applied to nodes
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
expect(selectedNode.getProperty('color')).not.toBeNull() expect(await selectedNode.getProperty('color')).not.toBeNull()
}) })
test('color picker shows current color of selected nodes', async ({ test('color picker shows current color of selected nodes', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -1,7 +1,7 @@
{ {
"name": "@comfyorg/comfyui-frontend", "name": "@comfyorg/comfyui-frontend",
"private": true, "private": true,
"version": "1.27.2", "version": "1.27.3",
"type": "module", "type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",
@@ -25,10 +25,10 @@
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true", "prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview", "preview": "nx preview",
"lint": "eslint src --cache --concurrency=auto", "lint": "eslint src --cache --concurrency=$npm_package_config_eslint_concurrency",
"lint:fix": "eslint src --cache --fix --concurrency=auto", "lint:fix": "eslint src --fix --cache --concurrency=$npm_package_config_eslint_concurrency",
"lint:no-cache": "eslint src", "lint:no-cache": "eslint src --concurrency=$npm_package_config_eslint_concurrency",
"lint:fix:no-cache": "eslint src --fix", "lint:fix:no-cache": "eslint src --fix --concurrency=$npm_package_config_eslint_concurrency",
"knip": "knip --cache", "knip": "knip --cache",
"knip:no-cache": "knip", "knip:no-cache": "knip",
"locale": "lobe-i18n locale", "locale": "lobe-i18n locale",
@@ -37,8 +37,12 @@
"storybook": "nx storybook -p 6006", "storybook": "nx storybook -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"config": {
"eslint_concurrency": "4"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0", "@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0", "@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1", "@lobehub/i18n-cli": "^1.25.1",
@@ -76,7 +80,6 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.62.0", "knip": "^5.62.0",
"lint-staged": "^15.2.7", "lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"nx": "21.4.1", "nx": "21.4.1",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"storybook": "^9.1.1", "storybook": "^9.1.1",

22
pnpm-lock.yaml generated
View File

@@ -171,6 +171,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^9.8.0 specifier: ^9.8.0
version: 9.12.0 version: 9.12.0
'@iconify-json/lucide':
specifier: ^1.2.66
version: 1.2.66
'@iconify/tailwind': '@iconify/tailwind':
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0 version: 1.2.0
@@ -282,9 +285,6 @@ importers:
lint-staged: lint-staged:
specifier: ^15.2.7 specifier: ^15.2.7
version: 15.2.7 version: 15.2.7
lucide-vue-next:
specifier: ^0.540.0
version: 0.540.0(vue@3.5.13(typescript@5.9.2))
nx: nx:
specifier: 21.4.1 specifier: 21.4.1
version: 21.4.1 version: 21.4.1
@@ -1595,6 +1595,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@iconify-json/lucide@1.2.66':
resolution: {integrity: sha512-TrhmfThWY2FHJIckjz7g34gUx3+cmja61DcHNdmu0rVDBQHIjPMYO1O8mMjoDSqIXEllz9wDZxCqT3lFuI+f/A==}
'@iconify/json@2.2.380': '@iconify/json@2.2.380':
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==} resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
@@ -4736,11 +4739,6 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==} resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'} engines: {node: '>=16.14'}
lucide-vue-next@0.540.0:
resolution: {integrity: sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==}
peerDependencies:
vue: '>=3.0.1'
lz-string@1.5.0: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@@ -8024,6 +8022,10 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@iconify-json/lucide@1.2.66':
dependencies:
'@iconify/types': 2.0.0
'@iconify/json@2.2.380': '@iconify/json@2.2.380':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
@@ -11563,10 +11565,6 @@ snapshots:
lru-cache@8.0.5: {} lru-cache@8.0.5: {}
lucide-vue-next@0.540.0(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.17: magic-string@0.30.17:

View File

@@ -0,0 +1,241 @@
#!/bin/bash
set -e
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Sanitize and validate branch name (allow alphanumeric, dots, dashes, underscores, slashes)
BRANCH_NAME=$(echo "$2" | sed 's/[^a-zA-Z0-9._/-]//g')
if [ -z "$BRANCH_NAME" ]; then
echo "Error: Invalid or empty branch name" >&2
exit 1
fi
# Validate status parameter
STATUS="${3:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- PLAYWRIGHT_TEST_STATUS -->"
# Use dot notation for artifact names (as Playwright creates them)
BROWSERS="chromium chromium-2x chromium-0.5x mobile-chrome"
# Install wrangler if not available (output to stderr for debugging)
if ! command -v wrangler > /dev/null 2>&1; then
echo "Installing wrangler v4..." >&2
npm install -g wrangler@^4.0.0 >&2 || {
echo "Failed to install wrangler" >&2
echo "failed"
return
}
fi
# Deploy a single browser report, WARN: ensure inputs are sanitized before calling this function
deploy_report() {
dir="$1"
browser="$2"
branch="$3"
[ ! -d "$dir" ] && echo "failed" && return
# Project name with dots converted to dashes for Cloudflare
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
project="comfyui-playwright-${sanitized_browser}"
echo "Deploying $browser to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch and project are already sanitized, use them directly
# Branch was sanitized at script start, project uses sanitized_browser
if output=$(wrangler pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Extract URL from output (improved regex for valid URL characters)
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-https://${branch}.${project}.pages.dev}"
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
# Create new comment
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Test Results
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
---
⏱️ Please wait while tests are running...
EOF
)
post_comment "$comment"
else
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "Looking for reports in: $(pwd)/reports"
echo "Available reports:"
ls -la reports/ 2>/dev/null || echo "Reports directory not found"
# Deploy all reports in parallel and collect URLs
temp_dir=$(mktemp -d)
pids=""
i=0
# Start parallel deployments
for browser in $BROWSERS; do
if [ -d "reports/playwright-report-$browser" ]; then
echo "Found report for $browser, deploying in parallel..."
(
url=$(deploy_report "reports/playwright-report-$browser" "$browser" "$cloudflare_branch")
echo "$url" > "$temp_dir/$i.url"
echo "Deployment result for $browser: $url"
) &
pids="$pids $!"
else
echo "Report not found for $browser at reports/playwright-report-$browser"
echo "failed" > "$temp_dir/$i.url"
fi
i=$((i + 1))
done
# Wait for all deployments to complete
for pid in $pids; do
wait $pid
done
# Collect URLs in order
urls=""
i=0
for browser in $BROWSERS; do
if [ -f "$temp_dir/$i.url" ]; then
url=$(cat "$temp_dir/$i.url")
else
url="failed"
fi
if [ -z "$urls" ]; then
urls="$url"
else
urls="$urls $url"
fi
i=$((i + 1))
done
# Clean up temp directory
rm -rf "$temp_dir"
# Generate completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Test Results
✅ **Tests completed successfully!**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
### 📊 Test Reports by Browser"
# Add browser results
i=0
for browser in $BROWSERS; do
# Get URL at position i
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
if [ "$url" != "failed" ] && [ -n "$url" ]; then
comment="$comment
- ✅ **${browser}**: [View Report](${url})"
else
comment="$comment
- ❌ **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
comment="$comment
---
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi

View File

@@ -7,66 +7,6 @@
@config '../../../tailwind.config.ts'; @config '../../../tailwind.config.ts';
@layer tailwind-utilities {
/* Set default values to prevent some styles from not working properly. */
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(66 153 225 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
@tailwind components;
@tailwind utilities;
}
:root { :root {
--fg-color: #000; --fg-color: #000;
--bg-color: #fff; --bg-color: #fff;
@@ -107,6 +47,91 @@
} }
} }
@theme {
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Palette Colors */
--color-charcoal-100: #171718;
--color-charcoal-200: #202121;
--color-charcoal-300: #262729;
--color-charcoal-400: #2d2e32;
--color-charcoal-500: #313235;
--color-charcoal-600: #3c3d42;
--color-charcoal-700: #494a50;
--color-charcoal-800: #55565e;
--color-stone-100: #444444;
--color-stone-200: #828282;
--color-stone-300: #bbbbbb;
--color-ivory-100: #fdfbfa;
--color-ivory-200: #faf9f5;
--color-ivory-300: #f0eee6;
--color-gray-100: #f3f3f3;
--color-gray-200: #e9e9e9;
--color-gray-300: #e1e1e1;
--color-gray-400: #d9d9d9;
--color-gray-500: #c5c5c5;
--color-gray-600: #b4b4b4;
--color-gray-700: #a0a0a0;
--color-gray-800: #8a8a8a;
--color-sand-100: #e1ded5;
--color-sand-200: #d6cfc2;
--color-sand-300: #888682;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-brand-yellow: #f0ff41;
--color-brand-blue: #172dd7;
--color-blue-100: #0b8ce9;
--color-blue-200: #31b9f4;
--color-success-100: #00cd72;
--color-success-200: #47e469;
--color-warning-100: #fd9903;
--color-warning-200: #fcbf64;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-error: #962a2a;
--color-blue-selection: rgb( from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb( from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@custom-variant dark-theme {
.dark-theme & {
@slot;
}
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
/* Everthing below here to be cleaned up over time. */
body { body {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
@@ -849,7 +874,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d, .comfy-load-3d,
.comfy-load-3d-animation, .comfy-load-3d-animation,
.comfy-preview-3d, .comfy-preview-3d,
.comfy-preview-3d-animation{ .comfy-preview-3d-animation {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: transparent; background: transparent;
@@ -862,7 +887,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d-animation canvas, .comfy-load-3d-animation canvas,
.comfy-preview-3d canvas, .comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas, .comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas{ .comfy-load-3d-viewer canvas {
display: flex; display: flex;
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
@@ -939,7 +964,9 @@ audio.comfy-audio.empty-audio-widget {
.lg-node .lg-slot, .lg-node .lg-slot,
.lg-node .lg-widget { .lg-node .lg-widget {
transition: opacity 0.1s ease, font-size 0.1s ease; transition:
opacity 0.1s ease,
font-size 0.1s ease;
} }
/* Performance optimization during canvas interaction */ /* Performance optimization during canvas interaction */
@@ -971,4 +998,3 @@ audio.comfy-audio.empty-audio-widget {
/* Use solid colors only */ /* Use solid colors only */
background-image: none !important; background-image: none !important;
} }

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
import IconButton from './IconButton.vue' import IconButton from './IconButton.vue'
@@ -33,13 +32,13 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = { export const Primary: Story = {
render: (args) => ({ render: (args) => ({
components: { IconButton, Trophy }, components: { IconButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconButton v-bind="args"> <IconButton v-bind="args">
<Trophy :size="16" /> <i class="icon-[lucide--trophy] size-4" />
</IconButton> </IconButton>
` `
}), }),
@@ -51,13 +50,13 @@ export const Primary: Story = {
export const Secondary: Story = { export const Secondary: Story = {
render: (args) => ({ render: (args) => ({
components: { IconButton, Settings }, components: { IconButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconButton v-bind="args"> <IconButton v-bind="args">
<Settings :size="16" /> <i class="icon-[lucide--settings] size-4" />
</IconButton> </IconButton>
` `
}), }),
@@ -69,13 +68,13 @@ export const Secondary: Story = {
export const Transparent: Story = { export const Transparent: Story = {
render: (args) => ({ render: (args) => ({
components: { IconButton, X }, components: { IconButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconButton v-bind="args"> <IconButton v-bind="args">
<X :size="16" /> <i class="icon-[lucide--x] size-4" />
</IconButton> </IconButton>
` `
}), }),
@@ -87,13 +86,13 @@ export const Transparent: Story = {
export const Small: Story = { export const Small: Story = {
render: (args) => ({ render: (args) => ({
components: { IconButton, Bell }, components: { IconButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconButton v-bind="args"> <IconButton v-bind="args">
<Bell :size="12" /> <i class="icon-[lucide--bell] size-3" />
</IconButton> </IconButton>
` `
}), }),
@@ -105,42 +104,42 @@ export const Small: Story = {
export const AllVariants: Story = { export const AllVariants: Story = {
render: () => ({ render: () => ({
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download }, components: { IconButton },
template: ` template: `
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconButton type="primary" size="sm" @click="() => {}"> <IconButton type="primary" size="sm" @click="() => {}">
<Trophy :size="12" /> <i class="icon-[lucide--trophy] size-3" />
</IconButton> </IconButton>
<IconButton type="primary" size="md" @click="() => {}"> <IconButton type="primary" size="md" @click="() => {}">
<Trophy :size="16" /> <i class="icon-[lucide--trophy] size-4" />
</IconButton> </IconButton>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconButton type="secondary" size="sm" @click="() => {}"> <IconButton type="secondary" size="sm" @click="() => {}">
<Settings :size="12" /> <i class="icon-[lucide--settings] size-3" />
</IconButton> </IconButton>
<IconButton type="secondary" size="md" @click="() => {}"> <IconButton type="secondary" size="md" @click="() => {}">
<Settings :size="16" /> <i class="icon-[lucide--settings] size-4" />
</IconButton> </IconButton>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconButton type="transparent" size="sm" @click="() => {}"> <IconButton type="transparent" size="sm" @click="() => {}">
<X :size="12" /> <i class="icon-[lucide--x] size-3" />
</IconButton> </IconButton>
<IconButton type="transparent" size="md" @click="() => {}"> <IconButton type="transparent" size="md" @click="() => {}">
<X :size="16" /> <i class="icon-[lucide--x] size-4" />
</IconButton> </IconButton>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconButton type="primary" size="md" @click="() => {}"> <IconButton type="primary" size="md" @click="() => {}">
<Bell :size="16" /> <i class="icon-[lucide--bell] size-4" />
</IconButton> </IconButton>
<IconButton type="secondary" size="md" @click="() => {}"> <IconButton type="secondary" size="md" @click="() => {}">
<Heart :size="16" /> <i class="icon-[lucide--heart] size-4" />
</IconButton> </IconButton>
<IconButton type="transparent" size="md" @click="() => {}"> <IconButton type="transparent" size="md" @click="() => {}">
<Download :size="16" /> <i class="icon-[lucide--download] size-4" />
</IconButton> </IconButton>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,11 @@
<template> <template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick"> <Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<slot></slot> <slot></slot>
</Button> </Button>
</template> </template>
@@ -15,11 +21,16 @@ import {
getButtonTypeClasses, getButtonTypeClasses,
getIconButtonSizeClasses getIconButtonSizeClasses
} from '@/types/buttonTypes' } from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps { interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void onClick: (event: Event) => void
} }
defineOptions({
inheritAttrs: false
})
const { const {
size = 'md', size = 'md',
type = 'secondary', type = 'secondary',
@@ -36,8 +47,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type) ? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type) : getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className] return cn(baseClasses, sizeClasses, typeClasses, className)
.filter(Boolean)
.join(' ')
}) })
</script> </script>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
import IconButton from './IconButton.vue' import IconButton from './IconButton.vue'
import IconGroup from './IconGroup.vue' import IconGroup from './IconGroup.vue'
@@ -17,17 +16,17 @@ type Story = StoryObj<typeof IconGroup>
export const Basic: Story = { export const Basic: Story = {
render: () => ({ render: () => ({
components: { IconGroup, IconButton, Download, ExternalLink, Heart }, components: { IconGroup, IconButton },
template: ` template: `
<IconGroup> <IconGroup>
<IconButton @click="console.log('Hello World!!')"> <IconButton @click="console.log('Hello World!!')">
<Heart :size="16" /> <i class="icon-[lucide--heart] size-4" />
</IconButton> </IconButton>
<IconButton @click="console.log('Hello World!!')"> <IconButton @click="console.log('Hello World!!')">
<Download :size="16" /> <i class="icon-[lucide--download] size-4" />
</IconButton> </IconButton>
<IconButton @click="console.log('Hello World!!')"> <IconButton @click="console.log('Hello World!!')">
<ExternalLink :size="16" /> <i class="icon-[lucide--external-link] size-4" />
</IconButton> </IconButton>
</IconGroup> </IconGroup>
` `

View File

@@ -1,7 +1,17 @@
<template> <template>
<div <div :class="iconGroupClasses">
class="flex justify-center items-center shrink-0 outline-hidden border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white rounded-lg cursor-pointer"
>
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-white dark-theme:bg-zinc-700',
'text-neutral-950 dark-theme:text-white',
'cursor-pointer'
)
</script>

View File

@@ -1,14 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' 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' import IconTextButton from './IconTextButton.vue'
@@ -49,14 +39,14 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = { export const Primary: Story = {
render: (args) => ({ render: (args) => ({
components: { IconTextButton, Package }, components: { IconTextButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconTextButton v-bind="args"> <IconTextButton v-bind="args">
<template #icon> <template #icon>
<Package :size="16" /> <i class="icon-[lucide--package] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
` `
@@ -70,14 +60,14 @@ export const Primary: Story = {
export const Secondary: Story = { export const Secondary: Story = {
render: (args) => ({ render: (args) => ({
components: { IconTextButton, Settings }, components: { IconTextButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconTextButton v-bind="args"> <IconTextButton v-bind="args">
<template #icon> <template #icon>
<Settings :size="16" /> <i class="icon-[lucide--settings] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
` `
@@ -91,14 +81,14 @@ export const Secondary: Story = {
export const Transparent: Story = { export const Transparent: Story = {
render: (args) => ({ render: (args) => ({
components: { IconTextButton, X }, components: { IconTextButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconTextButton v-bind="args"> <IconTextButton v-bind="args">
<template #icon> <template #icon>
<X :size="16" /> <i class="icon-[lucide--x] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
` `
@@ -112,14 +102,14 @@ export const Transparent: Story = {
export const WithIconRight: Story = { export const WithIconRight: Story = {
render: (args) => ({ render: (args) => ({
components: { IconTextButton, ChevronRight }, components: { IconTextButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconTextButton v-bind="args"> <IconTextButton v-bind="args">
<template #icon> <template #icon>
<ChevronRight :size="16" /> <i class="icon-[lucide--chevron-right] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
` `
@@ -134,14 +124,14 @@ export const WithIconRight: Story = {
export const Small: Story = { export const Small: Story = {
render: (args) => ({ render: (args) => ({
components: { IconTextButton, Save }, components: { IconTextButton },
setup() { setup() {
return { args } return { args }
}, },
template: ` template: `
<IconTextButton v-bind="args"> <IconTextButton v-bind="args">
<template #icon> <template #icon>
<Save :size="12" /> <i class="icon-[lucide--save] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
` `
@@ -156,66 +146,60 @@ export const Small: Story = {
export const AllVariants: Story = { export const AllVariants: Story = {
render: () => ({ render: () => ({
components: { components: {
IconTextButton, IconTextButton
Download,
Settings,
Trash2,
ChevronRight,
ChevronLeft,
Save
}, },
template: ` template: `
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}"> <IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon> <template #icon>
<Download :size="12" /> <i class="icon-[lucide--download] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}"> <IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon> <template #icon>
<Download :size="16" /> <i class="icon-[lucide--download] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}"> <IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon> <template #icon>
<Settings :size="12" /> <i class="icon-[lucide--settings] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}"> <IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon> <template #icon>
<Settings :size="16" /> <i class="icon-[lucide--settings] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}"> <IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon> <template #icon>
<Trash2 :size="12" /> <i class="icon-[lucide--trash-2] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}"> <IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon> <template #icon>
<Trash2 :size="16" /> <i class="icon-[lucide--trash-2] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}"> <IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon> <template #icon>
<ChevronRight :size="16" /> <i class="icon-[lucide--chevron-right] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}"> <IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon> <template #icon>
<ChevronLeft :size="16" /> <i class="icon-[lucide--chevron-left] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}"> <IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon> <template #icon>
<Save :size="16" /> <i class="icon-[lucide--save] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
</div> </div>

View File

@@ -1,5 +1,11 @@
<template> <template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick"> <Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<slot v-if="iconPosition !== 'right'" name="icon"></slot> <slot v-if="iconPosition !== 'right'" name="icon"></slot>
<span>{{ label }}</span> <span>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot> <slot v-if="iconPosition === 'right'" name="icon"></slot>
@@ -17,6 +23,11 @@ import {
getButtonSizeClasses, getButtonSizeClasses,
getButtonTypeClasses getButtonTypeClasses
} from '@/types/buttonTypes' } from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
interface IconTextButtonProps extends BaseButtonProps { interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
@@ -42,8 +53,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type) ? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type) : getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className] return cn(baseClasses, sizeClasses, typeClasses, className)
.filter(Boolean)
.join(' ')
}) })
</script> </script>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ScrollText } from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue' import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue' import MoreButton from './MoreButton.vue'
@@ -18,7 +17,7 @@ type Story = StoryObj<typeof MoreButton>
export const Basic: Story = { export const Basic: Story = {
render: () => ({ render: () => ({
components: { MoreButton, IconTextButton, Download, ScrollText }, components: { MoreButton, IconTextButton },
template: ` template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;"> <div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton> <MoreButton>
@@ -29,7 +28,7 @@ export const Basic: Story = {
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<Download :size="16" /> <i class="icon-[lucide--download] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
@@ -39,7 +38,7 @@ export const Basic: Story = {
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<ScrollText :size="16" /> <i class="icon-[lucide--scroll-text] size-4" />
</template> </template>
</IconTextButton> </IconTextButton>
</template> </template>

View File

@@ -14,7 +14,7 @@
unstyled unstyled
:pt="pt" :pt="pt"
> >
<div class="flex flex-col gap-1 p-2 min-w-40"> <div class="flex flex-col gap-2 p-2 min-w-40">
<slot :close="hide" /> <slot :close="hide" />
</div> </div>
</Popover> </Popover>
@@ -25,6 +25,8 @@
import Popover from 'primevue/popover' import Popover from 'primevue/popover'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import IconButton from './IconButton.vue' import IconButton from './IconButton.vue'
const popover = ref<InstanceType<typeof Popover>>() const popover = ref<InstanceType<typeof Popover>>()
@@ -39,13 +41,16 @@ const hide = () => {
const pt = computed(() => ({ const pt = computed(() => ({
root: { root: {
class: 'absolute z-50' class: cn('absolute z-50')
}, },
content: { content: {
class: [ class: cn(
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg', 'mt-2 rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700' 'bg-white dark-theme:bg-zinc-800',
] 'text-neutral dark-theme:text-white',
'shadow-lg',
'border border-zinc-200 dark-theme:border-zinc-700'
)
} }
})) }))
</script> </script>

View File

@@ -1,5 +1,11 @@
<template> <template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick"> <Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<span>{{ label }}</span> <span>{{ label }}</span>
</Button> </Button>
</template> </template>
@@ -15,12 +21,17 @@ import {
getButtonSizeClasses, getButtonSizeClasses,
getButtonTypeClasses getButtonTypeClasses
} from '@/types/buttonTypes' } from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
interface TextButtonProps extends BaseButtonProps { interface TextButtonProps extends BaseButtonProps {
label: string label: string
onClick: () => void onClick: () => void
} }
defineOptions({
inheritAttrs: false
})
const { const {
size = 'md', size = 'md',
type = 'primary', type = 'primary',
@@ -38,8 +49,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type) ? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type) : getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className] return cn(baseClasses, sizeClasses, typeClasses, className)
.filter(Boolean)
.join(' ')
}) })
</script> </script>

View File

@@ -1,13 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' 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 { ref } from 'vue'
import IconButton from '../button/IconButton.vue' import IconButton from '../button/IconButton.vue'
@@ -58,14 +49,6 @@ const meta: Meta<CardStoryArgs> = {
options: ['square', 'portrait', 'tallPortrait'], options: ['square', 'portrait', 'tallPortrait'],
description: 'Card container aspect ratio' 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: { topRatio: {
control: 'select', control: 'select',
options: ['square', 'landscape'], options: ['square', 'landscape'],
@@ -149,14 +132,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardTitle, CardTitle,
CardDescription, CardDescription,
IconButton, IconButton,
SquareChip, SquareChip
Info,
Folder,
Heart,
Download,
Star,
Upload,
MoreVertical
}, },
setup() { setup() {
const favorited = ref(false) const favorited = ref(false)
@@ -171,11 +147,10 @@ const createCardTemplate = (args: CardStoryArgs) => ({
} }
}, },
template: ` template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900"> <div class="min-h-screen">
<CardContainer <CardContainer
:ratio="args.containerRatio" :ratio="args.containerRatio"
:max-width="args.maxWidth" class="max-w-[320px] mx-auto"
:min-width="args.minWidth"
> >
<template #top> <template #top>
<CardTop :ratio="args.topRatio"> <CardTop :ratio="args.topRatio">
@@ -202,14 +177,14 @@ const createCardTemplate = (args: CardStoryArgs) => ({
class="!bg-white/90 !text-neutral-900" class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info clicked')" @click="() => console.log('Info clicked')"
> >
<Info :size="16" /> <i class="icon-[lucide--info] size-4" />
</IconButton> </IconButton>
<IconButton <IconButton
class="!bg-white/90" class="!bg-white/90"
:class="favorited ? '!text-red-500' : '!text-neutral-900'" :class="favorited ? '!text-red-500' : '!text-neutral-900'"
@click="toggleFavorite" @click="toggleFavorite"
> >
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" /> <i class="icon-[lucide--heart] size-4" :class="favorited ? 'fill-current' : ''" />
</IconButton> </IconButton>
</template> </template>
@@ -222,7 +197,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
<SquareChip v-if="args.showFileSize" :label="args.fileSize" /> <SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag"> <SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon> <template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" /> <i class="icon-[lucide--folder] size-3" />
</template> </template>
</SquareChip> </SquareChip>
</template> </template>
@@ -230,7 +205,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template> </template>
<template #bottom> <template #bottom>
<CardBottom class="p-3"> <CardBottom class="p-3 bg-neutral-100">
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle> <CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription> <CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
</CardBottom> </CardBottom>
@@ -244,8 +219,6 @@ export const Default: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args), render: (args: CardStoryArgs) => createCardTemplate(args),
args: { args: {
containerRatio: 'portrait', containerRatio: 'portrait',
maxWidth: 300,
minWidth: 200,
topRatio: 'square', topRatio: 'square',
showTopLeft: false, showTopLeft: false,
showTopRight: true, showTopRight: true,
@@ -271,8 +244,6 @@ export const SquareCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args), render: (args: CardStoryArgs) => createCardTemplate(args),
args: { args: {
containerRatio: 'square', containerRatio: 'square',
maxWidth: 400,
minWidth: 250,
topRatio: 'landscape', topRatio: 'landscape',
showTopLeft: false, showTopLeft: false,
showTopRight: true, showTopRight: true,
@@ -298,8 +269,6 @@ export const TallPortraitCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args), render: (args: CardStoryArgs) => createCardTemplate(args),
args: { args: {
containerRatio: 'tallPortrait', containerRatio: 'tallPortrait',
maxWidth: 280,
minWidth: 180,
topRatio: 'square', topRatio: 'square',
showTopLeft: true, showTopLeft: true,
showTopRight: true, showTopRight: true,
@@ -325,8 +294,6 @@ export const ImageCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args), render: (args: CardStoryArgs) => createCardTemplate(args),
args: { args: {
containerRatio: 'portrait', containerRatio: 'portrait',
maxWidth: 350,
minWidth: 220,
topRatio: 'square', topRatio: 'square',
showTopLeft: false, showTopLeft: false,
showTopRight: true, showTopRight: true,
@@ -351,8 +318,6 @@ export const MinimalCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args), render: (args: CardStoryArgs) => createCardTemplate(args),
args: { args: {
containerRatio: 'square', containerRatio: 'square',
maxWidth: 300,
minWidth: 200,
topRatio: 'landscape', topRatio: 'landscape',
showTopLeft: false, showTopLeft: false,
showTopRight: false, showTopRight: false,
@@ -377,8 +342,6 @@ export const FullFeaturedCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args), render: (args: CardStoryArgs) => createCardTemplate(args),
args: { args: {
containerRatio: 'tallPortrait', containerRatio: 'tallPortrait',
maxWidth: 320,
minWidth: 240,
topRatio: 'square', topRatio: 'square',
showTopLeft: true, showTopLeft: true,
showTopRight: true, showTopRight: true,
@@ -392,274 +355,10 @@ export const FullFeaturedCard: Story = {
backgroundColor: '#ef4444', backgroundColor: '#ef4444',
showImage: false, showImage: false,
imageUrl: '', imageUrl: '',
tags: ['Bundle', 'Premium', 'SDXL'], tags: ['Bundle', 'SDXL'],
showFileSize: true, showFileSize: true,
fileSize: '5.4 GB', fileSize: '5.4 GB',
showFileType: true, showFileType: true,
fileType: 'pack' fileType: 'pack'
} }
} }
export const GridOfCards: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download
},
setup() {
const cards = ref([
{
id: 1,
title: 'Realistic Vision',
description: 'Photorealistic model for portraits',
color: 'from-blue-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['SD 1.5'],
size: '2.1 GB'
},
{
id: 2,
title: 'DreamShaper XL',
description: 'Artistic style model with enhanced details',
color: 'from-purple-400 to-pink-600',
ratio: 'portrait' as const,
tags: ['SDXL'],
size: '6.5 GB'
},
{
id: 3,
title: 'Anime LoRA',
description: 'Character style LoRA',
color: 'from-green-400 to-teal-600',
ratio: 'portrait' as const,
tags: ['LoRA'],
size: '144 MB'
},
{
id: 4,
title: 'VAE Model',
description: 'Enhanced color VAE',
color: 'from-orange-400 to-red-600',
ratio: 'portrait' as const,
tags: ['VAE'],
size: '335 MB'
},
{
id: 5,
title: 'Workflow Bundle',
description: 'Complete workflow setup',
color: 'from-indigo-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['Workflow'],
size: '45 KB'
},
{
id: 6,
title: 'Embedding Pack',
description: 'Negative embeddings collection',
color: 'from-yellow-400 to-orange-600',
ratio: 'portrait' as const,
tags: ['Embedding'],
size: '2.3 MB'
}
])
return { cards }
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<CardContainer
v-for="card in cards"
:key="card.id"
:ratio="card.ratio"
:max-width="300"
:min-width="180"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-gray-600"
:class="card.color"
></div>
</template>
<template #top-right>
<IconButton
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info:', card.title)"
>
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip
v-for="tag in card.tags"
:key="tag"
:label="tag"
>
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
</template>
</SquareChip>
<SquareChip :label="card.size" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
`
})
}
export const ResponsiveGrid: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
SquareChip
},
setup() {
const generateCards = (
count: number,
ratio: 'square' | 'portrait' | 'tallPortrait'
) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
title: `Model ${i + 1}`,
description: `Description for model ${i + 1}`,
ratio,
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
}))
}
const squareCards = ref(generateCards(4, 'square'))
const portraitCards = ref(generateCards(6, 'portrait'))
const tallCards = ref(generateCards(5, 'tallPortrait'))
return {
squareCards,
portraitCards,
tallCards
}
},
template: `
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<CardContainer
v-for="card in squareCards"
:key="card.id"
:ratio="card.ratio"
:max-width="400"
:min-width="200"
>
<template #top>
<CardTop ratio="landscape">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<CardContainer
v-for="card in portraitCards"
:key="card.id"
:ratio="card.ratio"
:max-width="280"
:min-width="160"
>
<template #top>
<CardTop ratio="square">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-2">
<CardTitle>{{ card.title }}</CardTitle>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<CardContainer
v-for="card in tallCards"
:key="card.id"
:ratio="card.ratio"
:max-width="260"
:min-width="150"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</template>
<template #bottom-right>
<SquareChip :label="'#' + card.id" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -1,5 +1,5 @@
<template> <template>
<div :class="containerClasses" :style="containerStyle"> <div :class="containerClasses">
<slot name="top"></slot> <slot name="top"></slot>
<slot name="bottom"></slot> <slot name="bottom"></slot>
</div> </div>
@@ -8,13 +8,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
const { const { ratio = 'square' } = defineProps<{
ratio = 'square',
maxWidth,
minWidth
} = defineProps<{
maxWidth?: number
minWidth?: number
ratio?: 'square' | 'portrait' | 'tallPortrait' ratio?: 'square' | 'portrait' | 'tallPortrait'
}>() }>()
@@ -30,13 +24,4 @@ const containerClasses = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}` return `${baseClasses} ${ratioClasses[ratio]}`
}) })
const containerStyle = computed(() =>
maxWidth || minWidth
? {
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}
: {}
)
</script> </script>

View File

@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { createGridStyle } from '@/utils/gridUtil'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
import CardTop from './CardTop.vue'
const meta: Meta = {
title: 'Components/Card/CardGridList',
tags: ['autodocs'],
argTypes: {
minWidth: {
control: 'text',
description: 'Minimum width for each grid item'
},
maxWidth: {
control: 'text',
description: 'Maximum width for each grid item'
},
padding: {
control: 'text',
description: 'Padding around the grid'
},
gap: {
control: 'text',
description: 'Gap between grid items'
},
columns: {
control: 'number',
description: 'Fixed number of columns (overrides auto-fill)'
}
},
args: {
minWidth: '15rem',
maxWidth: '1fr',
padding: '0rem',
gap: '1rem'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { CardContainer, CardTop, CardBottom },
setup() {
const gridStyle = createGridStyle(args)
return { gridStyle }
},
template: `
<div :style="gridStyle">
<CardContainer v-for="i in 12" :key="i" ratio="square">
<template #top>
<CardTop ratio="landscape">
<template #default>
<div class="w-full h-full bg-blue-500"></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="bg-neutral-200"></CardBottom>
</template>
</CardContainer>
</div>
`
})
}

View File

@@ -2,8 +2,8 @@
<NoResultsPlaceholder <NoResultsPlaceholder
class="pb-0" class="pb-0"
icon="pi pi-exclamation-circle" icon="pi pi-exclamation-circle"
title="Some Nodes Are Missing" :title="$t('loadWorkflowWarning.missingNodesTitle')"
message="When loading the graph, the following node types were not found" :message="$t('loadWorkflowWarning.missingNodesDescription')"
/> />
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" /> <MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox <ListBox
@@ -53,13 +53,16 @@
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button' import Button from 'primevue/button'
import ListBox from 'primevue/listbox' import ListBox from 'primevue/listbox'
import { computed } from 'vue' import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue' import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes' import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/composables/useManagerState' import { useManagerState } from '@/composables/useManagerState'
import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useToastStore } from '@/stores/toastStore'
import type { MissingNodeType } from '@/types/comfy' import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes' import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -121,6 +124,35 @@ const openManager = async () => {
showToastOnLegacyError: true showToastOnLegacyError: true
}) })
} }
const { t } = useI18n()
const dialogStore = useDialogStore()
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
return (
!isLoading.value &&
!isInstalling.value &&
missingNodePacks.value?.length === 0
)
})
// Watch for completion and close dialog
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled) {
// Use nextTick to ensure state updates are complete
await nextTick()
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
// Show success toast
useToastStore().add({
severity: 'success',
summary: t('g.success'),
detail: t('manager.allMissingNodesInstalled'),
life: 3000
})
}
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,6 +1,7 @@
import { VueWrapper, mount } from '@vue/test-utils' import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config' import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@@ -31,11 +32,14 @@ const mockInstalledPacks = {
'installed-pack': { ver: '2.0.0' } 'installed-pack': { ver: '2.0.0' }
} }
const mockIsPackEnabled = vi.fn(() => true)
vi.mock('@/stores/comfyManagerStore', () => ({ vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({ useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks, installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) => isPackInstalled: (id: string) =>
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks] !!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
isPackEnabled: mockIsPackEnabled
})) }))
})) }))
@@ -60,6 +64,7 @@ describe('PackVersionBadge', () => {
beforeEach(() => { beforeEach(() => {
mockToggle.mockReset() mockToggle.mockReset()
mockHide.mockReset() mockHide.mockReset()
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
}) })
const mountComponent = ({ const mountComponent = ({
@@ -79,6 +84,9 @@ describe('PackVersionBadge', () => {
}, },
global: { global: {
plugins: [PrimeVue, createPinia(), i18n], plugins: [PrimeVue, createPinia(), i18n],
directives: {
tooltip: Tooltip
},
stubs: { stubs: {
Popover: PopoverStub, Popover: PopoverStub,
PackVersionSelectorPopover: true PackVersionSelectorPopover: true
@@ -229,4 +237,63 @@ describe('PackVersionBadge', () => {
expect(mockHide).not.toHaveBeenCalled() expect(mockHide).not.toHaveBeenCalled()
}) })
}) })
describe('disabled state', () => {
beforeEach(() => {
mockIsPackEnabled.mockReturnValue(false) // Set all packs as disabled for these tests
})
it('adds disabled styles when pack is disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.classes()).toContain('cursor-not-allowed')
expect(badge.classes()).toContain('opacity-60')
})
it('does not show chevron icon when disabled', () => {
const wrapper = mountComponent()
const chevronIcon = wrapper.find('.pi-chevron-right')
expect(chevronIcon.exists()).toBe(false)
})
it('does not show update arrow when disabled', () => {
const wrapper = mountComponent()
const updateIcon = wrapper.find('.pi-arrow-circle-up')
expect(updateIcon.exists()).toBe(false)
})
it('does not toggle popover when clicked while disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('click')
// Since it's disabled, the popover should not be toggled
expect(mockToggle).not.toHaveBeenCalled()
})
it('has correct tabindex when disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.attributes('tabindex')).toBe('-1')
})
it('does not respond to keyboard events when disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('keydown.enter')
await badge.trigger('keydown.space')
expect(mockToggle).not.toHaveBeenCalled()
})
})
}) })

View File

@@ -1,21 +1,28 @@
<template> <template>
<div> <div>
<div <div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1" v-tooltip.top="
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }" isDisabled ? $t('manager.enablePackToChangeVersion') : null
aria-haspopup="true" "
role="button" class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
tabindex="0" :class="{
@click="toggleVersionSelector" 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
@keydown.enter="toggleVersionSelector" 'cursor-pointer': !isDisabled,
@keydown.space="toggleVersionSelector" 'cursor-not-allowed opacity-60': isDisabled
}"
:aria-haspopup="!isDisabled"
:role="isDisabled ? 'text' : 'button'"
:tabindex="isDisabled ? -1 : 0"
@click="!isDisabled && toggleVersionSelector($event)"
@keydown.enter="!isDisabled && toggleVersionSelector($event)"
@keydown.space="!isDisabled && toggleVersionSelector($event)"
> >
<i <i
v-if="isUpdateAvailable" v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600 text-xs" class="pi pi-arrow-circle-up text-blue-600 text-xs"
/> />
<span>{{ installedVersion }}</span> <span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right text-xxs" /> <i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
</div> </div>
<Popover <Popover
@@ -61,6 +68,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore() const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
)
const installedVersion = computed(() => { const installedVersion = computed(() => {
if (!nodePack.id) return 'nightly' if (!nodePack.id) return 'nightly'
const version = const version =

View File

@@ -1,5 +1,8 @@
<template> <template>
<IconTextButton <IconTextButton
v-tooltip.top="
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
"
v-bind="$attrs" v-bind="$attrs"
type="transparent" type="transparent"
:label="$t('manager.updateAll')" :label="$t('manager.updateAll')"
@@ -24,8 +27,9 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node'] type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{ const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
nodePacks: NodePack[] nodePacks: NodePack[]
hasDisabledUpdatePacks?: boolean
}>() }>()
const isUpdating = ref<boolean>(false) const isUpdating = ref<boolean>(false)

View File

@@ -34,7 +34,8 @@
/> />
<PackUpdateButton <PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable" v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="updateAvailableNodePacks" :node-packs="enabledUpdateAvailableNodePacks"
:has-disabled-update-packs="hasDisabledUpdatePacks"
/> />
</div> </div>
<div class="flex mt-3 text-sm"> <div class="flex mt-3 text-sm">
@@ -103,8 +104,11 @@ const { t } = useI18n()
const { missingNodePacks, isLoading, error } = useMissingNodes() const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes // Use the composable to get update available nodes
const { hasUpdateAvailable, updateAvailableNodePacks } = const {
useUpdateAvailableNodes() hasUpdateAvailable,
enabledUpdateAvailableNodePacks,
hasDisabledUpdatePacks
} = useUpdateAvailableNodes()
const hasResults = computed( const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length () => searchQuery.value?.trim() && searchResults?.length

View File

@@ -28,7 +28,7 @@
id="graph-canvas" id="graph-canvas"
ref="canvasRef" ref="canvasRef"
tabindex="1" tabindex="1"
class="w-full h-full touch-none" class="align-top w-full h-full touch-none"
/> />
<!-- TransformPane for Vue node rendering --> <!-- TransformPane for Vue node rendering -->
@@ -36,6 +36,7 @@
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady" v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas" :canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate" @transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
> >
<!-- Vue nodes rendered based on graph nodes --> <!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode <VueGraphNode
@@ -96,7 +97,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue' import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue' import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback' import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers' import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useViewportCulling } from '@/composables/graph/useViewportCulling' import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge' import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -116,6 +117,7 @@ import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/TransformPane.vue' import TransformPane from '@/renderer/core/layout/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue' import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { UnauthorizedError, api } from '@/scripts/api' import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app' import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker' import { ChangeTracker } from '@/scripts/changeTracker'
@@ -147,6 +149,8 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const executionStore = useExecutionStore() const executionStore = useExecutionStore()
const toastStore = useToastStore() const toastStore = useToastStore()
const canvasInteractions = useCanvasInteractions()
const betaMenuEnabled = computed( const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled' () => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
) )

View File

@@ -13,6 +13,9 @@ interface ExtendedProps extends Partial<MultiSelectProps> {
showSelectedCount?: boolean showSelectedCount?: boolean
showClearButton?: boolean showClearButton?: boolean
searchPlaceholder?: string searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type // Override modelValue type to match our Option type
modelValue?: Array<{ name: string; value: string }> modelValue?: Array<{ name: string; value: string }>
} }
@@ -42,6 +45,18 @@ const meta: Meta<ExtendedProps> = {
}, },
searchPlaceholder: { searchPlaceholder: {
control: 'text' control: 'text'
},
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
} }
}, },
args: { args: {
@@ -274,3 +289,140 @@ export const CustomSearchPlaceholder: Story = {
searchPlaceholder: 'Filter packages...' searchPlaceholder: 'Filter packages...'
} }
} }
export const CustomMaxHeight: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected1, selected2, selected3, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<MultiSelect
v-model="selected1"
:options="manyOptions"
label="Small Dropdown"
list-max-height="10rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<MultiSelect
v-model="selected2"
:options="manyOptions"
label="Default Dropdown"
list-max-height="28rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<MultiSelect
v-model="selected3"
:options="manyOptions"
label="Large Dropdown"
list-max-height="32rem"
show-selected-count
/>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}

View File

@@ -1,10 +1,9 @@
<template> <template>
<!-- <!--
Note: Unlike SingleSelect, we don't need an explicit options prop because: Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection) 1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels 2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs" 3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels max-selected-labels="0" is required to show count badge instead of selected item labels
--> -->
@@ -20,12 +19,13 @@
v-if="showSearchBox || showSelectedCount || showClearButton" v-if="showSearchBox || showSelectedCount || showClearButton"
#header #header
> >
<div class="p-2 flex flex-col pb-0"> <div class="pt-2 pb-0 px-2 flex flex-col">
<SearchBox <SearchBox
v-if="showSearchBox" v-if="showSearchBox"
v-model="searchQuery" v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''" :class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:show-order="true" :show-order="true"
:show-border="true"
:place-holder="searchPlaceholder" :place-holder="searchPlaceholder"
/> />
<div <div
@@ -47,11 +47,11 @@
:label="$t('g.clearAll')" :label="$t('g.clearAll')"
type="transparent" type="transparent"
size="fit-content" size="fit-content"
class="text-sm text-blue-500! dark-theme:text-blue-600!" class="text-sm text-blue-500 dark-theme:text-blue-600"
@click.stop="selectedItems = []" @click.stop="selectedItems = []"
/> />
</div> </div>
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div> <div class="my-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div> </div>
</template> </template>
@@ -75,13 +75,13 @@
<!-- Custom option row: square checkbox + label (unchanged layout/colors) --> <!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps"> <template #option="slotProps">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2" :style="popoverStyle">
<div <div
class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200" class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200"
:class=" :class="
slotProps.selected slotProps.selected
? 'border-[3px] border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500' ? 'bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'border-[1px] border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700' : 'bg-neutral-100 dark-theme:bg-zinc-700'
" "
> >
<i-lucide:check <i-lucide:check
@@ -89,9 +89,11 @@
class="text-xs text-bold text-white" class="text-xs text-bold text-white"
/> />
</div> </div>
<Button class="border-none outline-none bg-transparent" unstyled>{{ <Button
slotProps.option.name class="border-none outline-none bg-transparent text-left"
}}</Button> unstyled
>{{ slotProps.option.name }}</Button
>
</div> </div>
</template> </template>
</MultiSelect> </MultiSelect>
@@ -105,6 +107,8 @@ import MultiSelect, {
import { computed } from 'vue' import { computed } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue' import SearchBox from '@/components/input/SearchBox.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import TextButton from '../button/TextButton.vue' import TextButton from '../button/TextButton.vue'
@@ -125,6 +129,12 @@ interface Props {
showClearButton?: boolean showClearButton?: boolean
/** Placeholder for the search input */ /** Placeholder for the search input */
searchPlaceholder?: string searchPlaceholder?: string
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
// Note: options prop is intentionally omitted. // Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility // It's passed via $attrs to maximize PrimeVue API compatibility
} }
@@ -133,7 +143,10 @@ const {
showSearchBox = false, showSearchBox = false,
showSelectedCount = false, showSelectedCount = false,
showClearButton = false, showClearButton = false,
searchPlaceholder = 'Search...' searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>() } = defineProps<Props>()
const selectedItems = defineModel<Option[]>({ const selectedItems = defineModel<Option[]>({
@@ -142,10 +155,15 @@ const selectedItems = defineModel<Option[]>({
const searchQuery = defineModel<string>('searchQuery') const searchQuery = defineModel<string>('searchQuery')
const selectedCount = computed(() => selectedItems.value.length) const selectedCount = computed(() => selectedItems.value.length)
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const pt = computed(() => ({ const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [ class: [
'relative inline-flex cursor-pointer select-none', 'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white', 'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid', 'border-[2.5px] border-solid',
@@ -170,16 +188,26 @@ const pt = computed(() => ({
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden' showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
}), }),
// Overlay & list visuals unchanged // Overlay & list visuals unchanged
overlay: overlay: {
'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', class: cn(
'mt-2 rounded-lg py-2 px-2',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
)
},
listContainer: () => ({
style: { maxHeight: listMaxHeight },
class: 'overflow-y-auto scrollbar-hide'
}),
list: { list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs' class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
}, },
// Option row hover and focus tone // Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({ option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: [ class: [
'flex gap-1 items-center p-2', 'flex gap-2 items-center h-10 px-2 rounded-lg',
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50', 'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Add focus/highlight state for keyboard navigation // Add focus/highlight state for keyboard navigation
{ {
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
@@ -189,11 +217,11 @@ const pt = computed(() => ({
// Hide built-in checkboxes entirely via PT (no :deep) // Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: { pcHeaderCheckbox: {
root: { class: 'hidden' }, root: { class: 'hidden' },
style: 'display: none !important' style: { display: 'none' }
}, },
pcOptionCheckbox: { pcOptionCheckbox: {
root: { class: 'hidden' }, root: { class: 'hidden' },
style: 'display: none !important' style: { display: 'none' }
} }
})) }))
</script> </script>

View File

@@ -14,11 +14,17 @@ const meta: Meta<typeof SearchBox> = {
showBorder: { showBorder: {
control: 'boolean', control: 'boolean',
description: 'Toggle border prop' description: 'Toggle border prop'
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
} }
}, },
args: { args: {
placeHolder: 'Search...', placeHolder: 'Search...',
showBorder: false showBorder: false,
size: 'md'
} }
} }
@@ -53,3 +59,27 @@ export const NoBorder: Story = {
showBorder: false showBorder: false
} }
} }
export const MediumSize: Story = {
...Default,
args: {
size: 'md',
showBorder: false
}
}
export const LargeSize: Story = {
...Default,
args: {
size: 'lg',
showBorder: false
}
}
export const LargeSizeWithBorder: Story = {
...Default,
args: {
size: 'lg',
showBorder: true
}
}

View File

@@ -6,7 +6,7 @@
:placeholder="placeHolder || 'Search...'" :placeholder="placeHolder || 'Search...'"
type="text" type="text"
unstyled unstyled
class="w-full p-0 border-none outline-hidden bg-transparent text-xs text-neutral dark-theme:text-white" :class="inputStyle"
/> />
</div> </div>
</template> </template>
@@ -15,20 +15,56 @@
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import { computed } from 'vue' import { computed } from 'vue'
const { placeHolder, showBorder = false } = defineProps<{ import { cn } from '@/utils/tailwindUtil'
const {
placeHolder,
showBorder = false,
size = 'md'
} = defineProps<{
placeHolder?: string placeHolder?: string
showBorder?: boolean showBorder?: boolean
size?: 'md' | 'lg'
}>() }>()
// defineModel without arguments uses 'modelValue' as the prop name // defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>() const searchQuery = defineModel<string>()
const wrapperStyle = computed(() => { const wrapperStyle = computed(() => {
return showBorder const baseClasses = [
? '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' 'relative flex w-full items-center gap-2',
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800' 'bg-white dark-theme:bg-zinc-800',
'cursor-text'
]
if (showBorder) {
return cn(
...baseClasses,
'rounded p-2',
'border border-solid',
'border-zinc-200 dark-theme:border-zinc-700'
)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn(...baseClasses, 'rounded-lg', sizeClasses)
})
const inputStyle = computed(() => {
return cn(
'absolute inset-0 w-full h-full pl-11',
'border-none outline-none bg-transparent',
'text-sm text-neutral dark-theme:text-white'
)
}) })
const iconColorStyle = computed(() => { const iconColorStyle = computed(() => {
return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700' return cn(
!showBorder ? 'text-neutral' : ['text-zinc-300', 'dark-theme:text-zinc-700']
)
}) })
</script> </script>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ArrowUpDown } from 'lucide-vue-next'
import { ref } from 'vue' import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue' import SingleSelect from './SingleSelect.vue'
@@ -11,7 +10,19 @@ const meta: Meta<typeof SingleSelect> = {
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
label: { control: 'text' }, label: { control: 'text' },
options: { control: 'object' } options: { control: 'object' },
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
}, },
args: { args: {
label: 'Sorting Type', label: 'Sorting Type',
@@ -57,7 +68,7 @@ export const Default: Story = {
export const WithIcon: Story = { export const WithIcon: Story = {
render: () => ({ render: () => ({
components: { SingleSelect, ArrowUpDown }, components: { SingleSelect },
setup() { setup() {
const selected = ref<string | null>('popular') const selected = ref<string | null>('popular')
const options = sampleOptions const options = sampleOptions
@@ -67,7 +78,7 @@ export const WithIcon: Story = {
<div> <div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type"> <SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon> <template #icon>
<ArrowUpDown :size="14" /> <i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template> </template>
</SingleSelect> </SingleSelect>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded"> <div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
@@ -94,7 +105,7 @@ export const Preselected: Story = {
export const AllVariants: Story = { export const AllVariants: Story = {
render: () => ({ render: () => ({
components: { SingleSelect, ArrowUpDown }, components: { SingleSelect },
setup() { setup() {
const options = sampleOptions const options = sampleOptions
const a = ref<string | null>(null) const a = ref<string | null>(null)
@@ -110,7 +121,7 @@ export const AllVariants: Story = {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon"> <SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon> <template #icon>
<ArrowUpDown :size="14" /> <i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template> </template>
</SingleSelect> </SingleSelect>
</div> </div>
@@ -122,6 +133,124 @@ export const AllVariants: Story = {
}), }),
parameters: { parameters: {
controls: { disable: true }, controls: { disable: true },
actions: { disable: true } actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
} }
} }

View File

@@ -1,10 +1,9 @@
<template> <template>
<!-- <!--
Note: We explicitly pass options here (not just via $attrs) because: Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values 1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label 2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template 3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name option-label="name" is required because our option template directly accesses option.name
--> -->
<Select <Select
@@ -18,7 +17,7 @@
> >
<!-- Trigger value --> <!-- Trigger value -->
<template #value="slotProps"> <template #value="slotProps">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm text-neutral-500">
<slot name="icon" /> <slot name="icon" />
<span <span
v-if="slotProps.value !== null && slotProps.value !== undefined" v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -34,18 +33,19 @@
<!-- Trigger caret --> <!-- Trigger caret -->
<template #dropdownicon> <template #dropdownicon>
<i-lucide:chevron-down <i-lucide:chevron-down class="text-base text-neutral-500" />
class="text-base text-neutral-400 dark-theme:text-gray-300"
/>
</template> </template>
<!-- Option row --> <!-- Option row -->
<template #option="{ option, selected }"> <template #option="{ option, selected }">
<div class="flex items-center justify-between gap-3 w-full"> <div
class="flex items-center justify-between gap-3 w-full"
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span> <span class="truncate">{{ option.name }}</span>
<i-lucide:check <i-lucide:check
v-if="selected" v-if="selected"
class="text-neutral-900 dark-theme:text-white" class="text-neutral-600 dark-theme:text-white"
/> />
</div> </div>
</template> </template>
@@ -56,11 +56,19 @@
import Select, { SelectPassThroughMethodOptions } from 'primevue/select' import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
import { computed } from 'vue' import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ defineOptions({
inheritAttrs: false inheritAttrs: false
}) })
const { label, options } = defineProps<{ const {
label,
options,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string label?: string
/** /**
* Required for displaying the selected item's label. * Required for displaying the selected item's label.
@@ -71,6 +79,12 @@ const { label, options } = defineProps<{
name: string name: string
value: string value: string
}[] }[]
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
}>() }>()
const selectedItem = defineModel<string | null>({ required: true }) const selectedItem = defineModel<string | null>({ required: true })
@@ -87,6 +101,17 @@ const getLabel = (val: string | null | undefined) => {
return found ? found.name : label ?? '' return found ? found.name : label ?? ''
} }
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
})
/** /**
* Unstyled + PT API only * Unstyled + PT API only
* - No background/border (same as page background) * - No background/border (same as page background)
@@ -98,7 +123,7 @@ const pt = computed(() => ({
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({ }: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [ class: [
// container // container
'relative inline-flex cursor-pointer select-none items-center', 'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface // trigger surface
'rounded-md', 'rounded-md',
'bg-transparent text-neutral dark-theme:text-white', 'bg-transparent text-neutral dark-theme:text-white',
@@ -118,23 +143,28 @@ const pt = computed(() => ({
'flex shrink-0 items-center justify-center px-3 py-2' 'flex shrink-0 items-center justify-center px-3 py-2'
}, },
overlay: { overlay: {
class: [ class: cn(
// dropdown panel 'mt-2 p-2 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' 'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
] 'border border-solid border-neutral-200 dark-theme:border-zinc-700'
)
}, },
listContainer: () => ({
style: `max-height: ${listMaxHeight}`,
class: 'overflow-y-auto scrollbar-hide'
}),
list: { list: {
class: class:
// Same list tone/size as MultiSelect // Same list tone/size as MultiSelect
'flex flex-col gap-1 p-0 list-none border-none text-xs' 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
}, },
option: ({ option: ({
context context
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({ }: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [ class: [
// Row layout // Row layout
'flex items-center justify-between gap-3 px-3 py-2', 'flex items-center justify-between gap-3 px-2 py-3 rounded',
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50', 'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Selected state + check icon // Selected state + check icon
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected }, { 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
// Add focus state for keyboard navigation // Add focus state for keyboard navigation

View File

@@ -1,5 +1,5 @@
<template> <template>
<BaseWidgetLayout :content-title="$t('Checkpoints')"> <BaseModalLayout :content-title="$t('Checkpoints')">
<template #leftPanel> <template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation"> <LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon> <template #header-icon>
@@ -12,7 +12,7 @@
</template> </template>
<template #header> <template #header>
<SearchBox v-model="searchQuery" class="max-w-[384px]" /> <SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template> </template>
<template #header-right-area> <template #header-right-area>
@@ -56,7 +56,7 @@
</template> </template>
<template #contentFilter> <template #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2"> <div class="relative px-6 pb-4 flex gap-2">
<MultiSelect <MultiSelect
v-model="selectedFrameworks" v-model="selectedFrameworks"
v-model:search-query="searchText" v-model:search-query="searchText"
@@ -87,16 +87,8 @@
<template #content> <template #content>
<!-- Card Examples --> <!-- Card Examples -->
<!-- <div class="min-h-0 px-6 py-4 overflow-y-auto scrollbar-hide"> --> <div :style="gridStyle">
<!-- <h2 class="text-xxl py-4 pt-0 m-0">{{ $t('Checkpoints') }}</h2> --> <CardContainer v-for="i in 100" :key="i" ratio="square">
<div class="flex flex-wrap gap-2">
<CardContainer
v-for="i in 100"
:key="i"
ratio="square"
:max-width="480"
:min-width="230"
>
<template #top> <template #top>
<CardTop ratio="landscape"> <CardTop ratio="landscape">
<template #default> <template #default>
@@ -126,17 +118,16 @@
</template> </template>
</CardContainer> </CardContainer>
</div> </div>
<!-- </div> -->
</template> </template>
<template #rightPanel> <template #rightPanel>
<RightSidePanel></RightSidePanel> <RightSidePanel></RightSidePanel>
</template> </template>
</BaseWidgetLayout> </BaseModalLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { provide, ref, watch } from 'vue' import { computed, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue' import IconButton from '@/components/button/IconButton.vue'
@@ -149,11 +140,12 @@ import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue' import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue' import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue' import SingleSelect from '@/components/input/SingleSelect.vue'
import BaseWidgetLayout from '@/components/widget/layout/BaseWidgetLayout.vue' import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue' import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue' import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
import { NavGroupData, NavItemData } from '@/types/navTypes' import { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes' import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
const frameworkOptions = ref([ const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' }, { name: 'Vue', value: 'vue' },
@@ -175,20 +167,20 @@ const sortOptions = ref([
]) ])
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([ const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' }, { id: 'installed', label: 'Installed', icon: 'icon-[lucide--download]' },
{ {
title: 'TAGS', title: 'TAGS',
items: [ items: [
{ id: 'tag-sd15', label: 'SD 1.5' }, { id: 'tag-sd15', label: 'SD 1.5', icon: 'icon-[lucide--tag]' },
{ id: 'tag-sdxl', label: 'SDXL' }, { id: 'tag-sdxl', label: 'SDXL', icon: 'icon-[lucide--tag]' },
{ id: 'tag-utility', label: 'Utility' } { id: 'tag-utility', label: 'Utility', icon: 'icon-[lucide--tag]' }
] ]
}, },
{ {
title: 'CATEGORIES', title: 'CATEGORIES',
items: [ items: [
{ id: 'cat-models', label: 'Models' }, { id: 'cat-models', label: 'Models', icon: 'icon-[lucide--layers]' },
{ id: 'cat-nodes', label: 'Nodes' } { id: 'cat-nodes', label: 'Nodes', icon: 'icon-[lucide--grid-3x3]' }
] ]
} }
]) ])
@@ -209,6 +201,8 @@ const selectedSort = ref<string>('popular')
const selectedNavItem = ref<string | null>('installed') const selectedNavItem = ref<string | null>('installed')
const gridStyle = computed(() => createGridStyle())
watch(searchText, (newQuery) => { watch(searchText, (newQuery) => {
console.log('searchText:', searchText.value, newQuery) console.log('searchText:', searchText.value, newQuery)
}) })

View File

@@ -1,20 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { import { computed, provide, ref } from 'vue'
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 IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue' import IconTextButton from '@/components/button/IconTextButton.vue'
@@ -28,10 +13,11 @@ import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue' import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes' import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes' import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
import LeftSidePanel from '../panel/LeftSidePanel.vue' import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue' import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseWidgetLayout from './BaseWidgetLayout.vue' import BaseModalLayout from './BaseModalLayout.vue'
interface StoryArgs { interface StoryArgs {
contentTitle: string contentTitle: string
@@ -44,7 +30,7 @@ interface StoryArgs {
} }
const meta: Meta<StoryArgs> = { const meta: Meta<StoryArgs> = {
title: 'Components/Widget/Layout/BaseWidgetLayout', title: 'Components/Widget/Layout/BaseModalLayout',
argTypes: { argTypes: {
contentTitle: { contentTitle: {
control: 'text', control: 'text',
@@ -82,7 +68,7 @@ type Story = StoryObj<typeof meta>
const createStoryTemplate = (args: StoryArgs) => ({ const createStoryTemplate = (args: StoryArgs) => ({
components: { components: {
BaseWidgetLayout, BaseModalLayout,
LeftSidePanel, LeftSidePanel,
RightSidePanel, RightSidePanel,
SearchBox, SearchBox,
@@ -94,20 +80,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer, CardContainer,
CardTop, CardTop,
CardBottom, CardBottom,
SquareChip, SquareChip
Settings,
Upload,
Download,
Scroll,
Info,
Filter,
Folder,
Puzzle,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X
}, },
setup() { setup() {
const t = (k: string) => k const t = (k: string) => k
@@ -118,20 +91,44 @@ const createStoryTemplate = (args: StoryArgs) => ({
provide(OnCloseKey, onClose) provide(OnCloseKey, onClose)
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([ const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' }, {
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--folder]'
},
{ {
title: 'TAGS', title: 'TAGS',
items: [ items: [
{ id: 'tag-sd15', label: 'SD 1.5' }, {
{ id: 'tag-sdxl', label: 'SDXL' }, id: 'tag-sd15',
{ id: 'tag-utility', label: 'Utility' } label: 'SD 1.5',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: 'icon-[lucide--tag]'
}
] ]
}, },
{ {
title: 'CATEGORIES', title: 'CATEGORIES',
items: [ items: [
{ id: 'cat-models', label: 'Models' }, {
{ id: 'cat-nodes', label: 'Nodes' } id: 'cat-models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
] ]
} }
]) ])
@@ -160,6 +157,8 @@ const createStoryTemplate = (args: StoryArgs) => ({
const selectedProjects = ref<string[]>([]) const selectedProjects = ref<string[]>([])
const selectedSort = ref<string>('popular') const selectedSort = ref<string>('popular')
const gridStyle = computed(() => createGridStyle())
return { return {
args, args,
t, t,
@@ -171,17 +170,18 @@ const createStoryTemplate = (args: StoryArgs) => ({
sortOptions, sortOptions,
selectedFrameworks, selectedFrameworks,
selectedProjects, selectedProjects,
selectedSort selectedSort,
gridStyle
} }
}, },
template: ` template: `
<div> <div>
<BaseWidgetLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'"> <BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<!-- Left Panel --> <!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel> <template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation"> <LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon> <template #header-icon>
<Puzzle :size="16" class="text-neutral" /> <i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template> </template>
<template #header-title> <template #header-title>
<span class="text-neutral text-base">Title</span> <span class="text-neutral text-base">Title</span>
@@ -193,6 +193,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasHeader" #header> <template v-if="args.hasHeader" #header>
<SearchBox <SearchBox
class="max-w-[384px]" class="max-w-[384px]"
size="lg"
:modelValue="searchQuery" :modelValue="searchQuery"
@update:modelValue="searchQuery = $event" @update:modelValue="searchQuery = $event"
/> />
@@ -203,7 +204,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2"> <div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}"> <IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon> <template #icon>
<Upload :size="12" /> <i class="icon-[lucide--upload] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
@@ -215,7 +216,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<Download :size="12" /> <i class="icon-[lucide--download] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
@@ -225,7 +226,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<Scroll :size="12" /> <i class="icon-[lucide--scroll] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
</template> </template>
@@ -235,7 +236,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content Filter --> <!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter> <template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2"> <div class="relative px-6 py-4 flex gap-2">
<MultiSelect <MultiSelect
v-model="selectedFrameworks" v-model="selectedFrameworks"
label="Select Frameworks" label="Select Frameworks"
@@ -256,7 +257,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]" class="w-[135px]"
> >
<template #icon> <template #icon>
<Filter :size="12" /> <i class="icon-[lucide--filter] size-3" />
</template> </template>
</SingleSelect> </SingleSelect>
</div> </div>
@@ -264,7 +265,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content --> <!-- Content -->
<template #content> <template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))"> <div :style="gridStyle">
<CardContainer <CardContainer
v-for="i in args.cardCount" v-for="i in args.cardCount"
:key="i" :key="i"
@@ -277,7 +278,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template> </template>
<template #top-right> <template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}"> <IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" /> <i class="icon-[lucide--info] size-4" />
</IconButton> </IconButton>
</template> </template>
<template #bottom-right> <template #bottom-right>
@@ -285,7 +286,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" /> <SquareChip label="1.2 MB" />
<SquareChip label="LoRA"> <SquareChip label="LoRA">
<template #icon> <template #icon>
<Folder :size="12" /> <i class="icon-[lucide--folder] size-3" />
</template> </template>
</SquareChip> </SquareChip>
</template> </template>
@@ -297,15 +298,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
</CardContainer> </CardContainer>
</div> </div>
</template> </template>
</BaseWidgetLayout> </BaseModalLayout>
<BaseWidgetLayout v-else :content-title="args.contentTitle || 'Content Title'"> <BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
<!-- Same content but WITH right panel --> <!-- Same content but WITH right panel -->
<!-- Left Panel --> <!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel> <template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation"> <LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon> <template #header-icon>
<Puzzle :size="16" class="text-neutral" /> <i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template> </template>
<template #header-title> <template #header-title>
<span class="text-neutral text-base">Title</span> <span class="text-neutral text-base">Title</span>
@@ -317,6 +318,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasHeader" #header> <template v-if="args.hasHeader" #header>
<SearchBox <SearchBox
class="max-w-[384px]" class="max-w-[384px]"
size="lg"
:modelValue="searchQuery" :modelValue="searchQuery"
@update:modelValue="searchQuery = $event" @update:modelValue="searchQuery = $event"
/> />
@@ -327,7 +329,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2"> <div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}"> <IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon> <template #icon>
<Upload :size="12" /> <i class="icon-[lucide--upload] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
@@ -339,7 +341,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<Download :size="12" /> <i class="icon-[lucide--download] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
@@ -349,7 +351,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<Scroll :size="12" /> <i class="icon-[lucide--scroll] size-3" />
</template> </template>
</IconTextButton> </IconTextButton>
</template> </template>
@@ -359,7 +361,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content Filter --> <!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter> <template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2"> <div class="relative px-6 py-4 flex gap-2">
<MultiSelect <MultiSelect
v-model="selectedFrameworks" v-model="selectedFrameworks"
label="Select Frameworks" label="Select Frameworks"
@@ -377,7 +379,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]" class="w-[135px]"
> >
<template #icon> <template #icon>
<Filter :size="12" /> <i class="icon-[lucide--filter] size-3" />
</template> </template>
</SingleSelect> </SingleSelect>
</div> </div>
@@ -385,7 +387,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content --> <!-- Content -->
<template #content> <template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))"> <div :style="gridStyle">
<CardContainer <CardContainer
v-for="i in args.cardCount" v-for="i in args.cardCount"
:key="i" :key="i"
@@ -398,7 +400,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template> </template>
<template #top-right> <template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}"> <IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" /> <i class="icon-[lucide--info] size-4" />
</IconButton> </IconButton>
</template> </template>
<template #bottom-right> <template #bottom-right>
@@ -406,7 +408,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" /> <SquareChip label="1.2 MB" />
<SquareChip label="LoRA"> <SquareChip label="LoRA">
<template #icon> <template #icon>
<Folder :size="12" /> <i class="icon-[lucide--folder] size-3" />
</template> </template>
</SquareChip> </SquareChip>
</template> </template>
@@ -423,7 +425,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template #rightPanel> <template #rightPanel>
<RightSidePanel /> <RightSidePanel />
</template> </template>
</BaseWidgetLayout> </BaseModalLayout>
</div> </div>
` `
}) })

View File

@@ -1,21 +1,13 @@
<template> <template>
<div <div :class="layoutClasses">
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-50 dark-theme:bg-zinc-800"
>
<IconButton <IconButton
v-show="!isRightPanelOpen && hasRightPanel" v-show="!isRightPanelOpen && hasRightPanel"
class="absolute top-4 right-16 z-10 transition-opacity duration-200" :class="rightPanelButtonClasses"
:class="{
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
}"
@click="toggleRightPanel" @click="toggleRightPanel"
> >
<i-lucide:panel-right class="text-sm" /> <i-lucide:panel-right class="text-sm" />
</IconButton> </IconButton>
<IconButton <IconButton :class="closeButtonClasses" @click="closeDialog">
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
@click="closeDialog"
>
<i class="pi pi-times text-sm"></i> <i class="pi pi-times text-sm"></i>
</IconButton> </IconButton>
<div class="flex w-full h-full"> <div class="flex w-full h-full">
@@ -32,12 +24,9 @@
</nav> </nav>
</Transition> </Transition>
<div class="flex-1 flex bg-zinc-100 dark-theme:bg-neutral-900"> <div :class="mainContainerClasses">
<div class="w-full h-full flex flex-col"> <div class="w-full h-full flex flex-col">
<header <header v-if="$slots.header" :class="headerClasses">
v-if="$slots.header"
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
>
<div class="flex-1 flex gap-2 shrink-0"> <div class="flex-1 flex gap-2 shrink-0">
<IconButton v-if="!notMobile" @click="toggleLeftPanel"> <IconButton v-if="!notMobile" @click="toggleLeftPanel">
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" /> <i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
@@ -46,12 +35,7 @@
<slot name="header"></slot> <slot name="header"></slot>
</div> </div>
<slot name="header-right-area"></slot> <slot name="header-right-area"></slot>
<div <div :class="rightAreaClasses">
class="flex justify-end gap-2 w-0"
:class="
hasRightPanel && !isRightPanelOpen ? 'min-w-18' : 'min-w-8'
"
>
<IconButton <IconButton
v-if="isRightPanelOpen && hasRightPanel" v-if="isRightPanelOpen && hasRightPanel"
@click="toggleRightPanel" @click="toggleRightPanel"
@@ -67,14 +51,14 @@
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0"> <h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
{{ contentTitle }} {{ contentTitle }}
</h2> </h2>
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto scrollbar-hide"> <div :class="contentContainerClasses">
<slot name="content"></slot> <slot name="content"></slot>
</div> </div>
</main> </main>
</div> </div>
<aside <aside
v-if="hasRightPanel && isRightPanelOpen" v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80" :class="rightPanelClasses"
> >
<slot name="rightPanel"></slot> <slot name="rightPanel"></slot>
</aside> </aside>
@@ -89,6 +73,7 @@ import { computed, inject, ref, useSlots, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue' import IconButton from '@/components/button/IconButton.vue'
import { OnCloseKey } from '@/types/widgetTypes' import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { contentTitle } = defineProps<{ const { contentTitle } = defineProps<{
contentTitle: string contentTitle: string
@@ -137,6 +122,50 @@ const toggleLeftPanel = () => {
const toggleRightPanel = () => { const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value isRightPanelOpen.value = !isRightPanelOpen.value
} }
// Computed classes for better readability
const layoutClasses = cn(
'base-widget-layout',
'rounded-2xl overflow-hidden relative',
'bg-zinc-50 dark-theme:bg-zinc-800'
)
const rightPanelButtonClasses = computed(() => {
return cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
'opacity-0 pointer-events-none':
isRightPanelOpen.value || !hasRightPanel.value
})
})
const closeButtonClasses = cn(
'absolute top-4 right-6 z-10',
'transition-opacity duration-200'
)
const mainContainerClasses = cn(
'flex-1 flex',
'bg-zinc-100 dark-theme:bg-neutral-900'
)
const headerClasses = cn(
'w-full h-18 px-6',
'flex items-center justify-between gap-2'
)
const rightAreaClasses = computed(() => {
return cn(
'flex justify-end gap-2 w-0',
hasRightPanel.value && !isRightPanelOpen.value ? 'min-w-22' : 'min-w-10'
)
})
const contentContainerClasses = computed(() => {
return cn('min-h-0 px-6 pt-0 pb-10', 'overflow-y-auto scrollbar-hide')
})
const rightPanelClasses = computed(() => {
return cn('w-1/4 min-w-40 max-w-80')
})
</script> </script>
<style scoped> <style scoped>
.base-widget-layout { .base-widget-layout {

View File

@@ -0,0 +1,11 @@
<template>
<i :class="icon" class="text-xs text-neutral" />
</template>
<script setup lang="ts">
import { NavItemData } from '@/types/navTypes'
defineProps<{
icon: NavItemData['icon']
}>()
</script>

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import NavItem from './NavItem.vue'
const meta: Meta<typeof NavItem> = {
title: 'Components/Widget/Nav/NavItem',
component: NavItem,
argTypes: {
icon: {
control: 'select',
description: 'Icon component to display'
},
active: {
control: 'boolean',
description: 'Active state of the nav item'
},
onClick: {
table: { disable: true }
},
default: {
control: 'text',
description: 'Text content for the nav item'
}
},
args: {
active: false,
onClick: () => {},
default: 'Navigation Item'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const InteractiveList: Story = {
render: () => ({
components: { NavItem },
template: `
<div class="space-y-1">
<NavItem
v-for="item in items"
:key="item.id"
:icon="item.icon"
:active="selectedId === item.id"
:on-click="() => selectedId = item.id"
>
{{ item.label }}
</NavItem>
</div>
`,
data() {
return {
selectedId: 'downloads'
}
},
setup() {
const items = [
{
id: 'downloads',
label: 'Downloads',
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
},
{
id: 'tags',
label: 'Tags',
icon: 'icon-[lucide--tag]'
},
{
id: 'settings',
label: 'Settings',
icon: 'icon-[lucide--wrench]'
},
{
id: 'default',
label: 'Default Icon',
icon: 'icon-[lucide--folder]'
}
]
return { items }
}
}),
parameters: {
controls: { disable: true }
}
}

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex items-center gap-2 px-4 py-2 text-xs rounded-md transition-colors cursor-pointer" class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
:class=" :class="
active active
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral' ? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
@@ -9,7 +9,8 @@
role="button" role="button"
@click="onClick" @click="onClick"
> >
<i-lucide:folder v-if="hasFolderIcon" class="text-xs text-neutral" /> <NavIcon v-if="icon" :icon="icon" />
<i-lucide:folder v-else class="text-xs text-neutral" />
<span class="flex items-center"> <span class="flex items-center">
<slot></slot> <slot></slot>
</span> </span>
@@ -17,12 +18,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { import { NavItemData } from '@/types/navTypes'
hasFolderIcon = true,
active, import NavIcon from './NavIcon.vue'
onClick
} = defineProps<{ const { icon, active, onClick } = defineProps<{
hasFolderIcon?: boolean icon: NavItemData['icon']
active?: boolean active?: boolean
onClick: () => void onClick: () => void
}>() }>()

View File

@@ -1,6 +1,6 @@
<template> <template>
<h3 <h3
class="m-0 px-3 py-0 pt-5 text-xxs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400" class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
> >
{{ title }} {{ title }}
</h3> </h3>

View File

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

View File

@@ -0,0 +1,242 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import LeftSidePanel from './LeftSidePanel.vue'
const meta: Meta<typeof LeftSidePanel> = {
title: 'Components/Widget/Panel/LeftSidePanel',
component: LeftSidePanel,
argTypes: {
'header-icon': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'header-title': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'onUpdate:modelValue': {
table: { disable: true }
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: 'installed',
navItems: [
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
</template>
</LeftSidePanel>
</div>
`
})
}
export const WithGroups: Story = {
args: {
modelValue: 'tag-sd15',
navItems: [
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--download]'
},
{
title: 'TAGS',
items: [
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: 'icon-[lucide--tag]'
}
]
},
{
title: 'CATEGORIES',
items: [
{
id: 'cat-models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
</template>
</LeftSidePanel>
<div class="mt-4 p-2 text-sm">
Selected: {{ selectedItem }}
</div>
</div>
`
})
}
export const DefaultIcons: Story = {
args: {
modelValue: 'home',
navItems: [
{
id: 'home',
label: 'Home',
icon: 'icon-[lucide--folder]'
},
{
id: 'documents',
label: 'Documents',
icon: 'icon-[lucide--folder]'
},
{
id: 'downloads',
label: 'Downloads',
icon: 'icon-[lucide--folder]'
},
{
id: 'desktop',
label: 'Desktop',
icon: 'icon-[lucide--folder]'
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
</template>
</LeftSidePanel>
</div>
`
})
}
export const LongLabels: Story = {
args: {
modelValue: 'general',
navItems: [
{
id: 'general',
label: 'General Settings',
icon: 'icon-[lucide--wrench]'
},
{
id: 'appearance',
label: 'Appearance & Themes Configuration',
icon: 'icon-[lucide--wrench]'
},
{
title: 'ADVANCED OPTIONS',
items: [
{
id: 'performance',
label: 'Performance & Optimization Settings',
icon: 'icon-[lucide--zap]'
},
{
id: 'experimental',
label: 'Experimental Features (Beta)',
icon: 'icon-[lucide--puzzle]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>
</template>
</LeftSidePanel>
</div>
`
})
}

View File

@@ -14,6 +14,7 @@
<NavItem <NavItem
v-for="subItem in item.items" v-for="subItem in item.items"
:key="subItem.id" :key="subItem.id"
:icon="subItem.icon"
:active="activeItem === subItem.id" :active="activeItem === subItem.id"
@click="activeItem = subItem.id" @click="activeItem = subItem.id"
> >
@@ -22,6 +23,7 @@
</div> </div>
<div v-else class="flex flex-col gap-2"> <div v-else class="flex flex-col gap-2">
<NavItem <NavItem
:icon="item.icon"
:active="activeItem === item.id" :active="activeItem === item.id"
@click="activeItem = item.id" @click="activeItem = item.id"
> >

View File

@@ -123,12 +123,14 @@ export function useSelectedLiteGraphItems() {
for (const i in selectedNodes) { for (const i in selectedNodes) {
selectedNodeArray.push(selectedNodes[i]) selectedNodeArray.push(selectedNodes[i])
} }
const allNodesMatch = !selectedNodeArray.some(
(selectedNode) => selectedNode.mode !== mode
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
// Process each selected node independently to determine its target state and apply to children // Process each selected node independently to determine its target state and apply to children
selectedNodeArray.forEach((selectedNode) => { selectedNodeArray.forEach((selectedNode) => {
// Apply standard toggle logic to the selected node itself // Apply standard toggle logic to the selected node itself
const newModeForSelectedNode =
selectedNode.mode === mode ? LGraphEventMode.ALWAYS : mode
selectedNode.mode = newModeForSelectedNode selectedNode.mode = newModeForSelectedNode

View File

@@ -24,14 +24,12 @@ export function useCanvasInteractions() {
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
// In standard mode, Ctrl+wheel should go to canvas for zoom // In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) { if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
event.preventDefault() // Prevent browser zoom
forwardEventToCanvas(event) forwardEventToCanvas(event)
return return
} }
// In legacy mode, all wheel events go to canvas for zoom // In legacy mode, all wheel events go to canvas for zoom
if (!isStandardNavMode.value) { if (!isStandardNavMode.value) {
event.preventDefault()
forwardEventToCanvas(event) forwardEventToCanvas(event)
return return
} }
@@ -68,9 +66,30 @@ export function useCanvasInteractions() {
) => { ) => {
const canvasEl = app.canvas?.canvas const canvasEl = app.canvas?.canvas
if (!canvasEl) return if (!canvasEl) return
event.preventDefault()
event.stopPropagation()
if (event instanceof WheelEvent) {
const { clientX, clientY, deltaX, deltaY, ctrlKey, metaKey, shiftKey } =
event
canvasEl.dispatchEvent(
new WheelEvent('wheel', {
clientX,
clientY,
deltaX,
deltaY,
ctrlKey,
metaKey,
shiftKey
})
)
return
}
// Create new event with same properties // Create new event with same properties
const EventConstructor = event.constructor as typeof WheelEvent const EventConstructor = event.constructor as
| typeof MouseEvent
| typeof PointerEvent
const newEvent = new EventConstructor(event.type, event) const newEvent = new EventConstructor(event.type, event)
canvasEl.dispatchEvent(newEvent) canvasEl.dispatchEvent(newEvent)
} }

View File

@@ -1511,6 +1511,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return 'Token-based' return 'Token-based'
} }
}, },
ByteDanceSeedreamNode: {
displayPrice: (node: LGraphNode): string => {
const sequentialGenerationWidget = node.widgets?.find(
(w) => w.name === 'sequential_image_generation'
) as IComboWidget
const maxImagesWidget = node.widgets?.find(
(w) => w.name === 'max_images'
) as IComboWidget
if (!sequentialGenerationWidget || !maxImagesWidget)
return '$0.03/Run ($0.03 for one output image)'
if (
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
) {
return '$0.03/Run'
}
const maxImages = Number(maxImagesWidget.value)
if (maxImages === 1) {
return '$0.03/Run'
}
const cost = (0.03 * maxImages).toFixed(2)
return `$${cost}/Run ($0.03 for one output image)`
}
},
ByteDanceTextToVideoNode: { ByteDanceTextToVideoNode: {
displayPrice: byteDanceVideoPricingCalculator displayPrice: byteDanceVideoPricingCalculator
}, },
@@ -1613,6 +1639,11 @@ export const useNodePricing = () => {
// ByteDance // ByteDance
ByteDanceImageNode: ['model'], ByteDanceImageNode: ['model'],
ByteDanceImageEditNode: ['model'], ByteDanceImageEditNode: ['model'],
ByteDanceSeedreamNode: [
'model',
'sequential_image_generation',
'max_images'
],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'], ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'], ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'], ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],

View File

@@ -44,9 +44,24 @@ export const useUpdateAvailableNodes = () => {
return filterOutdatedPacks(installedPacks.value) return filterOutdatedPacks(installedPacks.value)
}) })
// Check if there are any outdated packs // Filter only enabled outdated packs
const enabledUpdateAvailableNodePacks = computed(() => {
return updateAvailableNodePacks.value.filter((pack) =>
comfyManagerStore.isPackEnabled(pack.id)
)
})
// Check if there are any enabled outdated packs
const hasUpdateAvailable = computed(() => { const hasUpdateAvailable = computed(() => {
return updateAvailableNodePacks.value.length > 0 return enabledUpdateAvailableNodePacks.value.length > 0
})
// Check if there are disabled packs with updates
const hasDisabledUpdatePacks = computed(() => {
return (
updateAvailableNodePacks.value.length >
enabledUpdateAvailableNodePacks.value.length
)
}) })
// Automatically fetch installed pack data when composable is used // Automatically fetch installed pack data when composable is used
@@ -58,7 +73,9 @@ export const useUpdateAvailableNodes = () => {
return { return {
updateAvailableNodePacks, updateAvailableNodePacks,
enabledUpdateAvailableNodePacks,
hasUpdateAvailable, hasUpdateAvailable,
hasDisabledUpdatePacks,
isLoading, isLoading,
error error
} }

View File

@@ -0,0 +1,30 @@
import { type CSSProperties, type ComputedRef, computed } from 'vue'
interface PopoverSizeOptions {
minWidth?: string
maxWidth?: string
}
/**
* Composable for managing popover sizing styles
* @param options Popover size configuration
* @returns Computed style object for popover sizing
*/
export function usePopoverSizing(
options: PopoverSizeOptions
): ComputedRef<CSSProperties> {
return computed(() => {
const { minWidth, maxWidth } = options
const style: CSSProperties = {}
if (minWidth) {
style.minWidth = minWidth
}
if (maxWidth) {
style.maxWidth = maxWidth
}
return style
})
}

View File

@@ -0,0 +1,48 @@
import type { HintedString } from '@primevue/core'
import { computed } from 'vue'
/**
* Options for configuring transform-compatible overlay props
*/
interface TransformCompatOverlayOptions {
/**
* Where to append the overlay. 'self' keeps overlay within component
* for proper transform inheritance, 'body' teleports to document body
*/
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
// Future: other props needed for transform compatibility
// scrollTarget?: string | HTMLElement
// autoZIndex?: boolean
}
/**
* Composable that provides props to make PrimeVue overlay components
* compatible with CSS-transformed parent elements.
*
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
* body by default, breaking transform inheritance. This composable provides
* the necessary props to keep overlays within their component elements.
*
* @param overrides - Optional overrides for specific use cases
* @returns Computed props object to spread on PrimeVue overlay components
*
* @example
* ```vue
* <template>
* <Select v-bind="overlayProps" />
* </template>
*
* <script setup>
* const overlayProps = useTransformCompatOverlayProps()
* </script>
* ```
*/
export function useTransformCompatOverlayProps(
overrides: TransformCompatOverlayOptions = {}
) {
return computed(() => ({
appendTo: 'self' as const,
...overrides
}))
}

View File

@@ -977,8 +977,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Assets.UseAssetAPI', id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library', name: 'Use Asset API for model library',
type: 'boolean', type: 'boolean',
tooltip: tooltip: 'Use new Asset API for model browsing',
'Use new asset API instead of experiment endpoints for model browsing',
defaultValue: false, defaultValue: false,
experimental: true experimental: true
} }

View File

@@ -6,6 +6,7 @@ import {
type LinkRenderContext, type LinkRenderContext,
LitegraphLinkAdapter LitegraphLinkAdapter
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter' } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { CanvasPointer } from './CanvasPointer' import { CanvasPointer } from './CanvasPointer'
@@ -5559,7 +5560,9 @@ export class LGraphCanvas
const link = graph._links.get(link_id) const link = graph._links.get(link_id)
if (!link) continue if (!link) continue
const endPos = node.getInputPos(i) const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(node, i, true)
: node.getInputPos(i)
// find link info // find link info
const start_node = graph.getNodeById(link.origin_id) const start_node = graph.getNodeById(link.origin_id)
@@ -5569,7 +5572,9 @@ export class LGraphCanvas
const startPos: Point = const startPos: Point =
outputId === -1 outputId === -1
? [start_node.pos[0] + 10, start_node.pos[1] + 10] ? [start_node.pos[0] + 10, start_node.pos[1] + 10]
: start_node.getOutputPos(outputId) : LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(start_node, outputId, false)
: start_node.getOutputPos(outputId)
const output = start_node.outputs[outputId] const output = start_node.outputs[outputId]
if (!output) continue if (!output) continue

View File

@@ -313,9 +313,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
widget: Readonly<IBaseWidget> widget: Readonly<IBaseWidget>
) { ) {
// Use the first matching widget // Use the first matching widget
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode( const targetWidget = toConcreteWidget(widget, this)
this const promotedWidget = targetWidget.createCopyForNode(this)
)
Object.assign(promotedWidget, { Object.assign(promotedWidget, {
get name() { get name() {
@@ -370,7 +369,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphNode: this subgraphNode: this
}) })
input.widget = { name: subgraphInput.name } // NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
const backingInput =
targetWidget.node.findInputSlot(widget.name, true)?.widget ?? {}
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
Object.setPrototypeOf(input.widget, backingInput)
input._widget = promotedWidget input._widget = promotedWidget
} }

View File

@@ -76,6 +76,7 @@ export type IWidget =
| IImageCompareWidget | IImageCompareWidget
| ISelectButtonWidget | ISelectButtonWidget
| ITextareaWidget | ITextareaWidget
| IAssetWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> { export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle' type: 'toggle'
@@ -224,6 +225,12 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string value: string
} }
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
type: 'asset'
value: string
}
/** /**
* Valid widget types. TS cannot provide easily extensible type safety for this at present. * Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[] * Override linkedWidgets[]

View File

@@ -0,0 +1,41 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IAssetWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget, type DrawWidgetOptions } from './BaseWidget'
export class AssetWidget
extends BaseWidget<IAssetWidget>
implements IAssetWidget
{
constructor(widget: IAssetWidget, node: LGraphNode) {
super(widget, node)
this.type ??= 'asset'
this.value = widget.value?.toString() ?? ''
}
override get _displayValue(): string {
return String(this.value) //FIXME: Resolve asset name
}
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
) {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, { width, showText })
if (showText) {
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
override onClick() {
//Open Modal
this.callback?.(this.value)
}
}

View File

@@ -7,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets' } from '@/lib/litegraph/src/types/widgets'
import { toClass } from '@/lib/litegraph/src/utils/type' import { toClass } from '@/lib/litegraph/src/utils/type'
import { AssetWidget } from './AssetWidget'
import { BaseWidget } from './BaseWidget' import { BaseWidget } from './BaseWidget'
import { BooleanWidget } from './BooleanWidget' import { BooleanWidget } from './BooleanWidget'
import { ButtonWidget } from './ButtonWidget' import { ButtonWidget } from './ButtonWidget'
@@ -47,6 +48,7 @@ export type WidgetTypeMap = {
imagecompare: ImageCompareWidget imagecompare: ImageCompareWidget
selectbutton: SelectButtonWidget selectbutton: SelectButtonWidget
textarea: TextareaWidget textarea: TextareaWidget
asset: AssetWidget
[key: string]: BaseWidget [key: string]: BaseWidget
} }
@@ -115,6 +117,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(SelectButtonWidget, narrowedWidget, node) return toClass(SelectButtonWidget, narrowedWidget, node)
case 'textarea': case 'textarea':
return toClass(TextareaWidget, narrowedWidget, node) return toClass(TextareaWidget, narrowedWidget, node)
case 'asset':
return toClass(AssetWidget, narrowedWidget, node)
default: { default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node) if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
} }

View File

@@ -193,6 +193,8 @@
"updateSelected": "Update Selected", "updateSelected": "Update Selected",
"updateAll": "Update All", "updateAll": "Update All",
"updatingAllPacks": "Updating all packages", "updatingAllPacks": "Updating all packages",
"disabledNodesWontUpdate": "Disabled nodes will not be updated",
"enablePackToChangeVersion": "Enable this pack to change versions",
"license": "License", "license": "License",
"nightlyVersion": "Nightly", "nightlyVersion": "Nightly",
"latestVersion": "Latest", "latestVersion": "Latest",
@@ -211,6 +213,7 @@
"noDescription": "No description available", "noDescription": "No description available",
"installSelected": "Install Selected", "installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes", "installAllMissingNodes": "Install All Missing Nodes",
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected", "packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection", "mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
"notAvailable": "Not Available", "notAvailable": "Not Available",
@@ -1470,6 +1473,8 @@
"missingModelsMessage": "When loading the graph, the following models were not found" "missingModelsMessage": "When loading the graph, the following models were not found"
}, },
"loadWorkflowWarning": { "loadWorkflowWarning": {
"missingNodesTitle": "Some Nodes Are Missing",
"missingNodesDescription": "When loading the graph, the following node types were not found.\nThis may also happen if your installed version is lower and that node type cant be found.",
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.", "outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.", "outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:" "coreNodesFromVersion": "Requires ComfyUI {version}:"
@@ -1757,6 +1762,9 @@
"copiedTooltip": "Copied", "copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard" "copyTooltip": "Copy message to clipboard"
}, },
"widgets": {
"selectModel": "Select model"
},
"nodeHelpPage": { "nodeHelpPage": {
"inputs": "Inputs", "inputs": "Inputs",
"outputs": "Outputs", "outputs": "Outputs",

View File

@@ -9,8 +9,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { import type {
INodeInputSlot, INodeInputSlot,
INodeOutputSlot, INodeOutputSlot,
Point, Point
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces' } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils' import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
@@ -138,7 +137,7 @@ export function getSlotPosition(
node: LGraphNode, node: LGraphNode,
slotIndex: number, slotIndex: number,
isInput: boolean isInput: boolean
): ReadOnlyPoint { ): Point {
// Try to get precise position from slot layout (DOM-registered) // Try to get precise position from slot layout (DOM-registered)
const slotKey = getSlotKey(String(node.id), slotIndex, isInput) const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
const slotLayout = layoutStore.getSlotLayout(slotKey) const slotLayout = layoutStore.getSlotLayout(slotKey)

View File

@@ -330,6 +330,7 @@ export interface LayoutStore {
batchUpdateNodeBounds( batchUpdateNodeBounds(
updates: Array<{ nodeId: NodeId; bounds: Bounds }> updates: Array<{ nodeId: NodeId; bounds: Bounds }>
): void ): void
batchUpdateSlotLayouts( batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }> updates: Array<{ key: string; layout: SlotLayout }>
): void ): void

View File

@@ -74,6 +74,10 @@ export const useTransformState = () => {
// Computed transform string for CSS // Computed transform string for CSS
const transformStyle = computed(() => ({ const transformStyle = computed(() => ({
// Match LiteGraph DragAndScale.toCanvasContext():
// ctx.scale(scale); ctx.translate(offset)
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
// Effective mapping: screen = (canvas + offset) * scale
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`, transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
transformOrigin: '0 0' transformOrigin: '0 0'
})) }))
@@ -103,15 +107,15 @@ export const useTransformState = () => {
* Applies the same transform that LiteGraph uses for rendering. * Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements. * Essential for positioning Vue components to align with canvas elements.
* *
* Formula: screen = canvas * scale + offset * Formula: screen = (canvas + offset) * scale
* *
* @param point - Point in canvas coordinate system * @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system * @returns Point in screen coordinate system
*/ */
const canvasToScreen = (point: Point): Point => { const canvasToScreen = (point: Point): Point => {
return { return {
x: point.x * camera.z + camera.x, x: (point.x + camera.x) * camera.z,
y: point.y * camera.z + camera.y y: (point.y + camera.y) * camera.z
} }
} }
@@ -121,15 +125,15 @@ export const useTransformState = () => {
* Inverse of canvasToScreen. Useful for hit testing and converting * Inverse of canvasToScreen. Useful for hit testing and converting
* mouse events back to canvas space. * mouse events back to canvas space.
* *
* Formula: canvas = (screen - offset) / scale * Formula: canvas = screen / scale - offset
* *
* @param point - Point in screen coordinate system * @param point - Point in screen coordinate system
* @returns Point in canvas coordinate system * @returns Point in canvas coordinate system
*/ */
const screenToCanvas = (point: Point): Point => { const screenToCanvas = (point: Point): Point => {
return { return {
x: (point.x - camera.x) / camera.z, x: point.x / camera.z - camera.x,
y: (point.y - camera.y) / camera.z y: point.y / camera.z - camera.y
} }
} }

View File

@@ -7,28 +7,34 @@
:data-node-id="nodeData.id" :data-node-id="nodeData.id"
:class=" :class="
cn( cn(
'bg-white dark-theme:bg-[#15161A]', 'bg-white dark-theme:bg-charcoal-100',
'min-w-[445px]', 'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl', 'lg-node absolute rounded-2xl',
'outline outline-transparent outline-2', // border
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
!!executing && 'border-blue-500 dark-theme:border-blue-500',
!!error && 'border-red-700 dark-theme:border-red-300',
// hover
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
// Selected
'outline-transparent -outline-offset-2 outline-2',
!!isSelected && 'outline-black dark-theme:outline-white',
!!(isSelected && executing) &&
'outline-blue-500 dark-theme:outline-blue-500',
!!(isSelected && error) && 'outline-red-500 dark-theme:outline-red-500',
{ {
'outline-black dark-theme:outline-white': isSelected
},
{
'border-blue-500 ring-2 ring-blue-300': isSelected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
'animate-pulse': executing, 'animate-pulse': executing,
'opacity-50': nodeData.mode === 4, 'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
'will-change-transform': isDragging 'will-change-transform': isDragging
}, },
lodCssClass lodCssClass,
'pointer-events-auto'
) )
" "
:style="[ :style="[
{ {
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`, transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
pointerEvents: 'auto' zIndex: zIndex
}, },
dragStyle dragStyle
]" ]"
@@ -53,8 +59,35 @@
/> />
</div> </div>
<div
v-if="
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
"
:class="
cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isMinimalLOD && !isCollapsed"> <template v-if="!isMinimalLOD && !isCollapsed">
<div :class="cn(separatorClasses, 'mb-4')" /> <div class="mb-4 relative">
<div :class="separatorClasses" />
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
</div>
<!-- Node Body - rendered based on LOD level and collapsed state --> <!-- Node Body - rendered based on LOD level and collapsed state -->
<div <div
@@ -99,13 +132,6 @@
/> />
</div> </div>
</template> </template>
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
:style="{ width: `${progress * 100}%` }"
/>
</div> </div>
</template> </template>
@@ -194,6 +220,7 @@ onErrorCaptured((error) => {
// Use layout system for node position and dragging // Use layout system for node position and dragging
const { const {
position: layoutPosition, position: layoutPosition,
zIndex,
startDrag, startDrag,
handleDrag: handleLayoutDrag, handleDrag: handleLayoutDrag,
endDrag endDrag
@@ -226,7 +253,9 @@ const hasCustomContent = computed(() => {
}) })
// Computed classes and conditions for better reusability // Computed classes and conditions for better reusability
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0' const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-300 h-[1px] mx-0 w-full'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
// Common condition computations to avoid repetition // Common condition computations to avoid repetition
const shouldShowWidgets = computed( const shouldShowWidgets = computed(

View File

@@ -0,0 +1,195 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { type PropType, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import enMessages from '@/locales/en/main.json'
import NodeSlots from './NodeSlots.vue'
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
id: '123',
title: 'Test Node',
type: 'TestType',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})
// Explicit stubs to capture props for assertions
interface StubSlotData {
name?: string
type?: string
boundingRect?: [number, number, number, number]
}
const InputSlotStub = defineComponent({
name: 'InputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-input-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const OutputSlotStub = defineComponent({
name: 'OutputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-output-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const mountSlots = (nodeData: VueNodeData, readonly = false) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(NodeSlots, {
global: {
plugins: [i18n, createPinia()],
stubs: {
InputSlot: InputSlotStub,
OutputSlot: OutputSlotStub
}
},
props: { nodeData, readonly }
})
}
describe('NodeSlots.vue', () => {
it('filters out inputs with widget property and maps indexes correctly', () => {
// Two inputs without widgets (object and string) and one with widget (filtered)
const inputObjNoWidget = {
name: 'objNoWidget',
type: 'number',
boundingRect: [0, 0, 0, 0]
}
const inputObjWithWidget = {
name: 'objWithWidget',
type: 'number',
boundingRect: [0, 0, 0, 0],
widget: { name: 'objWithWidget' }
}
const inputs = [inputObjNoWidget, inputObjWithWidget, 'stringInput']
const wrapper = mountSlots(makeNodeData({ inputs }))
const inputEls = wrapper
.findAll('.stub-input-slot')
.map((w) => w.element as HTMLElement)
// Should filter out the widget-backed input; expect 2 inputs rendered
expect(inputEls.length).toBe(2)
// Verify expected tuple of {index, name, nodeId}
const info = inputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(info).toEqual([
{
index: 0,
name: 'objNoWidget',
nodeId: '123',
type: 'number',
readonly: false
},
// string input is converted to object with default type 'any'
{
index: 1,
name: 'stringInput',
nodeId: '123',
type: 'any',
readonly: false
}
])
// Ensure widget-backed input was indeed filtered out
expect(wrapper.find('[data-name="objWithWidget"]').exists()).toBe(false)
})
it('maps outputs and passes correct indexes', () => {
const outputObj = { name: 'outA', type: 'any', boundingRect: [0, 0, 0, 0] }
const outputs = [outputObj, 'outB']
const wrapper = mountSlots(makeNodeData({ outputs }))
const outputEls = wrapper
.findAll('.stub-output-slot')
.map((w) => w.element as HTMLElement)
expect(outputEls.length).toBe(2)
const outInfo = outputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(outInfo).toEqual([
{ index: 0, name: 'outA', nodeId: '123', type: 'any', readonly: false },
// string output mapped to object with type 'any'
{ index: 1, name: 'outB', nodeId: '123', type: 'any', readonly: false }
])
})
it('renders nothing when there are no inputs/outputs', () => {
const wrapper = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
expect(wrapper.findAll('.stub-input-slot').length).toBe(0)
expect(wrapper.findAll('.stub-output-slot').length).toBe(0)
})
it('passes readonly to child slots', () => {
const wrapper = mountSlots(
makeNodeData({ inputs: ['a'], outputs: ['b'] }),
/* readonly */ true
)
const all = [
...wrapper
.findAll('.stub-input-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement),
...wrapper
.findAll('.stub-output-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement)
]
expect(all.length).toBe(2)
for (const el of all) {
expect.soft(el.dataset.readonly).toBe('true')
}
})
})

View File

@@ -11,8 +11,7 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useCanvasStore } from '@/stores/graphStore' import { useCanvasStore } from '@/stores/graphStore'
interface NodeManager { interface NodeManager {
@@ -21,7 +20,7 @@ interface NodeManager {
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) { export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations() const { bringNodeToFront } = useNodeZIndex()
/** /**
* Handle node selection events * Handle node selection events
@@ -51,8 +50,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
// Bring node to front when clicked (similar to LiteGraph behavior) // Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement // Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) { if (!node.flags?.pinned) {
layoutMutations.setSource(LayoutSource.Vue) bringNodeToFront(nodeData.id)
layoutMutations.bringNodeToFront(nodeData.id)
} }
// Update canvas selection tracking // Update canvas selection tracking
@@ -171,14 +169,13 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
if (!canvasStore.canvas || !nodeManager.value) return if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) { if (!addToSelection) {
canvasStore.canvas.deselectAllNodes() canvasStore.canvas.deselectAll()
} }
nodeIds.forEach((nodeId) => { nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId) const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) { if (node && canvasStore.canvas) {
canvasStore.canvas.selectNode(node) canvasStore.canvas.select(node)
node.selected = true
} }
}) })

View File

@@ -0,0 +1,36 @@
/**
* Node Z-Index Management Composable
*
* Provides focused functionality for managing node layering through z-index.
* Integrates with the layout system to ensure proper visual ordering.
*/
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
interface NodeZIndexOptions {
/**
* Layout source for z-index mutations
* @default LayoutSource.Vue
*/
layoutSource?: LayoutSource
}
export function useNodeZIndex(options: NodeZIndexOptions = {}) {
const { layoutSource = LayoutSource.Vue } = options
const layoutMutations = useLayoutMutations()
/**
* Bring node to front (highest z-index)
* @param nodeId - The node to bring to front
* @param source - Optional source override
*/
function bringNodeToFront(nodeId: NodeId, source?: LayoutSource) {
layoutMutations.setSource(source ?? layoutSource)
layoutMutations.bringNodeToFront(nodeId)
}
return {
bringNodeToFront
}
}

View File

@@ -78,7 +78,7 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!(entry.target instanceof HTMLElement)) continue if (!(entry.target instanceof HTMLElement)) continue
const element = entry.target const element = entry.target
// Identify type + id via config dataAttribute // Find which type this element belongs to
let elementType: string | undefined let elementType: string | undefined
let elementId: string | undefined let elementId: string | undefined

View File

@@ -20,6 +20,7 @@
:model-value="selectedFile?.name" :model-value="selectedFile?.name"
:options="[selectedFile?.name || '']" :options="[selectedFile?.name || '']"
:disabled="true" :disabled="true"
v-bind="transformCompatProps"
class="min-w-[8em] max-w-[20em] text-xs" class="min-w-[8em] max-w-[20em] text-xs"
size="small" size="small"
:pt="{ :pt="{
@@ -88,6 +89,7 @@
:model-value="selectedFile?.name" :model-value="selectedFile?.name"
:options="[selectedFile?.name || '']" :options="[selectedFile?.name || '']"
:disabled="true" :disabled="true"
v-bind="transformCompatProps"
class="min-w-[8em] max-w-[20em] text-xs" class="min-w-[8em] max-w-[20em] text-xs"
size="small" size="small"
:pt="{ :pt="{
@@ -182,6 +184,7 @@ import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue' import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{ const props = defineProps<{
@@ -201,6 +204,9 @@ const { localValue, onChange } = useWidgetValue({
emit emit
}) })
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
// Since we only support single file, get the first file // Since we only support single file, get the first file

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
</script>
<template>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-model="modelValue"
:widget="widget"
:readonly="readonly"
v-bind="$attrs"
/>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (props.widget.options?.step2 !== undefined) {
return Number(props.widget.options.step2)
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
}
// Default to 'any' for unrestricted stepping
return 0
})
</script>
<template>
<WidgetLayoutField :widget>
<InputNumber
v-model="modelValue"
v-bind="filteredProps"
show-buttons
button-layout="horizontal"
size="small"
:disabled="readonly"
:step="stepValue"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:pt="{
incrementButton:
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
decrementButton:
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
}"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />
</template>
<template #decrementicon>
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
</WidgetLayoutField>
</template>
<style scoped>
:deep(.p-inputnumber-input) {
background-color: transparent;
border: 1px solid color-mix(in oklab, #d4d4d8 10%, transparent);
border-top: transparent;
border-bottom: transparent;
height: 1.625rem;
margin: 1px 0;
box-shadow: none;
}
</style>

View File

@@ -7,9 +7,9 @@ import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSlider from './WidgetSlider.vue' import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
describe('WidgetSlider Value Binding', () => { describe('WidgetInputNumberSlider Value Binding', () => {
const createMockWidget = ( const createMockWidget = (
value: number = 5, value: number = 5,
options: Partial<SliderProps & { precision?: number }> = {}, options: Partial<SliderProps & { precision?: number }> = {},
@@ -27,7 +27,7 @@ describe('WidgetSlider Value Binding', () => {
modelValue: number, modelValue: number,
readonly = false readonly = false
) => { ) => {
return mount(WidgetSlider, { return mount(WidgetInputNumberSlider, {
global: { global: {
plugins: [PrimeVue], plugins: [PrimeVue],
components: { InputText, Slider } components: { InputText, Slider }

View File

@@ -16,8 +16,6 @@
v-model="inputDisplayValue" v-model="inputDisplayValue"
:disabled="readonly" :disabled="readonly"
type="number" type="number"
:min="widget.options?.min"
:max="widget.options?.max"
:step="stepValue" :step="stepValue"
class="w-[4em] text-center text-xs px-0 !border-none !shadow-none !bg-transparent" class="w-[4em] text-center text-xs px-0 !border-none !shadow-none !bg-transparent"
size="small" size="small"

View File

@@ -18,7 +18,7 @@
:disabled="readonly" :disabled="readonly"
class="w-full text-xs" class="w-full text-xs"
size="small" size="small"
rows="6" :rows="6"
:pt="{ :pt="{
root: { root: {
onBlur: handleBlur onBlur: handleBlur

View File

@@ -2,9 +2,9 @@
<WidgetLayoutField :widget="widget"> <WidgetLayoutField :widget="widget">
<MultiSelect <MultiSelect
v-model="localValue" v-model="localValue"
v-bind="filteredProps" v-bind="combinedProps"
:disabled="readonly" :disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')" class="w-full text-xs"
size="small" size="small"
display="chip" display="chip"
:pt="{ :pt="{
@@ -20,14 +20,13 @@ import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue' import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue' import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { import {
PANEL_EXCLUDED_PROPS, PANEL_EXCLUDED_PROPS,
filterWidgetProps filterWidgetProps
} from '@/utils/widgetPropFilter' } from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue' import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{ const props = defineProps<{
@@ -48,24 +47,17 @@ const { localValue, onChange } = useWidgetValue({
emit emit
}) })
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// MultiSelect specific excluded props include overlay styles // MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [ const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS, ...PANEL_EXCLUDED_PROPS,
'overlayStyle' 'overlayStyle'
] as const ] as const
const filteredProps = computed(() => { const combinedProps = computed(() => ({
const filtered = filterWidgetProps( ...filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS),
props.widget.options, ...transformCompatProps.value
MULTISELECT_EXCLUDED_PROPS }))
)
// Ensure options array is available for MultiSelect
const values = props.widget.options?.values
if (values && Array.isArray(values)) {
filtered.options = values
}
return filtered
})
</script> </script>

View File

@@ -3,9 +3,9 @@
<Select <Select
v-model="localValue" v-model="localValue"
:options="selectOptions" :options="selectOptions"
v-bind="filteredProps" v-bind="combinedProps"
:disabled="readonly" :disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')" class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
size="small" size="small"
:pt="{ :pt="{
option: 'text-xs' option: 'text-xs'
@@ -20,14 +20,13 @@ import Select from 'primevue/select'
import { computed } from 'vue' import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue' import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { import {
PANEL_EXCLUDED_PROPS, PANEL_EXCLUDED_PROPS,
filterWidgetProps filterWidgetProps
} from '@/utils/widgetPropFilter' } from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue' import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{ const props = defineProps<{
@@ -48,9 +47,13 @@ const { localValue, onChange } = useWidgetValue({
emit emit
}) })
const filteredProps = computed(() => // Transform compatibility props for overlay positioning
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS) const transformCompatProps = useTransformCompatOverlayProps()
)
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract select options from widget options // Extract select options from widget options
const selectOptions = computed(() => { const selectOptions = computed(() => {

View File

@@ -2,9 +2,9 @@
<WidgetLayoutField :widget="widget"> <WidgetLayoutField :widget="widget">
<TreeSelect <TreeSelect
v-model="localValue" v-model="localValue"
v-bind="filteredProps" v-bind="combinedProps"
:disabled="readonly" :disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')" class="w-full text-xs"
size="small" size="small"
@update:model-value="onChange" @update:model-value="onChange"
/> />
@@ -16,14 +16,13 @@ import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue' import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue' import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { import {
PANEL_EXCLUDED_PROPS, PANEL_EXCLUDED_PROPS,
filterWidgetProps filterWidgetProps
} from '@/utils/widgetPropFilter' } from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue' import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{ const props = defineProps<{
@@ -44,6 +43,9 @@ const { localValue, onChange } = useWidgetValue({
emit emit
}) })
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// TreeSelect specific excluded props // TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [ const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS, ...PANEL_EXCLUDED_PROPS,
@@ -51,7 +53,8 @@ const TREE_SELECT_EXCLUDED_PROPS = [
'inputStyle' 'inputStyle'
] as const ] as const
const filteredProps = computed(() => const combinedProps = computed(() => ({
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS) ...filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS),
) ...transformCompatProps.value
}))
</script> </script>

View File

@@ -1,8 +1,12 @@
import { ref } from 'vue' import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue' import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration' import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { import {
ComboInputSpec, ComboInputSpec,
@@ -18,6 +22,8 @@ import {
type ComfyWidgetConstructorV2, type ComfyWidgetConstructorV2,
addValueControlWidgets addValueControlWidgets
} from '@/scripts/widgets' } from '@/scripts/widgets'
import { assetService } from '@/services/assetService'
import { useSettingStore } from '@/stores/settingStore'
import { useRemoteWidget } from './useRemoteWidget' import { useRemoteWidget } from './useRemoteWidget'
@@ -28,7 +34,10 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => {
return undefined return undefined
} }
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => { const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const widgetValue = ref<string[]>([]) const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({ const widget = new ComponentWidgetImpl({
node, node,
@@ -48,7 +57,32 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
return widget return widget
} }
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => { const addComboWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
inputSpec.name,
node.comfyClass || ''
)
if (isUsingAssetAPI && isEligible) {
// Get the default value for the button text (currently selected model)
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
const widget = node.addWidget('asset', inputSpec.name, displayLabel, () => {
console.log(
`Asset Browser would open here for:\nNode: ${node.type}\nWidget: ${inputSpec.name}\nCurrent Value:${currentValue}`
)
})
return widget
}
// Create normal combo widget
const defaultValue = getDefaultValue(inputSpec) const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? [] const comboOptions = inputSpec.options ?? []
const widget = node.addWidget( const widget = node.addWidget(
@@ -59,14 +93,14 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
{ {
values: comboOptions values: comboOptions
} }
) as IComboWidget )
if (inputSpec.remote) { if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({ const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote, remoteConfig: inputSpec.remote,
defaultValue, defaultValue,
node, node,
widget widget: widget as IComboWidget
}) })
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton() if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
@@ -84,14 +118,14 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
if (inputSpec.control_after_generate) { if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets( widget.linkedWidgets = addValueControlWidgets(
node, node,
widget, widget as IComboWidget,
undefined, undefined,
undefined, undefined,
transformInputSpecV2ToV1(inputSpec) transformInputSpecV2ToV1(inputSpec)
) )
} }
return widget return widget as IBaseWidget
} }
export const useComboWidget = () => { export const useComboWidget = () => {

View File

@@ -9,12 +9,12 @@ import WidgetColorPicker from '../components/WidgetColorPicker.vue'
import WidgetFileUpload from '../components/WidgetFileUpload.vue' import WidgetFileUpload from '../components/WidgetFileUpload.vue'
import WidgetGalleria from '../components/WidgetGalleria.vue' import WidgetGalleria from '../components/WidgetGalleria.vue'
import WidgetImageCompare from '../components/WidgetImageCompare.vue' import WidgetImageCompare from '../components/WidgetImageCompare.vue'
import WidgetInputNumber from '../components/WidgetInputNumber.vue'
import WidgetInputText from '../components/WidgetInputText.vue' import WidgetInputText from '../components/WidgetInputText.vue'
import WidgetMarkdown from '../components/WidgetMarkdown.vue' import WidgetMarkdown from '../components/WidgetMarkdown.vue'
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue' import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
import WidgetSelect from '../components/WidgetSelect.vue' import WidgetSelect from '../components/WidgetSelect.vue'
import WidgetSelectButton from '../components/WidgetSelectButton.vue' import WidgetSelectButton from '../components/WidgetSelectButton.vue'
import WidgetSlider from '../components/WidgetSlider.vue'
import WidgetTextarea from '../components/WidgetTextarea.vue' import WidgetTextarea from '../components/WidgetTextarea.vue'
import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue' import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
import WidgetTreeSelect from '../components/WidgetTreeSelect.vue' import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
@@ -38,11 +38,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false essential: false
} }
], ],
['int', { component: WidgetSlider, aliases: ['INT'], essential: true }], ['int', { component: WidgetInputNumber, aliases: ['INT'], essential: true }],
[ [
'float', 'float',
{ {
component: WidgetSlider, component: WidgetInputNumber,
aliases: ['FLOAT', 'number', 'slider'], aliases: ['FLOAT', 'number', 'slider'],
essential: true essential: true
} }

View File

@@ -0,0 +1,39 @@
import { z } from 'zod'
// Zod schemas for asset API validation
const zAsset = z.object({
id: z.string(),
name: z.string(),
tags: z.array(z.string()),
size: z.number(),
created_at: z.string().optional()
})
const zAssetResponse = z.object({
assets: z.array(zAsset).optional(),
total: z.number().optional(),
has_more: z.boolean().optional()
})
const zModelFolder = z.object({
name: z.string(),
folders: z.array(z.string())
})
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetResponse = z.infer<typeof zAssetResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
// Common interfaces for API responses
export interface ModelFile {
name: string
pathIndex: number
}
export interface ModelFolderInfo {
name: string
folders: string[]
}

View File

@@ -30,6 +30,7 @@ import type {
User, User,
UserDataFullInfo UserDataFullInfo
} from '@/schemas/apiSchema' } from '@/schemas/apiSchema'
import type { ModelFile, ModelFolderInfo } from '@/schemas/assetSchema'
import type { import type {
ComfyApiWorkflow, ComfyApiWorkflow,
ComfyWorkflowJSON, ComfyWorkflowJSON,
@@ -675,15 +676,14 @@ export class ComfyApi extends EventTarget {
* Gets a list of model folder keys (eg ['checkpoints', 'loras', ...]) * Gets a list of model folder keys (eg ['checkpoints', 'loras', ...])
* @returns The list of model folder keys * @returns The list of model folder keys
*/ */
async getModelFolders(): Promise<{ name: string; folders: string[] }[]> { async getModelFolders(): Promise<ModelFolderInfo[]> {
const res = await this.fetchApi(`/experiment/models`) const res = await this.fetchApi(`/experiment/models`)
if (res.status === 404) { if (res.status === 404) {
return [] return []
} }
const folderBlacklist = ['configs', 'custom_nodes'] const folderBlacklist = ['configs', 'custom_nodes']
return (await res.json()).filter( return (await res.json()).filter(
(folder: { name: string; folders: string[] }) => (folder: ModelFolderInfo) => !folderBlacklist.includes(folder.name)
!folderBlacklist.includes(folder.name)
) )
} }
@@ -692,9 +692,7 @@ export class ComfyApi extends EventTarget {
* @param {string} folder The folder to list models from, such as 'checkpoints' * @param {string} folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder * @returns The list of model filenames within the specified folder
*/ */
async getModels( async getModels(folder: string): Promise<ModelFile[]> {
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
const res = await this.fetchApi(`/experiment/models/${folder}`) const res = await this.fetchApi(`/experiment/models/${folder}`)
if (res.status === 404) { if (res.status === 404) {
return [] return []

View File

@@ -1,63 +1,32 @@
import { fromZodError } from 'zod-validation-error'
import {
type AssetResponse,
type ModelFile,
type ModelFolder,
assetResponseSchema
} from '@/schemas/assetSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const ASSETS_ENDPOINT = '/assets' const ASSETS_ENDPOINT = '/assets'
const MODELS_TAG = 'models' const MODELS_TAG = 'models'
const MISSING_TAG = 'missing' const MISSING_TAG = 'missing'
// Types for asset API responses /**
interface AssetResponse { * Input names that are eligible for asset browser
assets?: Asset[] */
total?: number const WHITELISTED_INPUTS = new Set(['ckpt_name', 'lora_name', 'vae_name'])
has_more?: boolean
}
interface Asset {
id: string
name: string
tags: string[]
size: number
created_at?: string
}
/** /**
* Type guard for validating asset structure * Validates asset response data using Zod schema
*/ */
function isValidAsset(asset: unknown): asset is Asset { function validateAssetResponse(data: unknown): AssetResponse {
return ( const result = assetResponseSchema.safeParse(data)
asset !== null && if (result.success) return result.data
typeof asset === 'object' &&
'id' in asset &&
'name' in asset &&
'tags' in asset &&
Array.isArray((asset as Asset).tags)
)
}
/** const error = fromZodError(result.error)
* Creates predicate for filtering assets by folder and excluding missing ones throw new Error(`Invalid asset response against zod schema:\n${error}`)
*/
function createAssetFolderFilter(folder?: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || asset.tags.includes(MISSING_TAG)) {
return false
}
if (folder && !asset.tags.includes(folder)) {
return false
}
return true
}
}
/**
* Creates predicate for filtering folder assets (requires name)
*/
function createFolderAssetFilter(folder: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || !asset.name) {
return false
}
return asset.tags.includes(folder) && !asset.tags.includes(MISSING_TAG)
}
} }
/** /**
@@ -66,7 +35,7 @@ function createFolderAssetFilter(folder: string) {
*/ */
function createAssetService() { function createAssetService() {
/** /**
* Handles API response with consistent error handling * Handles API response with consistent error handling and Zod validation
*/ */
async function handleAssetRequest( async function handleAssetRequest(
url: string, url: string,
@@ -78,7 +47,8 @@ function createAssetService() {
`Unable to load ${context}: Server returned ${res.status}. Please try again.` `Unable to load ${context}: Server returned ${res.status}. Please try again.`
) )
} }
return await res.json() const data = await res.json()
return validateAssetResponse(data)
} }
/** /**
* Gets a list of model folder keys from the asset API * Gets a list of model folder keys from the asset API
@@ -90,9 +60,7 @@ function createAssetService() {
* *
* @returns The list of model folder keys * @returns The list of model folder keys
*/ */
async function getAssetModelFolders(): Promise< async function getAssetModelFolders(): Promise<ModelFolder[]> {
{ name: string; folders: string[] }[]
> {
const data = await handleAssetRequest( const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`, `${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
'model folders' 'model folders'
@@ -102,22 +70,17 @@ function createAssetService() {
const blacklistedDirectories = ['configs'] const blacklistedDirectories = ['configs']
// Extract directory names from assets that actually exist, exclude missing assets // Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>() const discoveredFolders = new Set<string>(
if (data?.assets) { data?.assets
const directoryTags = data.assets ?.filter((asset) => !asset.tags.includes(MISSING_TAG))
.filter(createAssetFolderFilter()) ?.flatMap((asset) => asset.tags)
.flatMap((asset) => asset.tags) ?.filter(
.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag) (tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
) ) ?? []
)
for (const tag of directoryTags) {
discoveredFolders.add(tag)
}
}
// Return only discovered folders in alphabetical order // Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).sort() const sortedFolders = Array.from(discoveredFolders).toSorted()
return sortedFolders.map((name) => ({ name, folders: [] })) return sortedFolders.map((name) => ({ name, folders: [] }))
} }
@@ -126,25 +89,48 @@ function createAssetService() {
* @param folder The folder to list models from, such as 'checkpoints' * @param folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder * @returns The list of model filenames within the specified folder
*/ */
async function getAssetModels( async function getAssetModels(folder: string): Promise<ModelFile[]> {
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
const data = await handleAssetRequest( const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`, `${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
`models for ${folder}` `models for ${folder}`
) )
return data?.assets return (
? data.assets.filter(createFolderAssetFilter(folder)).map((asset) => ({ data?.assets
?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
)
?.map((asset) => ({
name: asset.name, name: asset.name,
pathIndex: 0 pathIndex: 0
})) })) ?? []
: [] )
}
/**
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
*
* @param inputName - The input name (e.g., 'ckpt_name', 'lora_name')
* @param nodeType - The ComfyUI node comfyClass (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
* @returns true if this input should use asset browser
*/
function isAssetBrowserEligible(
inputName: string,
nodeType: string
): boolean {
return (
// Must be an approved input name
WHITELISTED_INPUTS.has(inputName) &&
// Must be a registered node type
useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
)
} }
return { return {
getAssetModelFolders, getAssetModelFolders,
getAssetModels getAssetModels,
isAssetBrowserEligible
} }
} }

View File

@@ -484,7 +484,18 @@ export const useLitegraphService = () => {
) ?? {} ) ?? {}
if (widget) { if (widget) {
widget.label = st(nameKey, widget.label ?? inputName) // Check if this is an Asset Browser button widget
const isAssetBrowserButton =
widget.type === 'button' && widget.value === 'Select model'
if (isAssetBrowserButton) {
// Preserve Asset Browser button label (don't translate)
widget.label = String(widget.value)
} else {
// Apply normal translation for other widgets
widget.label = st(nameKey, widget.label ?? inputName)
}
widget.options ??= {} widget.options ??= {}
Object.assign(widget.options, { Object.assign(widget.options, {
advanced: inputSpec.advanced, advanced: inputSpec.advanced,

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { ModelFile } from '@/schemas/assetSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService' import { assetService } from '@/services/assetService'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
@@ -157,9 +158,7 @@ export class ModelFolder {
constructor( constructor(
public directory: string, public directory: string,
private getModelsFunc: ( private getModelsFunc: (folder: string) => Promise<ModelFile[]>
folder: string
) => Promise<{ name: string; pathIndex: number }[]>
) {} ) {}
get key(): string { get key(): string {

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { computed, ref } from 'vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore' import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -22,6 +22,22 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
const modelToNodeMap = ref<Record<string, ModelNodeProvider[]>>({}) const modelToNodeMap = ref<Record<string, ModelNodeProvider[]>>({})
const nodeDefStore = useNodeDefStore() const nodeDefStore = useNodeDefStore()
const haveDefaultsLoaded = ref(false) const haveDefaultsLoaded = ref(false)
/** Internal computed for reactive caching of registered node types */
const registeredNodeTypes = computed(() => {
return new Set(
Object.values(modelToNodeMap.value)
.flat()
.map((provider) => provider.nodeDef.name)
)
})
/** Get set of all registered node types for efficient lookup */
function getRegisteredNodeTypes(): Set<string> {
registerDefaults()
return registeredNodeTypes.value
}
/** /**
* Get the node provider for the given model type name. * Get the node provider for the given model type name.
* @param modelType The name of the model type to get the node provider for. * @param modelType The name of the model type to get the node provider for.
@@ -91,6 +107,7 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
return { return {
modelToNodeMap, modelToNodeMap,
getRegisteredNodeTypes,
getNodeProvider, getNodeProvider,
getAllNodeProviders, getAllNodeProviders,
registerNodeProvider, registerNodeProvider,

View File

@@ -56,8 +56,8 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => { export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => {
const sizeClasses = { const sizeClasses = {
'fit-content': 'w-auto h-auto', 'fit-content': 'w-auto h-auto',
sm: 'w-6 h-6 text-xs !rounded-md', sm: 'size-8 text-xs !rounded-md',
md: 'w-8 h-8 text-sm' md: 'size-10 text-sm'
} }
return sizeClasses[size] return sizeClasses[size]
} }

View File

@@ -1,6 +1,7 @@
export interface NavItemData { export interface NavItemData {
id: string id: string
label: string label: string
icon: string
} }
export interface NavGroupData { export interface NavGroupData {

45
src/utils/gridUtil.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { CSSProperties } from 'vue'
interface GridOptions {
/** Minimum width for each grid item (default: 15rem) */
minWidth?: string
/** Maximum width for each grid item (default: 1fr) */
maxWidth?: string
/** Padding around the grid (default: 0) */
padding?: string
/** Gap between grid items (default: 1rem) */
gap?: string
/** Fixed number of columns (overrides auto-fill with minmax) */
columns?: number
}
/**
* Creates CSS grid styles for responsive grid layouts
* @param options Grid configuration options
* @returns CSS properties object for grid styling
*/
export function createGridStyle(options: GridOptions = {}): CSSProperties {
const {
minWidth = '15rem',
maxWidth = '1fr',
padding = '0',
gap = '1rem',
columns
} = options
// Runtime validation for columns
if (columns !== undefined && columns < 1) {
console.warn('createGridStyle: columns must be >= 1, defaulting to 1')
}
const gridTemplateColumns = columns
? `repeat(${Math.max(1, columns ?? 1)}, 1fr)`
: `repeat(auto-fill, minmax(${minWidth}, ${maxWidth}))`
return {
display: 'grid',
gridTemplateColumns,
padding,
gap
}
}

View File

@@ -343,7 +343,7 @@ const onGraphReady = () => {
grid-column: 2; grid-column: 2;
grid-row: 2; grid-row: 2;
position: relative; position: relative;
overflow: hidden; overflow: clip;
} }
.comfyui-body-right { .comfyui-body-right {

View File

@@ -1,3 +1,4 @@
import lucide from '@iconify-json/lucide/icons.json'
import { addDynamicIconSelectors } from '@iconify/tailwind' import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './build/customIconCollection' import { iconCollection } from './build/customIconCollection'
@@ -5,257 +6,13 @@ import { iconCollection } from './build/customIconCollection'
export default { export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
fontSize: {
xxs: '0.625rem',
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
'6xl': '4rem'
},
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
'3xl': '1800px',
'4xl': '2500px',
'5xl': '3200px'
},
spacing: {
px: '1px',
0: '0px',
0.5: '0.125rem',
1: '0.25rem',
1.5: '0.375rem',
2: '0.5rem',
2.5: '0.625rem',
3: '0.75rem',
3.5: '0.875rem',
4: '1rem',
4.5: '1.125rem',
5: '1.25rem',
6: '1.5rem',
7: '1.75rem',
8: '2rem',
9: '2.25rem',
10: '2.5rem',
11: '2.75rem',
12: '3rem',
14: '3.5rem',
16: '4rem',
18: '4.5rem',
20: '5rem',
24: '6rem',
28: '7rem',
32: '8rem',
36: '9rem',
40: '10rem',
44: '11rem',
48: '12rem',
52: '13rem',
56: '14rem',
60: '15rem',
64: '16rem',
72: '18rem',
75: '18.75rem',
80: '20rem',
84: '22rem',
90: '24rem',
96: '26rem',
100: '28rem',
110: '32rem'
},
extend: {
colors: {
zinc: {
50: '#fafafa',
100: '#8282821a',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#A1A3AE',
500: '#71717a',
600: '#52525b',
700: '#38393b',
800: '#262729',
900: '#18181b',
950: '#09090b'
},
gray: {
50: '#f8fbfc',
100: '#f3f6fa',
200: '#edf2f7',
300: '#e2e8f0',
400: '#cbd5e0',
500: '#a0aec0',
600: '#718096',
700: '#4a5568',
800: '#2d3748',
900: '#1a202c',
950: '#0a1016'
},
teal: {
50: '#f0fdfa',
100: '#e0fcff',
200: '#bef8fd',
300: '#87eaf2',
400: '#54d1db',
500: '#38bec9',
600: '#2cb1bc',
700: '#14919b',
800: '#0e7c86',
900: '#005860',
950: '#022c28'
},
blue: {
50: '#eff6ff',
100: '#ebf8ff',
200: '#bee3f8',
300: '#90cdf4',
400: '#63b3ed',
500: '#4299e1',
600: '#3182ce',
700: '#2b6cb0',
800: '#2c5282',
900: '#2a4365',
950: '#172554'
},
green: {
50: '#fcfff5',
100: '#fafff3',
200: '#eaf9c9',
300: '#d1efa0',
400: '#b2e16e',
500: '#96ce4c',
600: '#7bb53d',
700: '#649934',
800: '#507b2e',
900: '#456829',
950: '#355819'
},
fuchsia: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
950: '#4a044e'
},
orange: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fedbb8',
300: '#fbd38d',
400: '#f6ad55',
500: '#ed8936',
600: '#dd6b20',
700: '#c05621',
800: '#9c4221',
900: '#7b341e',
950: '#431407'
},
yellow: {
50: '#fffef5',
100: '#fffce8',
200: '#fff8c5',
300: '#fff197',
400: '#ffcc00',
500: '#ffc000',
600: '#e6a800',
700: '#cc9600',
800: '#b38400',
900: '#997200',
950: '#664d00'
}
},
textColor: {
muted: 'var(--p-text-muted-color)',
highlight: 'var(--p-primary-color)'
},
/**
* Box shadows for different elevation levels
* https://m3.material.io/styles/elevation/overview
*/
boxShadow: {
'elevation-0': 'none',
'elevation-1':
'0 0 2px 0px rgb(0 0 0 / 0.01), 0 1px 2px -1px rgb(0 0 0 / 0.03), 0 1px 1px -1px rgb(0 0 0 / 0.01)',
'elevation-1.5':
'0 0 2px 0px rgb(0 0 0 / 0.025), 0 1px 2px -1px rgb(0 0 0 / 0.03), 0 1px 1px -1px rgb(0 0 0 / 0.01)',
'elevation-2':
'0 0 10px 0px rgb(0 0 0 / 0.06), 0 6px 8px -2px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.04)',
'elevation-3':
'0 0 15px 0px rgb(0 0 0 / 0.10), 0 8px 12px -3px rgb(0 0 0 / 0.09), 0 3px 5px -4px rgb(0 0 0 / 0.06)',
'elevation-4':
'0 0 18px 0px rgb(0 0 0 / 0.12), 0 10px 15px -3px rgb(0 0 0 / 0.11), 0 4px 6px -4px rgb(0 0 0 / 0.08)',
'elevation-5':
'0 0 20px 0px rgb(0 0 0 / 0.14), 0 12px 16px -4px rgb(0 0 0 / 0.13), 0 5px 7px -5px rgb(0 0 0 / 0.10)'
},
/**
* Background colors for different elevation levels
* https://m3.material.io/styles/elevation/overview
*/
backgroundColor: {
'dark-elevation-0': 'rgba(255, 255, 255, 0)',
'dark-elevation-1': 'rgba(255, 255, 255, 0.01)',
'dark-elevation-1.5': 'rgba(255, 255, 255, 0.015)',
'dark-elevation-2': 'rgba(255, 255, 255, 0.03)',
'dark-elevation-3': 'rgba(255, 255, 255, 0.04)',
'dark-elevation-4': 'rgba(255, 255, 255, 0.08)',
'dark-elevation-5': 'rgba(2 55, 255, 255, 0.12)'
}
}
},
plugins: [ plugins: [
addDynamicIconSelectors({ addDynamicIconSelectors({
iconSets: { iconSets: {
comfy: iconCollection comfy: iconCollection,
} lucide
}), },
function ({ addVariant }) { prefix: 'icon'
addVariant('dark-theme', '.dark-theme &') })
},
function ({ addUtilities }) {
const newUtilities = {
'.scrollbar-hide': {
/* Firefox */
'scrollbar-width': 'none',
/* Webkit-based browsers */
'&::-webkit-scrollbar': {
width: '1px'
},
'&::-webkit-scrollbar-thumb': {
'background-color': 'transparent'
}
}
}
addUtilities(newUtilities)
}
] ]
} }

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