Compare commits
2 Commits
vue-nodes/
...
vue-nodes/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96869ed27a | ||
|
|
b50a5bd459 |
7
.github/workflows/backport.yaml
vendored
@@ -133,10 +133,11 @@ jobs:
|
||||
if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
IFS=':' read -r version branch <<< "${backport}"
|
||||
|
||||
|
||||
9
.github/workflows/claude-pr-review.yml
vendored
@@ -47,7 +47,6 @@ jobs:
|
||||
needs: wait-for-ci
|
||||
if: needs.wait-for-ci.outputs.should-proceed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -70,17 +69,19 @@ jobs:
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
|
||||
- name: Run Claude PR Review
|
||||
uses: anthropics/claude-code-action@v1.0.6
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
label_trigger: "claude-review"
|
||||
prompt: |
|
||||
direct_prompt: |
|
||||
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.
|
||||
DO NOT create a summary comment.
|
||||
Each issue must be posted as a separate inline comment on the specific line of code.
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
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'"
|
||||
max_turns: 256
|
||||
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:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
300
.github/workflows/pr-playwright-deploy.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: PR Playwright Deploy (Forks)
|
||||
name: PR Playwright Deploy and Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -9,84 +9,272 @@ env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
deploy-reports:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
- name: Log workflow trigger 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
|
||||
- name: Get PR info
|
||||
id: pr-info
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
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}`,
|
||||
});
|
||||
|
||||
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
|
||||
if (!pr) {
|
||||
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
|
||||
return null;
|
||||
if (pullRequests.length === 0) {
|
||||
console.log('No open PR found for this branch');
|
||||
return { number: null, sanitized_branch: null };
|
||||
}
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
const pr = pullRequests[0];
|
||||
const branchName = context.payload.workflow_run.head_branch;
|
||||
const sanitizedBranch = branchName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
return {
|
||||
number: pr.number,
|
||||
sanitized_branch: sanitizedBranch
|
||||
};
|
||||
|
||||
- name: Handle Test Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
- name: Set project name
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null
|
||||
id: project-name
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then
|
||||
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "branch=${{ fromJSON(steps.pr-info.outputs.result).sanitized_branch }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
- name: Download playwright report
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
name: playwright-report-${{ matrix.browser }}
|
||||
path: playwright-report
|
||||
|
||||
- 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
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
if npx wrangler pages deploy playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
|
||||
SUCCESS=true
|
||||
echo "Deployment successful on attempt $RETRY_COUNT"
|
||||
else
|
||||
echo "Deployment failed on attempt $RETRY_COUNT"
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
echo "Retrying in 10 seconds..."
|
||||
sleep 10
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $SUCCESS = false ]; then
|
||||
echo "All deployment attempts failed"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
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: |
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
mv reports/playwright-report-chromium-merged reports/playwright-report-chromium
|
||||
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
|
||||
echo "## 🎭 Playwright Test Results" >> comment.md
|
||||
echo "" >> comment.md
|
||||
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
|
||||
echo "⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
|
||||
echo "" >> comment.md
|
||||
echo "### 🚀 Running Tests" >> comment.md
|
||||
echo "- 🧪 **chromium**: Running tests..." >> comment.md
|
||||
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
|
||||
77
.github/workflows/test-ui.yaml
vendored
@@ -229,13 +229,7 @@ jobs:
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: |
|
||||
# Run tests with both HTML and JSON reporters
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
npx playwright test --project=${{ matrix.browser }} \
|
||||
--reporter=list \
|
||||
--reporter=html \
|
||||
--reporter=json
|
||||
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -281,12 +275,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge into HTML Report
|
||||
run: |
|
||||
# Generate HTML report
|
||||
npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
# Generate JSON report separately with explicit output path
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
npx playwright merge-reports --reporter=json ./all-blob-reports
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Upload HTML report
|
||||
@@ -295,65 +284,3 @@ jobs:
|
||||
name: playwright-report-chromium
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
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)
|
||||
2
.github/workflows/update-electron-types.yaml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Get new version
|
||||
id: get-version
|
||||
run: |
|
||||
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].dependencies."@comfyorg/comfyui-electron-types".version')
|
||||
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version')
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
|
||||
1
.gitignore
vendored
@@ -51,7 +51,6 @@ tests-ui/workflows/examples
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser-tests/local/
|
||||
|
||||
.env
|
||||
|
||||
|
||||
@@ -57,8 +57,9 @@
|
||||
|
||||
/* Override Storybook's problematic & selector styles */
|
||||
/* Reset only the specific properties that Storybook injects */
|
||||
li+li {
|
||||
margin: 0;
|
||||
padding: revert-layer;
|
||||
#storybook-root li+li,
|
||||
#storybook-docs li+li {
|
||||
margin: inherit;
|
||||
padding: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,6 @@ import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import {
|
||||
@@ -145,7 +144,6 @@ export class ComfyPage {
|
||||
public readonly templates: ComfyTemplates
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -174,7 +172,6 @@ export class ComfyPage {
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
}
|
||||
|
||||
convertLeafToContent(structure: FolderStructure): FolderStructure {
|
||||
@@ -1424,7 +1421,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async closeDialog() {
|
||||
await this.page.locator('.p-dialog-close-button').click({ force: true })
|
||||
await this.page.locator('.p-dialog-close-button').click()
|
||||
await expect(this.page.locator('.p-dialog')).toBeHidden()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Vue Node Test Helpers
|
||||
*/
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Get locator for all Vue node components in the DOM
|
||||
*/
|
||||
get nodes(): Locator {
|
||||
return this.page.locator('[data-node-id]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
get selectedNodes(): Locator {
|
||||
return this.page.locator('[data-node-id].border-blue-500')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of Vue nodes in the DOM
|
||||
*/
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.nodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of selected Vue nodes
|
||||
*/
|
||||
async getSelectedNodeCount(): Promise<number> {
|
||||
return await this.selectedNodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Vue node IDs currently in the DOM
|
||||
*/
|
||||
async getNodeIds(): Promise<string[]> {
|
||||
return await this.nodes.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((n) => n.getAttribute('data-node-id'))
|
||||
.filter((id): id is string => id !== null)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a specific Vue node by ID
|
||||
*/
|
||||
async selectNode(nodeId: string): Promise<void> {
|
||||
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple Vue nodes by IDs using Ctrl+click
|
||||
*/
|
||||
async selectNodes(nodeIds: string[]): Promise<void> {
|
||||
if (nodeIds.length === 0) return
|
||||
|
||||
// Select first node normally
|
||||
await this.selectNode(nodeIds[0])
|
||||
|
||||
// Add additional nodes with Ctrl+click
|
||||
for (let i = 1; i < nodeIds.length; i++) {
|
||||
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections by clicking empty space
|
||||
*/
|
||||
async clearSelection(): Promise<void> {
|
||||
await this.page.mouse.click(50, 50)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Delete key
|
||||
*/
|
||||
async deleteSelected(): Promise<void> {
|
||||
await this.page.locator('#graph-canvas').focus()
|
||||
await this.page.keyboard.press('Delete')
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Backspace key
|
||||
*/
|
||||
async deleteSelectedWithBackspace(): Promise<void> {
|
||||
await this.page.locator('#graph-canvas').focus()
|
||||
await this.page.keyboard.press('Backspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Vue nodes to be rendered
|
||||
*/
|
||||
async waitForNodes(expectedCount?: number): Promise<void> {
|
||||
if (expectedCount !== undefined) {
|
||||
await this.page.waitForFunction(
|
||||
(count) => document.querySelectorAll('[data-node-id]').length >= count,
|
||||
expectedCount
|
||||
)
|
||||
} else {
|
||||
await this.page.waitForSelector('[data-node-id]')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,6 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('missing/missing_nodes')
|
||||
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
|
||||
await comfyPage.doubleClickCanvas()
|
||||
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 |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
@@ -149,7 +149,7 @@ test.describe('Selection Toolbox', () => {
|
||||
// Node should have the selected color class/style
|
||||
// Note: Exact verification method depends on how color is applied to nodes
|
||||
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
expect(await selectedNode.getProperty('color')).not.toBeNull()
|
||||
expect(selectedNode.getProperty('color')).not.toBeNull()
|
||||
})
|
||||
|
||||
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: 81 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 175 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: 68 KiB After Width: | Height: | Size: 69 KiB |
@@ -1,141 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes - Delete Key Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Enable Vue nodes rendering
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Can select all and delete Vue nodes with Delete key', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Get initial Vue node count
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
// Select all Vue nodes
|
||||
await comfyPage.ctrlA()
|
||||
|
||||
// Verify all Vue nodes are selected
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(initialNodeCount)
|
||||
|
||||
// Delete with Delete key
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Verify all Vue nodes were deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Can select specific Vue node and delete it', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Get initial Vue node count
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
// Get first Vue node ID and select it
|
||||
const nodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
await comfyPage.vueNodes.selectNode(nodeIds[0])
|
||||
|
||||
// Verify selection
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(1)
|
||||
|
||||
// Delete with Delete key
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Verify one Vue node was deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
})
|
||||
|
||||
test('Can select and delete Vue node with Backspace key', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Select first Vue node
|
||||
const nodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
await comfyPage.vueNodes.selectNode(nodeIds[0])
|
||||
|
||||
// Delete with Backspace key instead of Delete
|
||||
await comfyPage.vueNodes.deleteSelectedWithBackspace()
|
||||
|
||||
// Verify Vue node was deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
})
|
||||
|
||||
test('Delete key does not delete node when typing in Vue node widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
||||
|
||||
// Find a text input widget in a Vue node
|
||||
const textWidget = comfyPage.page
|
||||
.locator('input[type="text"], textarea')
|
||||
.first()
|
||||
|
||||
// Click on text widget to focus it
|
||||
await textWidget.click()
|
||||
await textWidget.fill('test text')
|
||||
|
||||
// Press Delete while focused on widget - should delete text, not node
|
||||
await textWidget.press('Delete')
|
||||
|
||||
// Node count should remain the same
|
||||
const finalNodeCount = await comfyPage.getGraphNodesCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Delete key does not delete node when nothing is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Ensure no Vue nodes are selected
|
||||
await comfyPage.vueNodes.clearSelection()
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(0)
|
||||
|
||||
// Press Delete key - should not crash and should handle gracefully
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
// Vue node count should remain the same
|
||||
const nodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(nodeCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Multi-select first two Vue nodes using Ctrl+click
|
||||
const nodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
const nodesToSelect = nodeIds.slice(0, 2)
|
||||
await comfyPage.vueNodes.selectNodes(nodesToSelect)
|
||||
|
||||
// Verify expected nodes are selected
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(nodesToSelect.length)
|
||||
|
||||
// Delete selected Vue nodes
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Verify expected nodes were deleted
|
||||
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length)
|
||||
})
|
||||
})
|
||||
@@ -264,13 +264,7 @@ test.describe('Animated image widget', () => {
|
||||
expect(filename).toContain('animated_webp.webp')
|
||||
})
|
||||
|
||||
// FIXME: This test keeps flip-flopping because it relies on animated webp timing,
|
||||
// which is inherently unreliable in CI environments. The test asset is an animated
|
||||
// webp with 2 frames, and the test depends on animation frame timing to verify that
|
||||
// animated webp images are properly displayed (as opposed to being treated as static webp).
|
||||
// While the underlying functionality works (animated webp are correctly distinguished
|
||||
// from static webp), the test is flaky due to timing dependencies with webp animation frames.
|
||||
test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => {
|
||||
test('Can preview saved animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/save_animated_webp')
|
||||
|
||||
// Get position of the load animated webp node
|
||||
|
||||
|
After Width: | Height: | Size: 169 KiB |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/assets/css/style.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"composables": "@/composables",
|
||||
"utils": "@/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
# 4. Fork PrimeVue UI Library
|
||||
|
||||
Date: 2025-08-27
|
||||
|
||||
## Status
|
||||
|
||||
Rejected
|
||||
|
||||
## Context
|
||||
|
||||
ComfyUI's frontend requires modifications to PrimeVue components that cannot be achieved through the library's customization APIs. Two specific technical incompatibilities have been identified with the transform-based canvas architecture:
|
||||
|
||||
**Screen Coordinate Hit-Testing Conflicts:**
|
||||
- PrimeVue components use `getBoundingClientRect()` for screen coordinate calculations that don't account for CSS transforms
|
||||
- The Slider component directly uses raw `pageX/pageY` coordinates ([lines 102-103](https://github.com/primefaces/primevue/blob/master/packages/primevue/src/slider/Slider.vue#L102-L103)) without transform-aware positioning
|
||||
- This breaks interaction in transformed coordinate spaces where screen coordinates don't match logical element positions
|
||||
|
||||
**Virtual Canvas Scroll Interference:**
|
||||
- LiteGraph's infinite canvas uses scroll coordinates semantically for graph navigation via the `DragAndScale` coordinate system
|
||||
- PrimeVue overlay components automatically trigger `scrollIntoView` behavior which interferes with this virtual positioning
|
||||
- This issue is documented in [PrimeVue discussion #4270](https://github.com/orgs/primefaces/discussions/4270) where the feature request was made to disable this behavior
|
||||
|
||||
**Historical Overlay Issues:**
|
||||
- Previous z-index positioning conflicts required manual workarounds (commit `6d4eafb0`) where PrimeVue Dialog components needed `autoZIndex: false` and custom mask styling, later resolved by removing PrimeVue's automatic z-index management entirely
|
||||
|
||||
**Minimal Update Overhead:**
|
||||
- Analysis of git history shows only 2 PrimeVue version updates in 2+ years, indicating that upstream sync overhead is negligible for this project
|
||||
|
||||
**Future Interaction System Requirements:**
|
||||
- The ongoing canvas architecture evolution will require more granular control over component interaction and event handling as the transform-based system matures
|
||||
- Predictable need for additional component modifications beyond current identified issues
|
||||
|
||||
## Decision
|
||||
|
||||
We will **NOT** fork PrimeVue. After evaluation, forking was determined to be unnecessarily complex and costly.
|
||||
|
||||
**Rationale for Rejection:**
|
||||
|
||||
- **Significant Implementation Complexity**: PrimeVue is structured as a monorepo ([primefaces/primevue](https://github.com/primefaces/primevue)) with significant code in a separate monorepo ([PrimeUIX](https://github.com/primefaces/primeuix)). Forking would require importing both repositories whole and selectively pruning or exempting components from our workspace tooling, adding substantial complexity.
|
||||
|
||||
- **Alternative Solutions Available**: The modifications we identified (e.g., scroll interference issues, coordinate system conflicts) have less costly solutions that don't require maintaining a full fork. For example, coordinate issues could be addressed through event interception and synthetic event creation with scaled values.
|
||||
|
||||
- **Maintenance Burden**: Ongoing maintenance and upgrades would be very painful, requiring manual conflict resolution and keeping pace with upstream changes across multiple repositories.
|
||||
|
||||
- **Limited Tooling Support**: There isn't adequate tooling that provides the granularity needed to cleanly manage a PrimeVue fork within our existing infrastructure.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Alternative Approach
|
||||
|
||||
- **Use PrimeVue as External Dependency**: Continue using PrimeVue as a standard npm dependency
|
||||
- **Targeted Workarounds**: Implement specific solutions for identified issues (coordinate system conflicts, scroll interference) without forking the entire library
|
||||
- **Selective Component Replacement**: Use libraries like shadcn/ui to replace specific problematic PrimeVue components and adjust them to match our design system
|
||||
- **Upstream Engagement**: Continue engaging with PrimeVue community for feature requests and bug reports
|
||||
- **Maintain Flexibility**: Preserve ability to upgrade PrimeVue versions without fork maintenance overhead
|
||||
|
||||
## Notes
|
||||
|
||||
- Technical issues documented in the Context section remain valid concerns
|
||||
- Solutions will be pursued through targeted fixes rather than wholesale forking
|
||||
- Future re-evaluation possible if PrimeVue's architecture significantly changes or if alternative tooling becomes available
|
||||
- This decision prioritizes maintainability and development velocity over maximum customization control
|
||||
@@ -13,7 +13,6 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -64,9 +64,6 @@ export default [
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
'vue/one-component-per-file': 'off', // TODO: fix
|
||||
// Restrict deprecated PrimeVue components
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
|
||||
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.27.3",
|
||||
"version": "1.27.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -25,8 +25,8 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"lint": "eslint src --cache",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint": "eslint src --cache --concurrency=auto",
|
||||
"lint:fix": "eslint src --cache --fix --concurrency=auto",
|
||||
"lint:no-cache": "eslint src",
|
||||
"lint:fix:no-cache": "eslint src --fix",
|
||||
"knip": "knip --cache",
|
||||
@@ -39,7 +39,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@iconify-json/lucide": "^1.2.66",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
@@ -77,13 +76,13 @@
|
||||
"jsdom": "^26.1.0",
|
||||
"knip": "^5.62.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"lucide-vue-next": "^0.540.0",
|
||||
"nx": "21.4.1",
|
||||
"prettier": "^3.3.2",
|
||||
"storybook": "^9.1.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"tsx": "^4.15.6",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"unplugin-icons": "^0.22.0",
|
||||
@@ -101,7 +100,7 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.72",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.69",
|
||||
"@iconify/json": "^2.2.380",
|
||||
"@primeuix/forms": "0.0.2",
|
||||
"@primeuix/styled": "0.3.2",
|
||||
@@ -141,7 +140,6 @@
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.2.5",
|
||||
"reka-ui": "^2.5.0",
|
||||
"semver": "^7.7.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.170.0",
|
||||
|
||||
190
pnpm-lock.yaml
generated
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: ^0.4.72
|
||||
version: 0.4.72
|
||||
specifier: ^0.4.69
|
||||
version: 0.4.69
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
@@ -134,9 +134,6 @@ importers:
|
||||
primevue:
|
||||
specifier: ^4.2.5
|
||||
version: 4.2.5(vue@3.5.13(typescript@5.9.2))
|
||||
reka-ui:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2))
|
||||
semver:
|
||||
specifier: ^7.7.2
|
||||
version: 7.7.2
|
||||
@@ -174,9 +171,6 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^9.8.0
|
||||
version: 9.12.0
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.2.66
|
||||
version: 1.2.66
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
@@ -288,6 +282,9 @@ importers:
|
||||
lint-staged:
|
||||
specifier: ^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:
|
||||
specifier: 21.4.1
|
||||
version: 21.4.1
|
||||
@@ -306,9 +303,6 @@ importers:
|
||||
tsx:
|
||||
specifier: ^4.15.6
|
||||
version: 4.19.4
|
||||
tw-animate-css:
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
@@ -986,8 +980,8 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.72':
|
||||
resolution: {integrity: sha512-Ecf0XYOKDqqIcnjSWL8GHLo6MOsuwqs0I1QgWc3Hv+BZm+qUE4vzOXCyhfFoTIGHLZFTwe37gnygPPKFzMu00Q==}
|
||||
'@comfyorg/comfyui-electron-types@0.4.69':
|
||||
resolution: {integrity: sha512-emEapJvbbx8lXiJ/84gmk+fYU73MmqkQKgBDQkyDwctcOb+eNe347PaH/+0AIjX8A/DtFHfnwgh9J8k3RVdqZA==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
@@ -1576,18 +1570,6 @@ packages:
|
||||
'@firebase/webchannel-wrapper@1.0.3':
|
||||
resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@floating-ui/vue@1.1.9':
|
||||
resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==}
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
|
||||
engines: {node: ^8.13.0 || >=10.10.0}
|
||||
@@ -1613,9 +1595,6 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@iconify-json/lucide@1.2.66':
|
||||
resolution: {integrity: sha512-TrhmfThWY2FHJIckjz7g34gUx3+cmja61DcHNdmu0rVDBQHIjPMYO1O8mMjoDSqIXEllz9wDZxCqT3lFuI+f/A==}
|
||||
|
||||
'@iconify/json@2.2.380':
|
||||
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
|
||||
|
||||
@@ -1628,12 +1607,6 @@ packages:
|
||||
'@iconify/utils@2.3.0':
|
||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||
|
||||
'@internationalized/date@3.9.0':
|
||||
resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==}
|
||||
|
||||
'@internationalized/number@3.6.5':
|
||||
resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==}
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -2267,9 +2240,6 @@ packages:
|
||||
storybook: ^9.1.1
|
||||
vue: ^3.0.0
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||
|
||||
'@tailwindcss/node@4.1.12':
|
||||
resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==}
|
||||
|
||||
@@ -2360,14 +2330,6 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2644,9 +2606,6 @@ packages:
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/webxr@0.5.20':
|
||||
resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==}
|
||||
|
||||
@@ -2897,21 +2856,12 @@ packages:
|
||||
'@vueuse/core@11.0.0':
|
||||
resolution: {integrity: sha512-shibzNGjmRjZucEm97B8V0NO5J3vPHMCE/mltxQ3vHezbDoFQBMtK11XsfwfPionxSbo+buqPmsCljtYuXIBpw==}
|
||||
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
|
||||
'@vueuse/metadata@11.0.0':
|
||||
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
|
||||
|
||||
'@vueuse/metadata@12.8.2':
|
||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||
|
||||
'@vueuse/shared@11.0.0':
|
||||
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
|
||||
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
'@webgpu/types@0.1.51':
|
||||
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
|
||||
|
||||
@@ -3065,10 +3015,6 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
aria-query@5.3.0:
|
||||
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
||||
|
||||
@@ -3539,9 +3485,6 @@ packages:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
defu@6.1.4:
|
||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -4793,6 +4736,11 @@ packages:
|
||||
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -5159,9 +5107,6 @@ packages:
|
||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -5612,11 +5557,6 @@ packages:
|
||||
resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==}
|
||||
hasBin: true
|
||||
|
||||
reka-ui@2.5.0:
|
||||
resolution: {integrity: sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==}
|
||||
peerDependencies:
|
||||
vue: '>= 3.2.0'
|
||||
|
||||
relateurl@0.2.7:
|
||||
resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -6051,9 +5991,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tw-animate-css@1.3.8:
|
||||
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -6356,8 +6293,8 @@ packages:
|
||||
vue-component-type-helpers@2.2.12:
|
||||
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
|
||||
|
||||
vue-component-type-helpers@3.0.7:
|
||||
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
|
||||
vue-component-type-helpers@3.0.6:
|
||||
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -7502,7 +7439,7 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.72': {}
|
||||
'@comfyorg/comfyui-electron-types@0.4.69': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
@@ -8064,26 +8001,6 @@ snapshots:
|
||||
|
||||
'@firebase/webchannel-wrapper@1.0.3': {}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@floating-ui/vue@1.1.9(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.4
|
||||
'@floating-ui/utils': 0.2.10
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.13
|
||||
@@ -8107,10 +8024,6 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.4.3': {}
|
||||
|
||||
'@iconify-json/lucide@1.2.66':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify/json@2.2.380':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
@@ -8135,14 +8048,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@internationalized/date@3.9.0':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@internationalized/number@3.6.5':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 9.14.3
|
||||
@@ -8925,11 +8830,7 @@ snapshots:
|
||||
storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.7
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
vue-component-type-helpers: 3.0.6
|
||||
|
||||
'@tailwindcss/node@4.1.12':
|
||||
dependencies:
|
||||
@@ -9002,13 +8903,6 @@ snapshots:
|
||||
tailwindcss: 4.1.12
|
||||
vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@@ -9316,8 +9210,6 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/webxr@0.5.20': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)':
|
||||
@@ -9724,19 +9616,8 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/core@12.8.2(typescript@5.9.2)':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 12.8.2
|
||||
'@vueuse/shared': 12.8.2(typescript@5.9.2)
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/metadata@11.0.0': {}
|
||||
|
||||
'@vueuse/metadata@12.8.2': {}
|
||||
|
||||
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
|
||||
@@ -9744,12 +9625,6 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.9.2)':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@webgpu/types@0.1.51': {}
|
||||
|
||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||
@@ -9898,10 +9773,6 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
aria-query@5.3.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -10371,8 +10242,6 @@ snapshots:
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
defu@6.1.4: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
@@ -11694,6 +11563,10 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
magic-string@0.30.17:
|
||||
@@ -12264,8 +12137,6 @@ snapshots:
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
@@ -12842,23 +12713,6 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.0.2
|
||||
|
||||
reka-ui@2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.4
|
||||
'@floating-ui/vue': 1.1.9(vue@3.5.13(typescript@5.9.2))
|
||||
'@internationalized/date': 3.9.0
|
||||
'@internationalized/number': 3.6.5
|
||||
'@tanstack/vue-virtual': 3.13.12(vue@3.5.13(typescript@5.9.2))
|
||||
'@vueuse/core': 12.8.2(typescript@5.9.2)
|
||||
'@vueuse/shared': 12.8.2(typescript@5.9.2)
|
||||
aria-hidden: 1.2.6
|
||||
defu: 6.1.4
|
||||
ohash: 2.0.11
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
|
||||
relateurl@0.2.7: {}
|
||||
|
||||
remark-frontmatter@5.0.0:
|
||||
@@ -13306,8 +13160,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tw-animate-css@1.3.8: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -13654,7 +13506,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.2.12: {}
|
||||
|
||||
vue-component-type-helpers@3.0.7: {}
|
||||
vue-component-type-helpers@3.0.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
interface TestStats {
|
||||
expected?: number
|
||||
unexpected?: number
|
||||
flaky?: number
|
||||
skipped?: number
|
||||
finished?: number
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
stats?: TestStats
|
||||
}
|
||||
|
||||
interface TestCounts {
|
||||
passed: number
|
||||
failed: number
|
||||
flaky: number
|
||||
skipped: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test counts from Playwright HTML report
|
||||
* @param reportDir - Path to the playwright-report directory
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total }
|
||||
*/
|
||||
function extractTestCounts(reportDir: string): TestCounts {
|
||||
const counts: TestCounts = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
flaky: 0,
|
||||
skipped: 0,
|
||||
total: 0
|
||||
}
|
||||
|
||||
try {
|
||||
// First, try to find report.json which Playwright generates with JSON reporter
|
||||
const jsonReportFile = path.join(reportDir, 'report.json')
|
||||
if (fs.existsSync(jsonReportFile)) {
|
||||
const reportJson: ReportData = JSON.parse(
|
||||
fs.readFileSync(jsonReportFile, 'utf-8')
|
||||
)
|
||||
if (reportJson.stats) {
|
||||
const stats = reportJson.stats
|
||||
counts.total = stats.expected || 0
|
||||
counts.passed =
|
||||
(stats.expected || 0) -
|
||||
(stats.unexpected || 0) -
|
||||
(stats.flaky || 0) -
|
||||
(stats.skipped || 0)
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
return counts
|
||||
}
|
||||
}
|
||||
|
||||
// Try index.html - Playwright HTML report embeds data in a script tag
|
||||
const indexFile = path.join(reportDir, 'index.html')
|
||||
if (fs.existsSync(indexFile)) {
|
||||
const content = fs.readFileSync(indexFile, 'utf-8')
|
||||
|
||||
// Look for the embedded report data in various formats
|
||||
// Format 1: window.playwrightReportBase64
|
||||
let dataMatch = content.match(
|
||||
/window\.playwrightReportBase64\s*=\s*["']([^"']+)["']/
|
||||
)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const decodedData = Buffer.from(dataMatch[1], 'base64').toString(
|
||||
'utf-8'
|
||||
)
|
||||
const reportData: ReportData = JSON.parse(decodedData)
|
||||
|
||||
if (reportData.stats) {
|
||||
const stats = reportData.stats
|
||||
counts.total = stats.expected || 0
|
||||
counts.passed =
|
||||
(stats.expected || 0) -
|
||||
(stats.unexpected || 0) -
|
||||
(stats.flaky || 0) -
|
||||
(stats.skipped || 0)
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
return counts
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to try other formats
|
||||
}
|
||||
}
|
||||
|
||||
// Format 2: window.playwrightReport
|
||||
dataMatch = content.match(/window\.playwrightReport\s*=\s*({[\s\S]*?});/)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
// Use Function constructor instead of eval for safety
|
||||
const reportData = new Function(
|
||||
'return ' + dataMatch[1]
|
||||
)() as ReportData
|
||||
|
||||
if (reportData.stats) {
|
||||
const stats = reportData.stats
|
||||
counts.total = stats.expected || 0
|
||||
counts.passed =
|
||||
(stats.expected || 0) -
|
||||
(stats.unexpected || 0) -
|
||||
(stats.flaky || 0) -
|
||||
(stats.skipped || 0)
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
return counts
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to try other formats
|
||||
}
|
||||
}
|
||||
|
||||
// Format 3: Look for stats in the HTML content directly
|
||||
// Playwright sometimes renders stats in the UI
|
||||
const statsMatch = content.match(
|
||||
/(\d+)\s+passed[^0-9]*(\d+)\s+failed[^0-9]*(\d+)\s+flaky[^0-9]*(\d+)\s+skipped/i
|
||||
)
|
||||
if (statsMatch) {
|
||||
counts.passed = parseInt(statsMatch[1]) || 0
|
||||
counts.failed = parseInt(statsMatch[2]) || 0
|
||||
counts.flaky = parseInt(statsMatch[3]) || 0
|
||||
counts.skipped = parseInt(statsMatch[4]) || 0
|
||||
counts.total =
|
||||
counts.passed + counts.failed + counts.flaky + counts.skipped
|
||||
return counts
|
||||
}
|
||||
|
||||
// Format 4: Try to extract from summary text patterns
|
||||
const passedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+passed/i)
|
||||
const failedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+failed/i)
|
||||
const flakyMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+flaky/i)
|
||||
const skippedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+skipped/i)
|
||||
const totalMatch = content.match(
|
||||
/(\d+)\s+(?:tests?|specs?)\s+(?:total|ran)/i
|
||||
)
|
||||
|
||||
if (passedMatch) counts.passed = parseInt(passedMatch[1]) || 0
|
||||
if (failedMatch) counts.failed = parseInt(failedMatch[1]) || 0
|
||||
if (flakyMatch) counts.flaky = parseInt(flakyMatch[1]) || 0
|
||||
if (skippedMatch) counts.skipped = parseInt(skippedMatch[1]) || 0
|
||||
if (totalMatch) {
|
||||
counts.total = parseInt(totalMatch[1]) || 0
|
||||
} else if (
|
||||
counts.passed ||
|
||||
counts.failed ||
|
||||
counts.flaky ||
|
||||
counts.skipped
|
||||
) {
|
||||
counts.total =
|
||||
counts.passed + counts.failed + counts.flaky + counts.skipped
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading report from ${reportDir}:`, error)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const reportDir = process.argv[2]
|
||||
|
||||
if (!reportDir) {
|
||||
console.error('Usage: extract-playwright-counts.ts <report-directory>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const counts = extractTestCounts(reportDir)
|
||||
|
||||
// Output as JSON for easy parsing in shell script
|
||||
console.log(JSON.stringify(counts))
|
||||
|
||||
export { extractTestCounts }
|
||||
@@ -1,377 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# Check if tsx is available, install if not
|
||||
if ! command -v tsx > /dev/null 2>&1; then
|
||||
echo "Installing tsx..." >&2
|
||||
npm install -g tsx >&2 || echo "Failed to install tsx" >&2
|
||||
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 + test counts
|
||||
temp_dir=$(mktemp -d)
|
||||
pids=""
|
||||
i=0
|
||||
|
||||
# Store current working directory for absolute paths
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BASE_DIR="$(pwd)"
|
||||
|
||||
# Start parallel deployments and count extractions
|
||||
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"
|
||||
|
||||
# Extract test counts using tsx (TypeScript executor)
|
||||
EXTRACT_SCRIPT="$SCRIPT_DIR/extract-playwright-counts.ts"
|
||||
REPORT_DIR="$BASE_DIR/reports/playwright-report-$browser"
|
||||
|
||||
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
|
||||
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
|
||||
echo "Extracted counts for $browser: $counts" >&2
|
||||
echo "$counts" > "$temp_dir/$i.counts"
|
||||
else
|
||||
echo "Script not found or tsx not available: $EXTRACT_SCRIPT" >&2
|
||||
echo '{}' > "$temp_dir/$i.counts"
|
||||
fi
|
||||
) &
|
||||
pids="$pids $!"
|
||||
else
|
||||
echo "Report not found for $browser at reports/playwright-report-$browser"
|
||||
echo "failed" > "$temp_dir/$i.url"
|
||||
echo '{}' > "$temp_dir/$i.counts"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Wait for all deployments to complete
|
||||
for pid in $pids; do
|
||||
wait $pid
|
||||
done
|
||||
|
||||
# Collect URLs and counts in order
|
||||
urls=""
|
||||
all_counts=""
|
||||
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
|
||||
|
||||
if [ -f "$temp_dir/$i.counts" ]; then
|
||||
counts=$(cat "$temp_dir/$i.counts")
|
||||
echo "Read counts for $browser from $temp_dir/$i.counts: $counts" >&2
|
||||
else
|
||||
counts="{}"
|
||||
echo "No counts file found for $browser at $temp_dir/$i.counts" >&2
|
||||
fi
|
||||
if [ -z "$all_counts" ]; then
|
||||
all_counts="$counts"
|
||||
else
|
||||
all_counts="$all_counts|$counts"
|
||||
fi
|
||||
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$temp_dir"
|
||||
|
||||
# Calculate total test counts across all browsers
|
||||
total_passed=0
|
||||
total_failed=0
|
||||
total_flaky=0
|
||||
total_skipped=0
|
||||
total_tests=0
|
||||
|
||||
# Parse counts and calculate totals
|
||||
IFS='|'
|
||||
set -- $all_counts
|
||||
for counts_json; do
|
||||
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
|
||||
# Parse JSON counts using simple grep/sed if jq is not available
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
passed=$(echo "$counts_json" | jq -r '.passed // 0')
|
||||
failed=$(echo "$counts_json" | jq -r '.failed // 0')
|
||||
flaky=$(echo "$counts_json" | jq -r '.flaky // 0')
|
||||
skipped=$(echo "$counts_json" | jq -r '.skipped // 0')
|
||||
total=$(echo "$counts_json" | jq -r '.total // 0')
|
||||
else
|
||||
# Fallback parsing without jq
|
||||
passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p')
|
||||
failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p')
|
||||
flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p')
|
||||
skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
|
||||
total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
|
||||
fi
|
||||
|
||||
total_passed=$((total_passed + ${passed:-0}))
|
||||
total_failed=$((total_failed + ${failed:-0}))
|
||||
total_flaky=$((total_flaky + ${flaky:-0}))
|
||||
total_skipped=$((total_skipped + ${skipped:-0}))
|
||||
total_tests=$((total_tests + ${total:-0}))
|
||||
fi
|
||||
done
|
||||
unset IFS
|
||||
|
||||
# Determine overall status
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
status_icon="❌"
|
||||
status_text="Some tests failed"
|
||||
elif [ $total_flaky -gt 0 ]; then
|
||||
status_icon="⚠️"
|
||||
status_text="Tests passed with flaky tests"
|
||||
elif [ $total_tests -gt 0 ]; then
|
||||
status_icon="✅"
|
||||
status_text="All tests passed!"
|
||||
else
|
||||
status_icon="🕵🏻"
|
||||
status_text="No test results found"
|
||||
fi
|
||||
|
||||
# Generate completion comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
$status_icon **$status_text**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
|
||||
|
||||
# Add summary counts if we have test data
|
||||
if [ $total_tests -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### 📈 Summary
|
||||
- **Total Tests:** $total_tests
|
||||
- **Passed:** $total_passed ✅
|
||||
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
|
||||
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
|
||||
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
|
||||
### 📊 Test Reports by Browser"
|
||||
|
||||
# Add browser results with individual counts
|
||||
i=0
|
||||
IFS='|'
|
||||
set -- $all_counts
|
||||
for counts_json; do
|
||||
# Get browser name
|
||||
browser=$(echo "$BROWSERS" | cut -d' ' -f$((i + 1)))
|
||||
# Get URL at position i
|
||||
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
|
||||
|
||||
if [ "$url" != "failed" ] && [ -n "$url" ]; then
|
||||
# Parse individual browser counts
|
||||
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
b_passed=$(echo "$counts_json" | jq -r '.passed // 0')
|
||||
b_failed=$(echo "$counts_json" | jq -r '.failed // 0')
|
||||
b_flaky=$(echo "$counts_json" | jq -r '.flaky // 0')
|
||||
b_skipped=$(echo "$counts_json" | jq -r '.skipped // 0')
|
||||
b_total=$(echo "$counts_json" | jq -r '.total // 0')
|
||||
else
|
||||
b_passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p')
|
||||
b_failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p')
|
||||
b_flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p')
|
||||
b_skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
|
||||
b_total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
|
||||
fi
|
||||
|
||||
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
|
||||
counts_str=" • ✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
|
||||
else
|
||||
counts_str=""
|
||||
fi
|
||||
else
|
||||
counts_str=""
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
- ✅ **${browser}**: [View Report](${url})${counts_str}"
|
||||
else
|
||||
comment="$comment
|
||||
- ❌ **${browser}**: Deployment failed"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
unset IFS
|
||||
|
||||
comment="$comment
|
||||
|
||||
---
|
||||
🎉 Click on the links above to view detailed test results for each browser configuration."
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
@@ -2,12 +2,71 @@
|
||||
|
||||
@import 'tailwindcss/theme' layer(theme);
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@plugin 'tailwindcss-primeui';
|
||||
@plugin "tailwindcss-primeui";
|
||||
|
||||
@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 {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
@@ -48,99 +107,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-node-component-surface: var(--color-charcoal-300);
|
||||
--color-node-component-surface-highlight: var(--color-slate-100);
|
||||
--color-node-component-surface-hovered: var(--color-charcoal-500);
|
||||
--color-node-component-surface-selected: var(--color-charcoal-700);
|
||||
--color-node-stroke: var(--color-stone-100);
|
||||
}
|
||||
|
||||
@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 {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@@ -883,7 +849,7 @@ audio.comfy-audio.empty-audio-widget {
|
||||
.comfy-load-3d,
|
||||
.comfy-load-3d-animation,
|
||||
.comfy-preview-3d,
|
||||
.comfy-preview-3d-animation {
|
||||
.comfy-preview-3d-animation{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
@@ -896,7 +862,7 @@ audio.comfy-audio.empty-audio-widget {
|
||||
.comfy-load-3d-animation canvas,
|
||||
.comfy-preview-3d canvas,
|
||||
.comfy-preview-3d-animation canvas,
|
||||
.comfy-load-3d-viewer canvas {
|
||||
.comfy-load-3d-viewer canvas{
|
||||
display: flex;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
@@ -973,9 +939,7 @@ audio.comfy-audio.empty-audio-widget {
|
||||
|
||||
.lg-node .lg-slot,
|
||||
.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 */
|
||||
@@ -1007,3 +971,4 @@ audio.comfy-audio.empty-audio-widget {
|
||||
/* Use solid colors only */
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const visible = computed(() => position.value !== 'Disabled')
|
||||
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
|
||||
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
@@ -32,13 +33,13 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
components: { IconButton, Trophy },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--trophy] size-4" />
|
||||
<Trophy :size="16" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -50,13 +51,13 @@ export const Primary: Story = {
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
components: { IconButton, Settings },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
<Settings :size="16" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -68,13 +69,13 @@ export const Secondary: Story = {
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
components: { IconButton, X },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<X :size="16" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -86,13 +87,13 @@ export const Transparent: Story = {
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
components: { IconButton, Bell },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--bell] size-3" />
|
||||
<Bell :size="12" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -104,42 +105,42 @@ export const Small: Story = {
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { IconButton },
|
||||
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="sm" @click="() => {}">
|
||||
<i class="icon-[lucide--trophy] size-3" />
|
||||
<Trophy :size="12" />
|
||||
</IconButton>
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--trophy] size-4" />
|
||||
<Trophy :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="secondary" size="sm" @click="() => {}">
|
||||
<i class="icon-[lucide--settings] size-3" />
|
||||
<Settings :size="12" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
<Settings :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="transparent" size="sm" @click="() => {}">
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
<X :size="12" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<X :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--bell] size-4" />
|
||||
<Bell :size="16" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--heart] size-4" />
|
||||
<Heart :size="16" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
<Download :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<slot></slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -21,16 +15,11 @@ import {
|
||||
getButtonTypeClasses,
|
||||
getIconButtonSizeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick: (event: Event) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
@@ -47,6 +36,8 @@ const buttonStyle = computed(() => {
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
import IconGroup from './IconGroup.vue'
|
||||
@@ -16,17 +17,17 @@ type Story = StoryObj<typeof IconGroup>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { IconGroup, IconButton },
|
||||
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
|
||||
template: `
|
||||
<IconGroup>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<i class="icon-[lucide--heart] size-4" />
|
||||
<Heart :size="16" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
<Download :size="16" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
<ExternalLink :size="16" />
|
||||
</IconButton>
|
||||
</IconGroup>
|
||||
`
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
<template>
|
||||
<div :class="iconGroupClasses">
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
</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,4 +1,14 @@
|
||||
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'
|
||||
|
||||
@@ -39,14 +49,14 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
components: { IconTextButton, Package },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--package] size-4" />
|
||||
<Package :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -60,14 +70,14 @@ export const Primary: Story = {
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
components: { IconTextButton, Settings },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
<Settings :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -81,14 +91,14 @@ export const Secondary: Story = {
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
components: { IconTextButton, X },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<X :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -102,14 +112,14 @@ export const Transparent: Story = {
|
||||
|
||||
export const WithIconRight: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
components: { IconTextButton, ChevronRight },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
<ChevronRight :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -124,14 +134,14 @@ export const WithIconRight: Story = {
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
components: { IconTextButton, Save },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--save] size-3" />
|
||||
<Save :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -146,60 +156,66 @@ export const Small: Story = {
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
IconTextButton
|
||||
IconTextButton,
|
||||
Download,
|
||||
Settings,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Save
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-3" />
|
||||
<Download :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
<Download :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--settings] size-3" />
|
||||
<Settings :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
<Settings :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-3" />
|
||||
<Trash2 :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
<Trash2 :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
<ChevronRight :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
<ChevronLeft :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
<Save :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
<slot v-if="iconPosition === 'right'" name="icon"></slot>
|
||||
@@ -23,11 +17,6 @@ import {
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface IconTextButtonProps extends BaseButtonProps {
|
||||
iconPosition?: 'left' | 'right'
|
||||
@@ -53,6 +42,8 @@ const buttonStyle = computed(() => {
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Download, ScrollText } from 'lucide-vue-next'
|
||||
|
||||
import IconTextButton from './IconTextButton.vue'
|
||||
import MoreButton from './MoreButton.vue'
|
||||
@@ -17,7 +18,7 @@ type Story = StoryObj<typeof MoreButton>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { MoreButton, IconTextButton },
|
||||
components: { MoreButton, IconTextButton, Download, ScrollText },
|
||||
template: `
|
||||
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||
<MoreButton>
|
||||
@@ -28,7 +29,7 @@ export const Basic: Story = {
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
<Download :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
@@ -38,7 +39,7 @@ export const Basic: Story = {
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--scroll-text] size-4" />
|
||||
<ScrollText :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
unstyled
|
||||
:pt="pt"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-2 min-w-40">
|
||||
<div class="flex flex-col gap-1 p-2 min-w-40">
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
</Popover>
|
||||
@@ -25,8 +25,6 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
@@ -41,16 +39,13 @@ const hide = () => {
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: {
|
||||
class: cn('absolute z-50')
|
||||
class: 'absolute z-50'
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'shadow-lg',
|
||||
'border border-zinc-200 dark-theme:border-zinc-700'
|
||||
)
|
||||
class: [
|
||||
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg',
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
||||
]
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<span>{{ label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -21,17 +15,12 @@ import {
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TextButtonProps extends BaseButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
@@ -49,6 +38,8 @@ const buttonStyle = computed(() => {
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
Download,
|
||||
Folder,
|
||||
Heart,
|
||||
Info,
|
||||
MoreVertical,
|
||||
Star,
|
||||
Upload
|
||||
} from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconButton from '../button/IconButton.vue'
|
||||
@@ -49,6 +58,14 @@ const meta: Meta<CardStoryArgs> = {
|
||||
options: ['square', 'portrait', 'tallPortrait'],
|
||||
description: 'Card container aspect ratio'
|
||||
},
|
||||
maxWidth: {
|
||||
control: { type: 'range', min: 200, max: 600, step: 10 },
|
||||
description: 'Maximum width in pixels'
|
||||
},
|
||||
minWidth: {
|
||||
control: { type: 'range', min: 150, max: 400, step: 10 },
|
||||
description: 'Minimum width in pixels'
|
||||
},
|
||||
topRatio: {
|
||||
control: 'select',
|
||||
options: ['square', 'landscape'],
|
||||
@@ -132,7 +149,14 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
IconButton,
|
||||
SquareChip
|
||||
SquareChip,
|
||||
Info,
|
||||
Folder,
|
||||
Heart,
|
||||
Download,
|
||||
Star,
|
||||
Upload,
|
||||
MoreVertical
|
||||
},
|
||||
setup() {
|
||||
const favorited = ref(false)
|
||||
@@ -147,10 +171,11 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen">
|
||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<CardContainer
|
||||
:ratio="args.containerRatio"
|
||||
class="max-w-[320px] mx-auto"
|
||||
:max-width="args.maxWidth"
|
||||
:min-width="args.minWidth"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop :ratio="args.topRatio">
|
||||
@@ -177,14 +202,14 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
class="!bg-white/90 !text-neutral-900"
|
||||
@click="() => console.log('Info clicked')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="!bg-white/90"
|
||||
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<i class="icon-[lucide--heart] size-4" :class="favorited ? 'fill-current' : ''" />
|
||||
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
@@ -197,7 +222,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
|
||||
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
|
||||
<template v-if="tag === 'LoRA'" #icon>
|
||||
<i class="icon-[lucide--folder] size-3" />
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
@@ -205,7 +230,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3 bg-neutral-100">
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
@@ -219,6 +244,8 @@ export const Default: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
maxWidth: 300,
|
||||
minWidth: 200,
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -244,6 +271,8 @@ export const SquareCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
maxWidth: 400,
|
||||
minWidth: 250,
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -269,6 +298,8 @@ export const TallPortraitCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
maxWidth: 280,
|
||||
minWidth: 180,
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -294,6 +325,8 @@ export const ImageCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
maxWidth: 350,
|
||||
minWidth: 220,
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -318,6 +351,8 @@ export const MinimalCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
maxWidth: 300,
|
||||
minWidth: 200,
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
@@ -342,6 +377,8 @@ export const FullFeaturedCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
maxWidth: 320,
|
||||
minWidth: 240,
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -355,10 +392,274 @@ export const FullFeaturedCard: Story = {
|
||||
backgroundColor: '#ef4444',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Bundle', 'SDXL'],
|
||||
tags: ['Bundle', 'Premium', 'SDXL'],
|
||||
showFileSize: true,
|
||||
fileSize: '5.4 GB',
|
||||
showFileType: true,
|
||||
fileType: 'pack'
|
||||
}
|
||||
}
|
||||
|
||||
export const GridOfCards: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
IconButton,
|
||||
SquareChip,
|
||||
Info,
|
||||
Folder,
|
||||
Heart,
|
||||
Download
|
||||
},
|
||||
setup() {
|
||||
const cards = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Realistic Vision',
|
||||
description: 'Photorealistic model for portraits',
|
||||
color: 'from-blue-400 to-blue-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['SD 1.5'],
|
||||
size: '2.1 GB'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'DreamShaper XL',
|
||||
description: 'Artistic style model with enhanced details',
|
||||
color: 'from-purple-400 to-pink-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['SDXL'],
|
||||
size: '6.5 GB'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Anime LoRA',
|
||||
description: 'Character style LoRA',
|
||||
color: 'from-green-400 to-teal-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['LoRA'],
|
||||
size: '144 MB'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'VAE Model',
|
||||
description: 'Enhanced color VAE',
|
||||
color: 'from-orange-400 to-red-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['VAE'],
|
||||
size: '335 MB'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Workflow Bundle',
|
||||
description: 'Complete workflow setup',
|
||||
color: 'from-indigo-400 to-blue-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['Workflow'],
|
||||
size: '45 KB'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Embedding Pack',
|
||||
description: 'Negative embeddings collection',
|
||||
color: 'from-yellow-400 to-orange-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['Embedding'],
|
||||
size: '2.3 MB'
|
||||
}
|
||||
])
|
||||
|
||||
return { cards }
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="300"
|
||||
:min-width="180"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-gray-600"
|
||||
:class="card.color"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<template #top-right>
|
||||
<IconButton
|
||||
class="!bg-white/90 !text-neutral-900"
|
||||
@click="() => console.log('Info:', card.title)"
|
||||
>
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in card.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
>
|
||||
<template v-if="tag === 'LoRA'" #icon>
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
<SquareChip :label="card.size" />
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ResponsiveGrid: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
SquareChip
|
||||
},
|
||||
setup() {
|
||||
const generateCards = (
|
||||
count: number,
|
||||
ratio: 'square' | 'portrait' | 'tallPortrait'
|
||||
) => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Model ${i + 1}`,
|
||||
description: `Description for model ${i + 1}`,
|
||||
ratio,
|
||||
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
|
||||
}))
|
||||
}
|
||||
|
||||
const squareCards = ref(generateCards(4, 'square'))
|
||||
const portraitCards = ref(generateCards(6, 'portrait'))
|
||||
const tallCards = ref(generateCards(5, 'tallPortrait'))
|
||||
|
||||
return {
|
||||
squareCards,
|
||||
portraitCards,
|
||||
tallCards
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in squareCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="400"
|
||||
:min-width="200"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in portraitCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="280"
|
||||
:min-width="160"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-2">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in tallCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="260"
|
||||
:min-width="150"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip :label="'#' + card.id" />
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<div :class="containerClasses" :style="containerStyle">
|
||||
<slot name="top"></slot>
|
||||
<slot name="bottom"></slot>
|
||||
</div>
|
||||
@@ -8,7 +8,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
const {
|
||||
ratio = 'square',
|
||||
maxWidth,
|
||||
minWidth
|
||||
} = defineProps<{
|
||||
maxWidth?: number
|
||||
minWidth?: number
|
||||
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
||||
}>()
|
||||
|
||||
@@ -24,4 +30,13 @@ const containerClasses = computed(() => {
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() =>
|
||||
maxWidth || minWidth
|
||||
? {
|
||||
maxWidth: `${maxWidth}px`,
|
||||
minWidth: `${minWidth}px`
|
||||
}
|
||||
: {}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
|
||||
<div class="text-2xl font-medium mb-2">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="$t('loadWorkflowWarning.missingNodesTitle')"
|
||||
:message="$t('loadWorkflowWarning.missingNodesDescription')"
|
||||
title="Some Nodes Are Missing"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
@@ -53,16 +53,13 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
@@ -124,35 +121,6 @@ const openManager = async () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import ManagerHeader from './ManagerHeader.vue'
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import PackVersionBadge from './PackVersionBadge.vue'
|
||||
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
|
||||
@@ -32,14 +31,11 @@ const mockInstalledPacks = {
|
||||
'installed-pack': { ver: '2.0.0' }
|
||||
}
|
||||
|
||||
const mockIsPackEnabled = vi.fn(() => true)
|
||||
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
installedPacks: mockInstalledPacks,
|
||||
isPackInstalled: (id: string) =>
|
||||
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
|
||||
isPackEnabled: mockIsPackEnabled
|
||||
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks]
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -64,7 +60,6 @@ describe('PackVersionBadge', () => {
|
||||
beforeEach(() => {
|
||||
mockToggle.mockReset()
|
||||
mockHide.mockReset()
|
||||
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
|
||||
})
|
||||
|
||||
const mountComponent = ({
|
||||
@@ -84,9 +79,6 @@ describe('PackVersionBadge', () => {
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
},
|
||||
stubs: {
|
||||
Popover: PopoverStub,
|
||||
PackVersionSelectorPopover: true
|
||||
@@ -237,63 +229,4 @@ describe('PackVersionBadge', () => {
|
||||
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,28 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-tooltip.top="
|
||||
isDisabled ? $t('manager.enablePackToChangeVersion') : null
|
||||
"
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
|
||||
:class="{
|
||||
'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
|
||||
'cursor-pointer': !isDisabled,
|
||||
'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)"
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
|
||||
aria-haspopup="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleVersionSelector"
|
||||
@keydown.enter="toggleVersionSelector"
|
||||
@keydown.space="toggleVersionSelector"
|
||||
>
|
||||
<i
|
||||
v-if="isUpdateAvailable"
|
||||
class="pi pi-arrow-circle-up text-blue-600 text-xs"
|
||||
/>
|
||||
<span>{{ installedVersion }}</span>
|
||||
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
|
||||
<i class="pi pi-chevron-right text-xxs" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
@@ -68,11 +61,6 @@ const popoverRef = ref()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
|
||||
const isDisabled = computed(
|
||||
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
|
||||
)
|
||||
|
||||
const installedVersion = computed(() => {
|
||||
if (!nodePack.id) return 'nightly'
|
||||
const version =
|
||||
|
||||
@@ -10,7 +10,7 @@ import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
// SelectedVersion is now using direct strings instead of enum
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
|
||||
import PackEnableToggle from './PackEnableToggle.vue'
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<IconTextButton
|
||||
v-tooltip.top="
|
||||
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
|
||||
"
|
||||
v-bind="$attrs"
|
||||
type="transparent"
|
||||
:label="$t('manager.updateAll')"
|
||||
@@ -27,9 +24,8 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
|
||||
const { nodePacks } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
hasDisabledUpdatePacks?: boolean
|
||||
}>()
|
||||
|
||||
const isUpdating = ref<boolean>(false)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
import DescriptionTabPanel from './DescriptionTabPanel.vue'
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
/>
|
||||
<PackUpdateButton
|
||||
v-if="isUpdateAvailableTab && hasUpdateAvailable"
|
||||
:node-packs="enabledUpdateAvailableNodePacks"
|
||||
:has-disabled-update-packs="hasDisabledUpdatePacks"
|
||||
:node-packs="updateAvailableNodePacks"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
@@ -104,11 +103,8 @@ const { t } = useI18n()
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
// Use the composable to get update available nodes
|
||||
const {
|
||||
hasUpdateAvailable,
|
||||
enabledUpdateAvailableNodePacks,
|
||||
hasDisabledUpdatePacks
|
||||
} = useUpdateAvailableNodes()
|
||||
const { hasUpdateAvailable, updateAvailableNodePacks } =
|
||||
useUpdateAvailableNodes()
|
||||
|
||||
const hasResults = computed(
|
||||
() => searchQuery.value?.trim() && searchResults?.length
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import GridSkeleton from './GridSkeleton.vue'
|
||||
import PackCardSkeleton from './PackCardSkeleton.vue'
|
||||
|
||||
@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import SignInForm from './SignInForm.vue'
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
id="graph-canvas"
|
||||
ref="canvasRef"
|
||||
tabindex="1"
|
||||
class="align-top w-full h-full touch-none"
|
||||
class="w-full h-full touch-none"
|
||||
/>
|
||||
|
||||
<!-- TransformPane for Vue node rendering -->
|
||||
@@ -36,17 +36,21 @@
|
||||
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<VueGraphNode
|
||||
v-for="nodeData in allNodes"
|
||||
v-for="nodeData in nodesToRender"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:readonly="false"
|
||||
:executing="executionStore.executingNodeId === nodeData.id"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
@node-click="handleNodeSelect"
|
||||
@@ -92,7 +96,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
|
||||
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
@@ -112,7 +116,6 @@ import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.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 { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -144,8 +147,6 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
@@ -178,27 +179,31 @@ const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
||||
|
||||
const nodePositions = vueNodeLifecycle.nodePositions
|
||||
const nodeSizes = vueNodeLifecycle.nodeSizes
|
||||
const allNodes = viewportCulling.allNodes
|
||||
const nodesToRender = viewportCulling.nodesToRender
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
viewportCulling.handleTransformUpdate()
|
||||
// TODO: Fix paste position sync in separate PR
|
||||
vueNodeLifecycle.detectChangesInRAF.value()
|
||||
viewportCulling.handleTransformUpdate(
|
||||
vueNodeLifecycle.detectChangesInRAF.value
|
||||
)
|
||||
}
|
||||
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
|
||||
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
|
||||
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
|
||||
|
||||
// Provide selection state to all Vue nodes
|
||||
const selectedNodeIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
canvasStore.selectedItems
|
||||
const selectedNodeIds = ref(new Set<string>())
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
watch(
|
||||
() => canvasStore.selectedItems,
|
||||
(newSelectedItems) => {
|
||||
selectedNodeIds.value = new Set(
|
||||
newSelectedItems
|
||||
.filter((item) => item.id !== undefined)
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
|
||||
@@ -13,9 +13,6 @@ interface ExtendedProps extends Partial<MultiSelectProps> {
|
||||
showSelectedCount?: boolean
|
||||
showClearButton?: boolean
|
||||
searchPlaceholder?: string
|
||||
listMaxHeight?: string
|
||||
popoverMinWidth?: string
|
||||
popoverMaxWidth?: string
|
||||
// Override modelValue type to match our Option type
|
||||
modelValue?: Array<{ name: string; value: string }>
|
||||
}
|
||||
@@ -45,18 +42,6 @@ const meta: Meta<ExtendedProps> = {
|
||||
},
|
||||
searchPlaceholder: {
|
||||
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: {
|
||||
@@ -289,140 +274,3 @@ export const CustomSearchPlaceholder: Story = {
|
||||
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,9 +1,10 @@
|
||||
<template>
|
||||
<!--
|
||||
<!--
|
||||
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)
|
||||
2. We display a count badge instead of actual selected labels
|
||||
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
|
||||
max-selected-labels="0" is required to show count badge instead of selected item labels
|
||||
-->
|
||||
@@ -19,13 +20,12 @@
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
#header
|
||||
>
|
||||
<div class="pt-2 pb-0 px-2 flex flex-col">
|
||||
<div class="p-2 flex flex-col pb-0">
|
||||
<SearchBox
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:show-order="true"
|
||||
:show-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div
|
||||
@@ -47,11 +47,11 @@
|
||||
:label="$t('g.clearAll')"
|
||||
type="transparent"
|
||||
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 = []"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,13 +75,13 @@
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2" :style="popoverStyle">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
||||
: 'bg-neutral-100 dark-theme:bg-zinc-700'
|
||||
? 'border-[3px] border-blue-400 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'
|
||||
"
|
||||
>
|
||||
<i-lucide:check
|
||||
@@ -89,11 +89,9 @@
|
||||
class="text-xs text-bold text-white"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
class="border-none outline-none bg-transparent text-left"
|
||||
unstyled
|
||||
>{{ slotProps.option.name }}</Button
|
||||
>
|
||||
<Button class="border-none outline-none bg-transparent" unstyled>{{
|
||||
slotProps.option.name
|
||||
}}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
@@ -107,8 +105,6 @@ import MultiSelect, {
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TextButton from '../button/TextButton.vue'
|
||||
|
||||
@@ -129,12 +125,6 @@ interface Props {
|
||||
showClearButton?: boolean
|
||||
/** Placeholder for the search input */
|
||||
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.
|
||||
// It's passed via $attrs to maximize PrimeVue API compatibility
|
||||
}
|
||||
@@ -143,10 +133,7 @@ const {
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
searchPlaceholder = 'Search...',
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
searchPlaceholder = 'Search...'
|
||||
} = defineProps<Props>()
|
||||
|
||||
const selectedItems = defineModel<Option[]>({
|
||||
@@ -155,15 +142,10 @@ const selectedItems = defineModel<Option[]>({
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
|
||||
const popoverStyle = usePopoverSizing({
|
||||
minWidth: popoverMinWidth,
|
||||
maxWidth: popoverMaxWidth
|
||||
})
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'h-10 relative inline-flex cursor-pointer select-none',
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
@@ -188,26 +170,16 @@ const pt = computed(() => ({
|
||||
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay: {
|
||||
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'
|
||||
}),
|
||||
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',
|
||||
list: {
|
||||
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
|
||||
},
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'flex gap-2 items-center h-10 px-2 rounded-lg',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
'flex gap-1 items-center p-2',
|
||||
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
|
||||
// Add focus/highlight state for keyboard navigation
|
||||
{
|
||||
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
|
||||
@@ -217,11 +189,11 @@ const pt = computed(() => ({
|
||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
style: 'display: none !important'
|
||||
},
|
||||
pcOptionCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
style: 'display: none !important'
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -14,17 +14,11 @@ const meta: Meta<typeof SearchBox> = {
|
||||
showBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border prop'
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['md', 'lg'],
|
||||
description: 'Size variant of the search box'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
placeHolder: 'Search...',
|
||||
showBorder: false,
|
||||
size: 'md'
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,27 +53,3 @@ export const NoBorder: Story = {
|
||||
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...'"
|
||||
type="text"
|
||||
unstyled
|
||||
:class="inputStyle"
|
||||
class="w-full p-0 border-none outline-hidden bg-transparent text-xs text-neutral dark-theme:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,56 +15,20 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
placeHolder,
|
||||
showBorder = false,
|
||||
size = 'md'
|
||||
} = defineProps<{
|
||||
const { placeHolder, showBorder = false } = defineProps<{
|
||||
placeHolder?: string
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
}>()
|
||||
// defineModel without arguments uses 'modelValue' as the prop name
|
||||
const searchQuery = defineModel<string>()
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
const baseClasses = [
|
||||
'relative flex w-full items-center gap-2',
|
||||
'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'
|
||||
)
|
||||
return showBorder
|
||||
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
|
||||
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
|
||||
})
|
||||
|
||||
const iconColorStyle = computed(() => {
|
||||
return cn(
|
||||
!showBorder ? 'text-neutral' : ['text-zinc-300', 'dark-theme:text-zinc-700']
|
||||
)
|
||||
return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ArrowUpDown } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
@@ -10,19 +11,7 @@ const meta: Meta<typeof SingleSelect> = {
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
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'
|
||||
}
|
||||
options: { control: 'object' }
|
||||
},
|
||||
args: {
|
||||
label: 'Sorting Type',
|
||||
@@ -68,7 +57,7 @@ export const Default: Story = {
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
components: { SingleSelect, ArrowUpDown },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
const options = sampleOptions
|
||||
@@ -78,7 +67,7 @@ export const WithIcon: Story = {
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
<ArrowUpDown :size="14" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
@@ -105,7 +94,7 @@ export const Preselected: Story = {
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
components: { SingleSelect, ArrowUpDown },
|
||||
setup() {
|
||||
const options = sampleOptions
|
||||
const a = ref<string | null>(null)
|
||||
@@ -121,7 +110,7 @@ export const AllVariants: Story = {
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="b" :options="options" label="With Icon">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
<ArrowUpDown :size="14" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
@@ -133,124 +122,6 @@ export const AllVariants: Story = {
|
||||
}),
|
||||
parameters: {
|
||||
controls: { 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 }
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<!--
|
||||
<!--
|
||||
Note: We explicitly pass options here (not just via $attrs) because:
|
||||
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
|
||||
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
|
||||
-->
|
||||
<Select
|
||||
@@ -17,7 +18,7 @@
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
@@ -33,19 +34,18 @@
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down class="text-base text-neutral-500" />
|
||||
<i-lucide:chevron-down
|
||||
class="text-base text-neutral-400 dark-theme:text-gray-300"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
<template #option="{ option, selected }">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 w-full"
|
||||
:style="optionStyle"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i-lucide:check
|
||||
v-if="selected"
|
||||
class="text-neutral-600 dark-theme:text-white"
|
||||
class="text-neutral-900 dark-theme:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,19 +56,11 @@
|
||||
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
label,
|
||||
options,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
} = defineProps<{
|
||||
const { label, options } = defineProps<{
|
||||
label?: string
|
||||
/**
|
||||
* Required for displaying the selected item's label.
|
||||
@@ -79,12 +71,6 @@ const {
|
||||
name: 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 })
|
||||
@@ -101,17 +87,6 @@ const getLabel = (val: string | null | undefined) => {
|
||||
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
|
||||
* - No background/border (same as page background)
|
||||
@@ -123,7 +98,7 @@ const pt = computed(() => ({
|
||||
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
||||
class: [
|
||||
// container
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
'relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-md',
|
||||
'bg-transparent text-neutral dark-theme:text-white',
|
||||
@@ -143,28 +118,23 @@ const pt = computed(() => ({
|
||||
'flex shrink-0 items-center justify-center px-3 py-2'
|
||||
},
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 p-2 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
|
||||
)
|
||||
class: [
|
||||
// dropdown panel
|
||||
'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'
|
||||
]
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: `max-height: ${listMaxHeight}`,
|
||||
class: 'overflow-y-auto scrollbar-hide'
|
||||
}),
|
||||
list: {
|
||||
class:
|
||||
// Same list tone/size as MultiSelect
|
||||
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
'flex flex-col gap-1 p-0 list-none border-none text-xs'
|
||||
},
|
||||
option: ({
|
||||
context
|
||||
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
||||
class: [
|
||||
// Row layout
|
||||
'flex items-center justify-between gap-3 px-2 py-3 rounded',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
'flex items-center justify-between gap-3 px-3 py-2',
|
||||
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
|
||||
// Selected state + check icon
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
|
||||
// Add focus state for keyboard navigation
|
||||
|
||||
@@ -88,8 +88,8 @@ const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
|
||||
useConflictAcknowledgment()
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
const shouldShowRedDot = computed(() => {
|
||||
const releaseRedDot = showReleaseRedDot
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import CurrentUserButton from './CurrentUserButton.vue'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
|
||||
import {
|
||||
SliderRange,
|
||||
SliderRoot,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import { type HTMLAttributes, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<
|
||||
SliderRootProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const pressed = ref(false)
|
||||
const setPressed = (val: boolean) => {
|
||||
pressed.value = val
|
||||
}
|
||||
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SliderRoot
|
||||
v-slot="{ modelValue }"
|
||||
data-slot="slider"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
|
||||
'data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@slide-start="() => setPressed(true)"
|
||||
@slide-move="() => setPressed(true)"
|
||||
@slide-end="() => setPressed(false)"
|
||||
>
|
||||
<SliderTrack
|
||||
data-slot="slider-track"
|
||||
:class="
|
||||
cn(
|
||||
'bg-node-stroke relative grow overflow-hidden rounded-full',
|
||||
'cursor-pointer overflow-visible',
|
||||
`before:absolute before:-inset-2 before:block before:bg-transparent`,
|
||||
'data-[orientation=horizontal]:h-0.5 data-[orientation=horizontal]:w-full',
|
||||
'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-0.5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
class="bg-node-component-surface-highlight absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
/>
|
||||
</SliderTrack>
|
||||
|
||||
<SliderThumb
|
||||
v-for="(_, key) in modelValue"
|
||||
:key="key"
|
||||
data-slot="slider-thumb"
|
||||
:class="
|
||||
cn(
|
||||
'bg-node-component-surface-highlight ring-node-component-surface-selected block size-3.5 shrink-0 rounded-full shadow-sm transition-[color,box-shadow]',
|
||||
'cursor-grab',
|
||||
'before:absolute before:-inset-1 before:block before:bg-transparent before:rounded-full',
|
||||
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SliderRoot>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseModalLayout :content-title="$t('Checkpoints')">
|
||||
<BaseWidgetLayout :content-title="$t('Checkpoints')">
|
||||
<template #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
||||
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -56,7 +56,7 @@
|
||||
</template>
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative px-6 pb-4 flex gap-2">
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
v-model:search-query="searchText"
|
||||
@@ -87,8 +87,16 @@
|
||||
|
||||
<template #content>
|
||||
<!-- Card Examples -->
|
||||
<div :style="gridStyle">
|
||||
<CardContainer v-for="i in 100" :key="i" ratio="square">
|
||||
<!-- <div class="min-h-0 px-6 py-4 overflow-y-auto scrollbar-hide"> -->
|
||||
<!-- <h2 class="text-xxl py-4 pt-0 m-0">{{ $t('Checkpoints') }}</h2> -->
|
||||
<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>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
@@ -118,16 +126,17 @@
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
</template>
|
||||
|
||||
<template #rightPanel>
|
||||
<RightSidePanel></RightSidePanel>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</BaseWidgetLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, ref, watch } from 'vue'
|
||||
import { provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
@@ -140,12 +149,11 @@ import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import BaseWidgetLayout from '@/components/widget/layout/BaseWidgetLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
|
||||
import { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
const frameworkOptions = ref([
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
@@ -167,20 +175,20 @@ const sortOptions = ref([
|
||||
])
|
||||
|
||||
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
|
||||
{ id: 'installed', label: 'Installed', icon: 'icon-[lucide--download]' },
|
||||
{ id: 'installed', label: 'Installed' },
|
||||
{
|
||||
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]' }
|
||||
{ id: 'tag-sd15', label: 'SD 1.5' },
|
||||
{ id: 'tag-sdxl', label: 'SDXL' },
|
||||
{ id: 'tag-utility', label: 'Utility' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'CATEGORIES',
|
||||
items: [
|
||||
{ id: 'cat-models', label: 'Models', icon: 'icon-[lucide--layers]' },
|
||||
{ id: 'cat-nodes', label: 'Nodes', icon: 'icon-[lucide--grid-3x3]' }
|
||||
{ id: 'cat-models', label: 'Models' },
|
||||
{ id: 'cat-nodes', label: 'Nodes' }
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -201,8 +209,6 @@ const selectedSort = ref<string>('popular')
|
||||
|
||||
const selectedNavItem = ref<string | null>('installed')
|
||||
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
|
||||
watch(searchText, (newQuery) => {
|
||||
console.log('searchText:', searchText.value, newQuery)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import {
|
||||
Download,
|
||||
Filter,
|
||||
Folder,
|
||||
Info,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRight,
|
||||
PanelRightClose,
|
||||
Puzzle,
|
||||
Scroll,
|
||||
Settings,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
@@ -13,11 +28,10 @@ import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
||||
import RightSidePanel from '../panel/RightSidePanel.vue'
|
||||
import BaseModalLayout from './BaseModalLayout.vue'
|
||||
import BaseWidgetLayout from './BaseWidgetLayout.vue'
|
||||
|
||||
interface StoryArgs {
|
||||
contentTitle: string
|
||||
@@ -30,7 +44,7 @@ interface StoryArgs {
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Widget/Layout/BaseModalLayout',
|
||||
title: 'Components/Widget/Layout/BaseWidgetLayout',
|
||||
argTypes: {
|
||||
contentTitle: {
|
||||
control: 'text',
|
||||
@@ -68,7 +82,7 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
const createStoryTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
BaseModalLayout,
|
||||
BaseWidgetLayout,
|
||||
LeftSidePanel,
|
||||
RightSidePanel,
|
||||
SearchBox,
|
||||
@@ -80,7 +94,20 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
SquareChip
|
||||
SquareChip,
|
||||
Settings,
|
||||
Upload,
|
||||
Download,
|
||||
Scroll,
|
||||
Info,
|
||||
Filter,
|
||||
Folder,
|
||||
Puzzle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRight,
|
||||
PanelRightClose,
|
||||
X
|
||||
},
|
||||
setup() {
|
||||
const t = (k: string) => k
|
||||
@@ -91,44 +118,20 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
|
||||
{
|
||||
id: 'installed',
|
||||
label: 'Installed',
|
||||
icon: 'icon-[lucide--folder]'
|
||||
},
|
||||
{ id: 'installed', label: 'Installed' },
|
||||
{
|
||||
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]'
|
||||
}
|
||||
{ id: 'tag-sd15', label: 'SD 1.5' },
|
||||
{ id: 'tag-sdxl', label: 'SDXL' },
|
||||
{ id: 'tag-utility', label: 'Utility' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'CATEGORIES',
|
||||
items: [
|
||||
{
|
||||
id: 'cat-models',
|
||||
label: 'Models',
|
||||
icon: 'icon-[lucide--layers]'
|
||||
},
|
||||
{
|
||||
id: 'cat-nodes',
|
||||
label: 'Nodes',
|
||||
icon: 'icon-[lucide--grid-3x3]'
|
||||
}
|
||||
{ id: 'cat-models', label: 'Models' },
|
||||
{ id: 'cat-nodes', label: 'Nodes' }
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -157,8 +160,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
const selectedProjects = ref<string[]>([])
|
||||
const selectedSort = ref<string>('popular')
|
||||
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
|
||||
return {
|
||||
args,
|
||||
t,
|
||||
@@ -170,18 +171,17 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
sortOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedSort,
|
||||
gridStyle
|
||||
selectedSort
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
|
||||
<BaseWidgetLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
<Puzzle :size="16" class="text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
@@ -193,7 +193,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
@update:modelValue="searchQuery = $event"
|
||||
/>
|
||||
@@ -204,7 +203,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--upload] size-3" />
|
||||
<Upload :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
@@ -216,7 +215,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-3" />
|
||||
<Download :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
@@ -226,7 +225,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--scroll] size-3" />
|
||||
<Scroll :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
@@ -236,7 +235,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Content Filter -->
|
||||
<template v-if="args.hasContentFilter" #contentFilter>
|
||||
<div class="relative px-6 py-4 flex gap-2">
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
label="Select Frameworks"
|
||||
@@ -257,7 +256,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
class="w-[135px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--filter] size-3" />
|
||||
<Filter :size="12" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
@@ -265,7 +264,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Content -->
|
||||
<template #content>
|
||||
<div :style="gridStyle">
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
|
||||
<CardContainer
|
||||
v-for="i in args.cardCount"
|
||||
:key="i"
|
||||
@@ -278,7 +277,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
</template>
|
||||
<template #top-right>
|
||||
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
@@ -286,7 +285,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
<SquareChip label="1.2 MB" />
|
||||
<SquareChip label="LoRA">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--folder] size-3" />
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
@@ -298,15 +297,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
</CardContainer>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</BaseWidgetLayout>
|
||||
|
||||
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
|
||||
<BaseWidgetLayout v-else :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Same content but WITH right panel -->
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
<Puzzle :size="16" class="text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
@@ -318,7 +317,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
@update:modelValue="searchQuery = $event"
|
||||
/>
|
||||
@@ -329,7 +327,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--upload] size-3" />
|
||||
<Upload :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
@@ -341,7 +339,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-3" />
|
||||
<Download :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
@@ -351,7 +349,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--scroll] size-3" />
|
||||
<Scroll :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
@@ -361,7 +359,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Content Filter -->
|
||||
<template v-if="args.hasContentFilter" #contentFilter>
|
||||
<div class="relative px-6 py-4 flex gap-2">
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
label="Select Frameworks"
|
||||
@@ -379,7 +377,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
class="w-[135px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--filter] size-3" />
|
||||
<Filter :size="12" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
@@ -387,7 +385,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Content -->
|
||||
<template #content>
|
||||
<div :style="gridStyle">
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
|
||||
<CardContainer
|
||||
v-for="i in args.cardCount"
|
||||
:key="i"
|
||||
@@ -400,7 +398,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
</template>
|
||||
<template #top-right>
|
||||
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
@@ -408,7 +406,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
<SquareChip label="1.2 MB" />
|
||||
<SquareChip label="LoRA">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--folder] size-3" />
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
@@ -425,7 +423,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
<template #rightPanel>
|
||||
<RightSidePanel />
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</BaseWidgetLayout>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
<div :class="layoutClasses">
|
||||
<div
|
||||
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-50 dark-theme:bg-zinc-800"
|
||||
>
|
||||
<IconButton
|
||||
v-show="!isRightPanelOpen && hasRightPanel"
|
||||
:class="rightPanelButtonClasses"
|
||||
class="absolute top-4 right-16 z-10 transition-opacity duration-200"
|
||||
:class="{
|
||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
||||
}"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i-lucide:panel-right class="text-sm" />
|
||||
</IconButton>
|
||||
<IconButton :class="closeButtonClasses" @click="closeDialog">
|
||||
<IconButton
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times text-sm"></i>
|
||||
</IconButton>
|
||||
<div class="flex w-full h-full">
|
||||
@@ -24,9 +32,12 @@
|
||||
</nav>
|
||||
</Transition>
|
||||
|
||||
<div :class="mainContainerClasses">
|
||||
<div class="flex-1 flex bg-zinc-100 dark-theme:bg-neutral-900">
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<header v-if="$slots.header" :class="headerClasses">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
|
||||
>
|
||||
<div class="flex-1 flex gap-2 shrink-0">
|
||||
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
|
||||
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
|
||||
@@ -35,7 +46,12 @@
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<slot name="header-right-area"></slot>
|
||||
<div :class="rightAreaClasses">
|
||||
<div
|
||||
class="flex justify-end gap-2 w-0"
|
||||
:class="
|
||||
hasRightPanel && !isRightPanelOpen ? 'min-w-18' : 'min-w-8'
|
||||
"
|
||||
>
|
||||
<IconButton
|
||||
v-if="isRightPanelOpen && hasRightPanel"
|
||||
@click="toggleRightPanel"
|
||||
@@ -51,14 +67,14 @@
|
||||
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div :class="contentContainerClasses">
|
||||
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto scrollbar-hide">
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<aside
|
||||
v-if="hasRightPanel && isRightPanelOpen"
|
||||
:class="rightPanelClasses"
|
||||
class="w-1/4 min-w-40 max-w-80"
|
||||
>
|
||||
<slot name="rightPanel"></slot>
|
||||
</aside>
|
||||
@@ -73,7 +89,6 @@ import { computed, inject, ref, useSlots, watch } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { contentTitle } = defineProps<{
|
||||
contentTitle: string
|
||||
@@ -122,50 +137,6 @@ const toggleLeftPanel = () => {
|
||||
const toggleRightPanel = () => {
|
||||
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>
|
||||
<style scoped>
|
||||
.base-widget-layout {
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<i :class="icon" class="text-xs text-neutral" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NavItemData } from '@/types/navTypes'
|
||||
|
||||
defineProps<{
|
||||
icon: NavItemData['icon']
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,96 +0,0 @@
|
||||
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>
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
|
||||
class="flex items-center gap-2 px-4 py-2 text-xs rounded-md transition-colors cursor-pointer"
|
||||
:class="
|
||||
active
|
||||
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
|
||||
@@ -9,8 +9,7 @@
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<NavIcon v-if="icon" :icon="icon" />
|
||||
<i-lucide:folder v-else class="text-xs text-neutral" />
|
||||
<i-lucide:folder v-if="hasFolderIcon" class="text-xs text-neutral" />
|
||||
<span class="flex items-center">
|
||||
<slot></slot>
|
||||
</span>
|
||||
@@ -18,12 +17,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NavItemData } from '@/types/navTypes'
|
||||
|
||||
import NavIcon from './NavIcon.vue'
|
||||
|
||||
const { icon, active, onClick } = defineProps<{
|
||||
icon: NavItemData['icon']
|
||||
const {
|
||||
hasFolderIcon = true,
|
||||
active,
|
||||
onClick
|
||||
} = defineProps<{
|
||||
hasFolderIcon?: boolean
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
}>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<h3
|
||||
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
||||
class="m-0 px-3 py-0 pt-5 text-xxs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
138
src/components/widget/nav/Navigation.stories.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
FolderOpen,
|
||||
GraduationCap,
|
||||
Home,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
User,
|
||||
Users
|
||||
} from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
||||
import NavItem from './NavItem.vue'
|
||||
import NavTitle from './NavTitle.vue'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Widget/Navigation',
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const NavigationItem: Story = {
|
||||
render: () => ({
|
||||
components: { NavItem },
|
||||
template: `
|
||||
<div class="space-y-2">
|
||||
<NavItem>Dashboard</NavItem>
|
||||
<NavItem>Projects</NavItem>
|
||||
<NavItem>Messages</NavItem>
|
||||
<NavItem>Settings</NavItem>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomNavigation: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
NavTitle,
|
||||
NavItem,
|
||||
Home,
|
||||
FolderOpen,
|
||||
BarChart3,
|
||||
Users,
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
User,
|
||||
Bell,
|
||||
LogOut
|
||||
},
|
||||
template: `
|
||||
<nav class="w-64 p-4 bg-white dark-theme:bg-zinc-800 rounded-lg">
|
||||
<NavTitle title="Main Menu" />
|
||||
<div class="mt-4 space-y-2">
|
||||
<NavItem :hasFolderIcon="false"><Home :size="16" class="inline mr-2" />Dashboard</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><FolderOpen :size="16" class="inline mr-2" />Projects</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><BarChart3 :size="16" class="inline mr-2" />Analytics</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><Users :size="16" class="inline mr-2" />Team</NavItem>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<NavTitle title="Resources" />
|
||||
<div class="mt-4 space-y-2">
|
||||
<NavItem :hasFolderIcon="false"><BookOpen :size="16" class="inline mr-2" />Documentation</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><GraduationCap :size="16" class="inline mr-2" />Tutorials</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><MessageSquare :size="16" class="inline mr-2" />Community</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<NavTitle title="Account" />
|
||||
<div class="mt-4 space-y-2">
|
||||
<NavItem :hasFolderIcon="false"><Settings :size="16" class="inline mr-2" />Settings</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><User :size="16" class="inline mr-2" />Profile</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><Bell :size="16" class="inline mr-2" />Notifications</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><LogOut :size="16" class="inline mr-2" />Logout</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LeftSidePanelDemo: Story = {
|
||||
render: () => ({
|
||||
components: { LeftSidePanel, FolderOpen },
|
||||
setup() {
|
||||
const navItems = [
|
||||
{
|
||||
title: 'Workspace',
|
||||
items: [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'projects', label: 'Projects' },
|
||||
{ id: 'workflows', label: 'Workflows' },
|
||||
{ id: 'models', label: 'Models' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
items: [
|
||||
{ id: 'node-editor', label: 'Node Editor' },
|
||||
{ id: 'image-browser', label: 'Image Browser' },
|
||||
{ id: 'queue-manager', label: 'Queue Manager' },
|
||||
{ id: 'extensions', label: 'Extensions' }
|
||||
]
|
||||
},
|
||||
{ id: 'settings', label: 'Settings' }
|
||||
]
|
||||
const active = ref<string | null>(null)
|
||||
return { navItems, active }
|
||||
},
|
||||
template: `
|
||||
<div class="w-full h-[560px] flex">
|
||||
<div class="w-64 rounded-lg">
|
||||
<LeftSidePanel v-model="active" :nav-items="navItems">
|
||||
<template #header-icon>
|
||||
<FolderOpen :size="14" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
Navigation
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-3 text-sm bg-gray-50 dark-theme:bg-zinc-900 border-t border-zinc-200 dark-theme:border-zinc-700">
|
||||
Active: {{ active ?? 'None' }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
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,7 +14,6 @@
|
||||
<NavItem
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
@@ -23,7 +22,6 @@
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<NavItem
|
||||
:icon="item.icon"
|
||||
:active="activeItem === item.id"
|
||||
@click="activeItem = item.id"
|
||||
>
|
||||
|
||||
@@ -123,14 +123,12 @@ export function useSelectedLiteGraphItems() {
|
||||
for (const i in selectedNodes) {
|
||||
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
|
||||
selectedNodeArray.forEach((selectedNode) => {
|
||||
// Apply standard toggle logic to the selected node itself
|
||||
const newModeForSelectedNode =
|
||||
selectedNode.mode === mode ? LGraphEventMode.ALWAYS : mode
|
||||
|
||||
selectedNode.mode = newModeForSelectedNode
|
||||
|
||||
|
||||
@@ -3,12 +3,8 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||
|
||||
/**
|
||||
* Manages the position of the selection toolbox independently.
|
||||
@@ -20,7 +16,6 @@ export function useSelectionToolboxPosition(
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
// World position of selection center
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
@@ -39,40 +34,17 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectableItems)
|
||||
|
||||
// Get bounds for all selected items
|
||||
const allBounds: ReadOnlyRect[] = []
|
||||
for (const item of selectableItems) {
|
||||
// Skip items without valid IDs
|
||||
if (item.id == null) continue
|
||||
|
||||
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
|
||||
// Use layout store for Vue nodes (only works with string IDs)
|
||||
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||
if (layout) {
|
||||
allBounds.push([
|
||||
layout.bounds.x,
|
||||
layout.bounds.y,
|
||||
layout.bounds.width,
|
||||
layout.bounds.height
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
||||
if (item instanceof LGraphNode) {
|
||||
const bounds = item.getBounding()
|
||||
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
|
||||
}
|
||||
}
|
||||
if (!bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
// Compute union bounds
|
||||
const unionBounds = computeUnionBounds(allBounds)
|
||||
if (!unionBounds) return
|
||||
const [xBase, y, width] = bounds
|
||||
|
||||
worldPosition.value = {
|
||||
x: unionBounds.x + unionBounds.width / 2,
|
||||
y: unionBounds.y - 10
|
||||
x: xBase + width / 2,
|
||||
y: y
|
||||
}
|
||||
|
||||
updateTransform()
|
||||
|
||||
@@ -24,12 +24,14 @@ export function useCanvasInteractions() {
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault() // Prevent browser zoom
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
event.preventDefault()
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
@@ -66,30 +68,9 @@ export function useCanvasInteractions() {
|
||||
) => {
|
||||
const canvasEl = app.canvas?.canvas
|
||||
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
|
||||
const EventConstructor = event.constructor as
|
||||
| typeof MouseEvent
|
||||
| typeof PointerEvent
|
||||
const EventConstructor = event.constructor as typeof WheelEvent
|
||||
const newEvent = new EventConstructor(event.type, event)
|
||||
canvasEl.dispatchEvent(newEvent)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
|
||||
@@ -56,7 +55,6 @@ export interface VueNodeData {
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: unknown[]
|
||||
outputs?: unknown[]
|
||||
hasErrors?: boolean
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
}
|
||||
@@ -202,21 +200,13 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
}
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
node.constructor?.title ||
|
||||
node.constructor?.name ||
|
||||
'Unknown'
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
title: node.title || 'Untitled',
|
||||
type: node.type || 'Unknown',
|
||||
mode: node.mode || 0,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
@@ -605,7 +595,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
|
||||
/**
|
||||
* Handles node addition to the graph - sets up Vue state and spatial indexing
|
||||
* Defers position extraction until after potential configure() calls
|
||||
*/
|
||||
const handleNodeAdded = (
|
||||
node: LGraphNode,
|
||||
@@ -619,7 +608,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
// Extract safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
// Set up reactive tracking state
|
||||
@@ -629,52 +618,27 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
attachMetadata(node)
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
nodePositions.set(id, nodePosition)
|
||||
nodeSizes.set(id, nodeSize)
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index for viewport culling with final positions
|
||||
const nodeBounds: Bounds = {
|
||||
x: nodePosition.x,
|
||||
y: nodePosition.y,
|
||||
width: nodeSize.width,
|
||||
height: nodeSize.height
|
||||
}
|
||||
spatialIndex.insert(id, nodeBounds, id)
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: nodePosition,
|
||||
size: nodeSize,
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
// Add to spatial index for viewport culling
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.insert(id, bounds, id)
|
||||
|
||||
// Check if we're in the middle of configuring the graph (workflow loading)
|
||||
if (window.app?.configuringGraph) {
|
||||
// During workflow loading - defer layout initialization until configure completes
|
||||
// Chain our callback with any existing onAfterGraphConfigured callback
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
() => {
|
||||
// Re-extract data now that configure() has populated title/slots/widgets/etc.
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
// Add node to layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: { x: node.pos[0], y: node.pos[1] },
|
||||
size: { width: node.size[0], height: node.size[1] },
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
interface NodeManager {
|
||||
@@ -20,17 +21,13 @@ interface NodeManager {
|
||||
|
||||
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
|
||||
/**
|
||||
* Handle node selection events
|
||||
* Supports single selection and multi-select with Ctrl/Cmd
|
||||
*/
|
||||
const handleNodeSelect = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasDragging: boolean
|
||||
) => {
|
||||
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
@@ -46,18 +43,16 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
} else {
|
||||
// If it wasn't a drag: single-select the node
|
||||
if (!wasDragging) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
// Regular click -> single select
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned to avoid unwanted movement
|
||||
if (!node.flags?.pinned) {
|
||||
bringNodeToFront(nodeData.id)
|
||||
layoutMutations.setSource(LayoutSource.Vue)
|
||||
layoutMutations.bringNodeToFront(nodeData.id)
|
||||
}
|
||||
|
||||
// Update canvas selection tracking
|
||||
@@ -114,7 +109,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
// TODO: add custom double-click behavior here
|
||||
// For now, ensure node is selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData, false)
|
||||
handleNodeSelect(event, nodeData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +128,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
|
||||
// Select the node if not already selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData, false)
|
||||
handleNodeSelect(event, nodeData)
|
||||
}
|
||||
|
||||
// Let LiteGraph handle the context menu
|
||||
@@ -158,7 +153,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true
|
||||
})
|
||||
handleNodeSelect(syntheticEvent, nodeData, false)
|
||||
handleNodeSelect(syntheticEvent, nodeData)
|
||||
}
|
||||
|
||||
// Set drag data for potential drop operations
|
||||
@@ -176,13 +171,14 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
if (!addToSelection) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.deselectAllNodes()
|
||||
}
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node && canvasStore.canvas) {
|
||||
canvasStore.canvas.select(node)
|
||||
canvasStore.canvas.selectNode(node)
|
||||
node.selected = true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* Vue Nodes Viewport Culling
|
||||
* Viewport Culling Composable
|
||||
*
|
||||
* Principles:
|
||||
* 1. Query DOM directly using data attributes (no cache to maintain)
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
* Handles viewport culling optimization for Vue nodes including:
|
||||
* - Transform state synchronization
|
||||
* - Visible node calculation with screen space transforms
|
||||
* - Adaptive margin computation based on zoom level
|
||||
* - Performance optimizations for large graphs
|
||||
*/
|
||||
import { type Ref, computed } from 'vue'
|
||||
import { type Ref, computed, readonly, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useTransformState } from '@/renderer/core/layout/useTransformState'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
@@ -23,84 +25,188 @@ export function useViewportCulling(
|
||||
nodeManager: Ref<NodeManager | null>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { syncWithCanvas } = useTransformState()
|
||||
|
||||
const allNodes = computed(() => {
|
||||
if (!isVueNodesEnabled.value) return []
|
||||
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
|
||||
return Array.from(vueNodeData.value.values())
|
||||
// Transform tracking for performance optimization
|
||||
const lastScale = ref(1)
|
||||
const lastOffsetX = ref(0)
|
||||
const lastOffsetY = ref(0)
|
||||
|
||||
// Current transform state
|
||||
const currentTransformState = computed(() => ({
|
||||
scale: lastScale.value,
|
||||
offsetX: lastOffsetX.value,
|
||||
offsetY: lastOffsetY.value
|
||||
}))
|
||||
|
||||
/**
|
||||
* Computed property that returns nodes visible in the current viewport
|
||||
* Implements sophisticated culling algorithm with adaptive margins
|
||||
*/
|
||||
const nodesToRender = computed(() => {
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Access trigger to force re-evaluation after nodeManager initialization
|
||||
void nodeDataTrigger.value
|
||||
|
||||
if (!comfyApp.graph) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = Array.from(vueNodeData.value.values())
|
||||
|
||||
// Apply viewport culling - check if node bounds intersect with viewport
|
||||
// TODO: use quadtree
|
||||
if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) {
|
||||
const canvas = canvasStore.canvas
|
||||
const manager = nodeManager.value
|
||||
|
||||
// Ensure transform is synced before checking visibility
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
|
||||
const ds = canvas.ds
|
||||
|
||||
// Work in screen space - viewport is simply the canvas element size
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
|
||||
// Add margin that represents a constant distance in canvas space
|
||||
// Convert canvas units to screen pixels by multiplying by scale
|
||||
const canvasMarginDistance = 200 // Fixed margin in canvas units
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
|
||||
const filtered = allNodes.filter((nodeData) => {
|
||||
const node = manager.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
// Transform node position to screen space (same as DOM widgets)
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
// Check if node bounds intersect with expanded viewport (in screen space)
|
||||
const isVisible = !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
|
||||
return isVisible
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
return allNodes
|
||||
})
|
||||
|
||||
/**
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
* Handle transform updates with performance optimization
|
||||
* Only syncs when transform actually changes to avoid unnecessary reflows
|
||||
*/
|
||||
const updateVisibility = () => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
|
||||
const handleTransformUpdate = (detectChangesInRAF: () => void) => {
|
||||
// Skip all work if Vue nodes are disabled
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
const manager = nodeManager.value
|
||||
const ds = canvas.ds
|
||||
// Sync transform state only when it changes (avoids reflows)
|
||||
if (comfyApp.canvas?.ds) {
|
||||
const currentScale = comfyApp.canvas.ds.scale
|
||||
const currentOffsetX = comfyApp.canvas.ds.offset[0]
|
||||
const currentOffsetY = comfyApp.canvas.ds.offset[1]
|
||||
|
||||
// Viewport bounds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
// Get all node elements at once
|
||||
const nodeElements = document.querySelectorAll('[data-node-id]')
|
||||
|
||||
// Update each element's visibility
|
||||
for (const element of nodeElements) {
|
||||
const nodeId = element.getAttribute('data-node-id')
|
||||
if (!nodeId) continue
|
||||
|
||||
const node = manager.getNode(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
// Calculate if node is outside viewport
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
const isNodeOutsideViewport =
|
||||
screen_x + screen_width < -margin ||
|
||||
screen_x > viewport_width + margin ||
|
||||
screen_y + screen_height < -margin ||
|
||||
screen_y > viewport_height + margin
|
||||
|
||||
// Setting display none directly avoid potential cascade resolution
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.display = isNodeOutsideViewport ? 'none' : ''
|
||||
if (
|
||||
currentScale !== lastScale.value ||
|
||||
currentOffsetX !== lastOffsetX.value ||
|
||||
currentOffsetY !== lastOffsetY.value
|
||||
) {
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
lastScale.value = currentScale
|
||||
lastOffsetX.value = currentOffsetX
|
||||
lastOffsetY.value = currentOffsetY
|
||||
}
|
||||
}
|
||||
|
||||
// Detect node changes during transform updates
|
||||
detectChangesInRAF()
|
||||
|
||||
// Trigger reactivity for nodesToRender
|
||||
void nodesToRender.value.length
|
||||
}
|
||||
|
||||
// RAF throttling for smooth updates during continuous panning
|
||||
let rafId: number | null = null
|
||||
|
||||
/**
|
||||
* Handle transform update - called by TransformPane event
|
||||
* Uses RAF to batch updates for smooth performance
|
||||
* Calculate if a specific node is visible in viewport
|
||||
* Useful for individual node visibility checks
|
||||
*/
|
||||
const handleTransformUpdate = () => {
|
||||
if (!isVueNodesEnabled.value) return
|
||||
|
||||
// Cancel previous RAF if still pending
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
const isNodeVisible = (nodeData: VueNodeData): boolean => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) {
|
||||
return true // Default to visible if culling not available
|
||||
}
|
||||
|
||||
// Schedule update in next animation frame
|
||||
rafId = requestAnimationFrame(() => {
|
||||
updateVisibility()
|
||||
rafId = null
|
||||
})
|
||||
const canvas = canvasStore.canvas
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
const ds = canvas.ds
|
||||
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const canvasMarginDistance = 200
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
return !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get viewport bounds information for debugging
|
||||
*/
|
||||
const getViewportInfo = () => {
|
||||
if (!canvasStore.canvas || !comfyApp.canvas) {
|
||||
return null
|
||||
}
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
return {
|
||||
viewport_width: canvas.canvas.width,
|
||||
viewport_height: canvas.canvas.height,
|
||||
scale: ds.scale,
|
||||
offset: [ds.offset[0], ds.offset[1]],
|
||||
margin_distance: 200,
|
||||
margin_x: 200 * ds.scale,
|
||||
margin_y: 200 * ds.scale
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allNodes,
|
||||
nodesToRender,
|
||||
handleTransformUpdate,
|
||||
updateVisibility
|
||||
isNodeVisible,
|
||||
getViewportInfo,
|
||||
|
||||
// Transform state
|
||||
currentTransformState: readonly(currentTransformState),
|
||||
lastScale: readonly(lastScale),
|
||||
lastOffsetX: readonly(lastOffsetX),
|
||||
lastOffsetY: readonly(lastOffsetY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1511,32 +1511,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
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: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
@@ -1639,11 +1613,6 @@ export const useNodePricing = () => {
|
||||
// ByteDance
|
||||
ByteDanceImageNode: ['model'],
|
||||
ByteDanceImageEditNode: ['model'],
|
||||
ByteDanceSeedreamNode: [
|
||||
'model',
|
||||
'sequential_image_generation',
|
||||
'max_images'
|
||||
],
|
||||
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
|
||||
|
||||
@@ -44,24 +44,9 @@ export const useUpdateAvailableNodes = () => {
|
||||
return filterOutdatedPacks(installedPacks.value)
|
||||
})
|
||||
|
||||
// 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
|
||||
// Check if there are any outdated packs
|
||||
const hasUpdateAvailable = computed(() => {
|
||||
return enabledUpdateAvailableNodePacks.value.length > 0
|
||||
})
|
||||
|
||||
// Check if there are disabled packs with updates
|
||||
const hasDisabledUpdatePacks = computed(() => {
|
||||
return (
|
||||
updateAvailableNodePacks.value.length >
|
||||
enabledUpdateAvailableNodePacks.value.length
|
||||
)
|
||||
return updateAvailableNodePacks.value.length > 0
|
||||
})
|
||||
|
||||
// Automatically fetch installed pack data when composable is used
|
||||
@@ -73,9 +58,7 @@ export const useUpdateAvailableNodes = () => {
|
||||
|
||||
return {
|
||||
updateAvailableNodePacks,
|
||||
enabledUpdateAvailableNodePacks,
|
||||
hasUpdateAvailable,
|
||||
hasDisabledUpdatePacks,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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
|
||||
}))
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import arc from '@/assets/palettes/arc.json' with { type: 'json' }
|
||||
import dark from '@/assets/palettes/dark.json' with { type: 'json' }
|
||||
import github from '@/assets/palettes/github.json' with { type: 'json' }
|
||||
import light from '@/assets/palettes/light.json' with { type: 'json' }
|
||||
import nord from '@/assets/palettes/nord.json' with { type: 'json' }
|
||||
import solarized from '@/assets/palettes/solarized.json' with { type: 'json' }
|
||||
import arc from '@/assets/palettes/arc.json'
|
||||
import dark from '@/assets/palettes/dark.json'
|
||||
import github from '@/assets/palettes/github.json'
|
||||
import light from '@/assets/palettes/light.json'
|
||||
import nord from '@/assets/palettes/nord.json'
|
||||
import solarized from '@/assets/palettes/solarized.json'
|
||||
import type {
|
||||
ColorPalettes,
|
||||
CompletedPalette
|
||||
|
||||
@@ -972,13 +972,5 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: false,
|
||||
experimental: true,
|
||||
versionAdded: '1.27.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Assets.UseAssetAPI',
|
||||
name: 'Use Asset API for model library',
|
||||
type: 'boolean',
|
||||
tooltip: 'Use new Asset API for model browsing',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
}
|
||||
]
|
||||
|
||||
72
src/i18n.ts
@@ -1,41 +1,41 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import arCommands from './locales/ar/commands.json' with { type: 'json' }
|
||||
import ar from './locales/ar/main.json' with { type: 'json' }
|
||||
import arNodes from './locales/ar/nodeDefs.json' with { type: 'json' }
|
||||
import arSettings from './locales/ar/settings.json' with { type: 'json' }
|
||||
import enCommands from './locales/en/commands.json' with { type: 'json' }
|
||||
import en from './locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
|
||||
import enSettings from './locales/en/settings.json' with { type: 'json' }
|
||||
import esCommands from './locales/es/commands.json' with { type: 'json' }
|
||||
import es from './locales/es/main.json' with { type: 'json' }
|
||||
import esNodes from './locales/es/nodeDefs.json' with { type: 'json' }
|
||||
import esSettings from './locales/es/settings.json' with { type: 'json' }
|
||||
import frCommands from './locales/fr/commands.json' with { type: 'json' }
|
||||
import fr from './locales/fr/main.json' with { type: 'json' }
|
||||
import frNodes from './locales/fr/nodeDefs.json' with { type: 'json' }
|
||||
import frSettings from './locales/fr/settings.json' with { type: 'json' }
|
||||
import jaCommands from './locales/ja/commands.json' with { type: 'json' }
|
||||
import ja from './locales/ja/main.json' with { type: 'json' }
|
||||
import jaNodes from './locales/ja/nodeDefs.json' with { type: 'json' }
|
||||
import jaSettings from './locales/ja/settings.json' with { type: 'json' }
|
||||
import koCommands from './locales/ko/commands.json' with { type: 'json' }
|
||||
import ko from './locales/ko/main.json' with { type: 'json' }
|
||||
import koNodes from './locales/ko/nodeDefs.json' with { type: 'json' }
|
||||
import koSettings from './locales/ko/settings.json' with { type: 'json' }
|
||||
import ruCommands from './locales/ru/commands.json' with { type: 'json' }
|
||||
import ru from './locales/ru/main.json' with { type: 'json' }
|
||||
import ruNodes from './locales/ru/nodeDefs.json' with { type: 'json' }
|
||||
import ruSettings from './locales/ru/settings.json' with { type: 'json' }
|
||||
import zhTWCommands from './locales/zh-TW/commands.json' with { type: 'json' }
|
||||
import zhTW from './locales/zh-TW/main.json' with { type: 'json' }
|
||||
import zhTWNodes from './locales/zh-TW/nodeDefs.json' with { type: 'json' }
|
||||
import zhTWSettings from './locales/zh-TW/settings.json' with { type: 'json' }
|
||||
import zhCommands from './locales/zh/commands.json' with { type: 'json' }
|
||||
import zh from './locales/zh/main.json' with { type: 'json' }
|
||||
import zhNodes from './locales/zh/nodeDefs.json' with { type: 'json' }
|
||||
import zhSettings from './locales/zh/settings.json' with { type: 'json' }
|
||||
import arCommands from './locales/ar/commands.json'
|
||||
import ar from './locales/ar/main.json'
|
||||
import arNodes from './locales/ar/nodeDefs.json'
|
||||
import arSettings from './locales/ar/settings.json'
|
||||
import enCommands from './locales/en/commands.json'
|
||||
import en from './locales/en/main.json'
|
||||
import enNodes from './locales/en/nodeDefs.json'
|
||||
import enSettings from './locales/en/settings.json'
|
||||
import esCommands from './locales/es/commands.json'
|
||||
import es from './locales/es/main.json'
|
||||
import esNodes from './locales/es/nodeDefs.json'
|
||||
import esSettings from './locales/es/settings.json'
|
||||
import frCommands from './locales/fr/commands.json'
|
||||
import fr from './locales/fr/main.json'
|
||||
import frNodes from './locales/fr/nodeDefs.json'
|
||||
import frSettings from './locales/fr/settings.json'
|
||||
import jaCommands from './locales/ja/commands.json'
|
||||
import ja from './locales/ja/main.json'
|
||||
import jaNodes from './locales/ja/nodeDefs.json'
|
||||
import jaSettings from './locales/ja/settings.json'
|
||||
import koCommands from './locales/ko/commands.json'
|
||||
import ko from './locales/ko/main.json'
|
||||
import koNodes from './locales/ko/nodeDefs.json'
|
||||
import koSettings from './locales/ko/settings.json'
|
||||
import ruCommands from './locales/ru/commands.json'
|
||||
import ru from './locales/ru/main.json'
|
||||
import ruNodes from './locales/ru/nodeDefs.json'
|
||||
import ruSettings from './locales/ru/settings.json'
|
||||
import zhTWCommands from './locales/zh-TW/commands.json'
|
||||
import zhTW from './locales/zh-TW/main.json'
|
||||
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
|
||||
import zhTWSettings from './locales/zh-TW/settings.json'
|
||||
import zhCommands from './locales/zh/commands.json'
|
||||
import zh from './locales/zh/main.json'
|
||||
import zhNodes from './locales/zh/nodeDefs.json'
|
||||
import zhSettings from './locales/zh/settings.json'
|
||||
|
||||
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type LinkRenderContext,
|
||||
LitegraphLinkAdapter
|
||||
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
@@ -5560,9 +5559,7 @@ export class LGraphCanvas
|
||||
const link = graph._links.get(link_id)
|
||||
if (!link) continue
|
||||
|
||||
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)
|
||||
const endPos = node.getInputPos(i)
|
||||
|
||||
// find link info
|
||||
const start_node = graph.getNodeById(link.origin_id)
|
||||
@@ -5572,9 +5569,7 @@ export class LGraphCanvas
|
||||
const startPos: Point =
|
||||
outputId === -1
|
||||
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
|
||||
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
||||
? getSlotPosition(start_node, outputId, false)
|
||||
: start_node.getOutputPos(outputId)
|
||||
: start_node.getOutputPos(outputId)
|
||||
|
||||
const output = start_node.outputs[outputId]
|
||||
if (!output) continue
|
||||
|
||||
@@ -3833,12 +3833,33 @@ export class LGraphNode
|
||||
? this.getInputPos(slotIndex)
|
||||
: this.getOutputPos(slotIndex)
|
||||
|
||||
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[2] = slot.isWidgetInputSlot
|
||||
? BaseWidget.margin
|
||||
: LiteGraph.NODE_SLOT_HEIGHT
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// Vue-based slot dimensions
|
||||
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
|
||||
|
||||
if (slot.isWidgetInputSlot) {
|
||||
// Widget slots have a 20x20 clickable area centered at the position
|
||||
slot.boundingRect[0] = pos[0] - 10
|
||||
slot.boundingRect[1] = pos[1] - 10
|
||||
slot.boundingRect[2] = 20
|
||||
slot.boundingRect[3] = 20
|
||||
} else {
|
||||
// Regular slots have a 20x20 clickable area for the connector
|
||||
// but the full slot height for vertical spacing
|
||||
slot.boundingRect[0] = pos[0] - 10
|
||||
slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
|
||||
slot.boundingRect[2] = 20
|
||||
slot.boundingRect[3] = dimensions.SLOT_HEIGHT
|
||||
}
|
||||
} else {
|
||||
// Traditional LiteGraph dimensions
|
||||
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[2] = slot.isWidgetInputSlot
|
||||
? BaseWidget.margin
|
||||
: LiteGraph.NODE_SLOT_HEIGHT
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
#measureSlots(): ReadOnlyRect | null {
|
||||
|
||||
@@ -24,6 +24,26 @@ import {
|
||||
} from './types/globalEnums'
|
||||
import { createUuidv4 } from './utils/uuid'
|
||||
|
||||
/**
|
||||
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
|
||||
* These values ensure both systems can independently calculate node, slot, and widget positions
|
||||
* to place them in identical locations.
|
||||
*
|
||||
* IMPORTANT: These values must match the actual rendered dimensions of Vue components
|
||||
* for the positioning contract to work correctly.
|
||||
*/
|
||||
export const COMFY_VUE_NODE_DIMENSIONS = {
|
||||
spacing: {
|
||||
BETWEEN_SLOTS_AND_BODY: 8,
|
||||
BETWEEN_WIDGETS: 8
|
||||
},
|
||||
components: {
|
||||
HEADER_HEIGHT: 34, // 18 header + 16 padding
|
||||
SLOT_HEIGHT: 24,
|
||||
STANDARD_WIDGET_HEIGHT: 30
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The Global Scope. It contains all the registered node classes.
|
||||
*/
|
||||
@@ -75,6 +95,14 @@ export class LiteGraphGlobal {
|
||||
WIDGET_SECONDARY_TEXT_COLOR = '#999'
|
||||
WIDGET_DISABLED_TEXT_COLOR = '#666'
|
||||
|
||||
/**
|
||||
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
|
||||
* These values ensure both systems can independently calculate node, slot, and widget positions
|
||||
* to place them in identical locations.
|
||||
*/
|
||||
// WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
|
||||
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
|
||||
|
||||
LINK_COLOR = '#9A9'
|
||||
EVENT_LINK_COLOR = '#A86'
|
||||
CONNECTING_LINK_COLOR = '#AFA'
|
||||
|
||||
@@ -104,6 +104,7 @@ export { BadgePosition, LGraphBadge } from './LGraphBadge'
|
||||
export { LGraphCanvas } from './LGraphCanvas'
|
||||
export { LGraphGroup } from './LGraphGroup'
|
||||
export { LGraphNode, type NodeId } from './LGraphNode'
|
||||
export { COMFY_VUE_NODE_DIMENSIONS } from './LiteGraphGlobal'
|
||||
export { LLink } from './LLink'
|
||||
export { createBounds } from './measure'
|
||||
export { Reroute, type RerouteId } from './Reroute'
|
||||
|
||||
@@ -272,7 +272,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
const matchingIndex = this.#getBypassSlotIndex(slot, type)
|
||||
|
||||
// No input types match
|
||||
if (matchingIndex === -1) {
|
||||
if (matchingIndex === undefined) {
|
||||
console.debug(
|
||||
`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`,
|
||||
this
|
||||
@@ -331,7 +331,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
* Used when bypassing nodes.
|
||||
* @param slot The output slot index on this node
|
||||
* @param type The type of the final target input (so type list matches are accurate)
|
||||
* @returns The index of the input slot on this node, otherwise `-1`.
|
||||
* @returns The index of the input slot on this node, otherwise `undefined`.
|
||||
*/
|
||||
#getBypassSlotIndex(slot: number, type: ISlotType) {
|
||||
const { inputs } = this
|
||||
@@ -352,15 +352,15 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
return slot
|
||||
}
|
||||
|
||||
// Preserve legacy behaviour; use exact match first.
|
||||
const exactMatch = inputs.findIndex((input) => input.type === type)
|
||||
if (exactMatch !== -1) return exactMatch
|
||||
|
||||
// Find first matching slot - prefer exact type
|
||||
return inputs.findIndex(
|
||||
(input) =>
|
||||
LiteGraph.isValidConnection(input.type, outputType) &&
|
||||
LiteGraph.isValidConnection(input.type, type)
|
||||
return (
|
||||
// Preserve legacy behaviour; use exact match first.
|
||||
inputs.findIndex((input) => input.type === type) ??
|
||||
inputs.findIndex(
|
||||
(input) =>
|
||||
LiteGraph.isValidConnection(input.type, outputType) &&
|
||||
LiteGraph.isValidConnection(input.type, type)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||