Merge remote-tracking branch 'origin/main' into bl-update-slots
@@ -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
|
||||||
|
|
||||||
|
|||||||
9
.github/workflows/claude-pr-review.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
300
.github/workflows/pr-playwright-deploy.yaml
vendored
@@ -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
|
|
||||||
62
.github/workflows/test-ui.yaml
vendored
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -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 ({
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 141 KiB |
15
package.json
@@ -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
@@ -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:
|
||||||
|
|||||||
241
scripts/cicd/pr-playwright-deploy-and-comment.sh
Executable 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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
69
src/components/card/CardGridList.stories.ts
Normal 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>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -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 {
|
||||||
11
src/components/widget/nav/NavIcon.vue
Normal 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>
|
||||||
96
src/components/widget/nav/NavItem.stories.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
242
src/components/widget/panel/LeftSidePanel.stories.ts
Normal 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>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/composables/usePopoverSizing.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
48
src/composables/useTransformCompatOverlayProps.ts
Normal 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
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
41
src/lib/litegraph/src/widgets/AssetWidget.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 can’t 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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
195
src/renderer/extensions/vueNodes/components/NodeSlots.spec.ts
Normal 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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/schemas/assetSchema.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||