Compare commits
1 Commits
sno-import
...
vue-nodes/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5035245395 |
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
|
||||
62
.github/workflows/test-ui.yaml
vendored
@@ -284,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)
|
||||
1
.gitignore
vendored
@@ -51,7 +51,6 @@ tests-ui/workflows/examples
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser-tests/local/
|
||||
|
||||
.env
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
pnpm exec lint-staged
|
||||
pnpm exec tsx scripts/check-unused-i18n-keys.ts
|
||||
npx lint-staged
|
||||
npx tsx scripts/check-unused-i18n-keys.ts
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run Knip with cache via package script
|
||||
pnpm knip
|
||||
|
||||
@@ -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>
|
||||
@@ -10,7 +10,7 @@ import type { Position } from './types'
|
||||
* - {@link Mouse.move}
|
||||
* - {@link Mouse.up}
|
||||
*/
|
||||
interface DragOptions {
|
||||
export interface DragOptions {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
clickCount?: number
|
||||
steps?: number
|
||||
|
||||
@@ -134,7 +134,7 @@ export class SubgraphSlotReference {
|
||||
}
|
||||
}
|
||||
|
||||
class NodeSlotReference {
|
||||
export class NodeSlotReference {
|
||||
constructor(
|
||||
readonly type: 'input' | 'output',
|
||||
readonly index: number,
|
||||
@@ -201,7 +201,7 @@ class NodeSlotReference {
|
||||
}
|
||||
}
|
||||
|
||||
class NodeWidgetReference {
|
||||
export class NodeWidgetReference {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
|
||||
@@ -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 |
@@ -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: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 79 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 |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 169 KiB |
@@ -1,132 +0,0 @@
|
||||
# Import Map Visualization
|
||||
|
||||
This document describes the import map visualization tool for the ComfyUI Frontend project.
|
||||
|
||||
## Overview
|
||||
|
||||
The import map visualization provides an interactive graph showing all the import dependencies in the ComfyUI Frontend codebase. This helps developers understand:
|
||||
|
||||
- Module dependencies and relationships
|
||||
- Code organization and architecture
|
||||
- Circular dependencies (if any)
|
||||
- External package usage
|
||||
- Module coupling and cohesion
|
||||
|
||||
## Viewing the Import Map
|
||||
|
||||
Open `docs/import-map.html` in a web browser to view the interactive visualization.
|
||||
|
||||
### Features
|
||||
|
||||
- **Interactive Graph**: Drag nodes to explore the dependency graph
|
||||
- **Color-Coded Categories**: Different module types are shown in different colors:
|
||||
- 🔴 Components
|
||||
- 🔵 Stores
|
||||
- 🟢 Services
|
||||
- 🟡 Views
|
||||
- 🟠 Composables
|
||||
- ⚪ Utils
|
||||
- 🟣 External packages
|
||||
- ⚫ Other modules
|
||||
|
||||
- **Search**: Use the search box to find specific files or modules
|
||||
- **Zoom & Pan**: Navigate through the graph using mouse controls
|
||||
- **Export**: Export the raw dependency data as JSON
|
||||
|
||||
## Generating the Import Map
|
||||
|
||||
To regenerate the import map after code changes:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/generate-import-map.ts
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Scan all TypeScript and Vue files in the `src/` directory
|
||||
2. Extract import statements
|
||||
3. Build a dependency graph
|
||||
4. Generate both JSON data and HTML visualization
|
||||
|
||||
### Output Files
|
||||
|
||||
- `docs/import-map.json` - Raw dependency data in JSON format
|
||||
- `docs/import-map.html` - Interactive HTML visualization
|
||||
|
||||
## Understanding the Visualization
|
||||
|
||||
### Node Size
|
||||
- Larger nodes indicate modules that are imported by many other modules
|
||||
- Small nodes are leaf modules with fewer dependents
|
||||
|
||||
### Links
|
||||
- Lines between nodes show import relationships
|
||||
- Thicker lines indicate multiple imports between the same modules
|
||||
|
||||
### Layout
|
||||
- The graph uses force-directed layout to automatically position nodes
|
||||
- Highly connected modules tend to cluster together
|
||||
- External dependencies are typically on the periphery
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Architecture Review
|
||||
- Identify architectural patterns and layers
|
||||
- Spot potential violations of architectural boundaries
|
||||
- Find opportunities for refactoring
|
||||
|
||||
### Dependency Analysis
|
||||
- Identify heavily used modules that might benefit from optimization
|
||||
- Find unused or rarely used modules
|
||||
- Detect circular dependencies
|
||||
|
||||
### Onboarding
|
||||
- Help new developers understand the codebase structure
|
||||
- Visualize the relationships between different parts of the application
|
||||
- Identify entry points and core modules
|
||||
|
||||
### Performance Optimization
|
||||
- Find modules that might benefit from code splitting
|
||||
- Identify heavy external dependencies
|
||||
- Optimize bundle size by understanding import chains
|
||||
|
||||
## Technical Details
|
||||
|
||||
The import map generator uses:
|
||||
- TypeScript AST parsing to extract imports
|
||||
- D3.js for interactive visualization
|
||||
- Force-directed graph layout algorithm
|
||||
- Fast-glob for file system traversal
|
||||
|
||||
## Limitations
|
||||
|
||||
- Dynamic imports (`import()`) are detected but may not show the full dependency picture
|
||||
- Conditional imports are shown as always-present dependencies
|
||||
- Type-only imports are included in the visualization
|
||||
- The visualization works best with up to ~1000 nodes
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential enhancements for the import map tool:
|
||||
|
||||
- [ ] Filter by module type or specific directories
|
||||
- [ ] Show import cycle detection
|
||||
- [ ] Display bundle size information
|
||||
- [ ] Integration with webpack bundle analyzer
|
||||
- [ ] Real-time updates during development
|
||||
- [ ] Export to other visualization formats (GraphViz, etc.)
|
||||
- [ ] Show test file dependencies separately
|
||||
- [ ] Add metrics dashboard (coupling, cohesion, etc.)
|
||||
|
||||
## Contributing
|
||||
|
||||
To improve the import map visualization:
|
||||
|
||||
1. The generation script is located at `scripts/generate-import-map.ts`
|
||||
2. The HTML template is embedded in the script
|
||||
3. Submit PRs with improvements or bug fixes
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Architecture Decision Records](./adr/README.md)
|
||||
- [Settings System](./SETTINGS.md)
|
||||
- [Extension Development](./extensions/development.md)
|
||||
@@ -64,39 +64,6 @@ export default [
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
// Restrict deprecated PrimeVue components
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'primevue/calendar',
|
||||
message:
|
||||
'Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from "primevue/datepicker"'
|
||||
},
|
||||
{
|
||||
name: 'primevue/dropdown',
|
||||
message:
|
||||
'Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from "primevue/select"'
|
||||
},
|
||||
{
|
||||
name: 'primevue/inputswitch',
|
||||
message:
|
||||
'InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from "primevue/toggleswitch"'
|
||||
},
|
||||
{
|
||||
name: 'primevue/overlaypanel',
|
||||
message:
|
||||
'OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from "primevue/popover"'
|
||||
},
|
||||
{
|
||||
name: 'primevue/sidebar',
|
||||
message:
|
||||
'Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from "primevue/drawer"'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
// i18n rules
|
||||
'@intlify/vue-i18n/no-raw-text': [
|
||||
'error',
|
||||
|
||||
@@ -2,56 +2,84 @@ import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
entry: [
|
||||
'{build,scripts}/**/*.{js,ts}',
|
||||
'src/assets/css/style.css',
|
||||
'build/**/*.ts',
|
||||
'scripts/**/*.{js,ts}',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts'
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts'
|
||||
],
|
||||
project: [
|
||||
'browser_tests/**/*.{js,ts}',
|
||||
'build/**/*.{js,ts,vue}',
|
||||
'scripts/**/*.{js,ts}',
|
||||
'src/**/*.{js,ts,vue}',
|
||||
'tests-ui/**/*.{js,ts,vue}',
|
||||
'*.{js,ts,mts}'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'],
|
||||
ignoreBinaries: ['only-allow', 'openapi-typescript'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons',
|
||||
'@iconify/json',
|
||||
'tailwindcss',
|
||||
'tailwindcss-primeui', // Need to figure out why tailwind plugin isn't applying
|
||||
// Dev
|
||||
'@trivago/prettier-plugin-sort-imports'
|
||||
],
|
||||
ignore: [
|
||||
// Generated files
|
||||
'dist/**',
|
||||
'types/**',
|
||||
'node_modules/**',
|
||||
// Config files that might not show direct usage
|
||||
'.husky/**',
|
||||
// Temporary or cache files
|
||||
'.vite/**',
|
||||
'coverage/**',
|
||||
// i18n config
|
||||
'.i18nrc.cjs',
|
||||
// Vitest litegraph config
|
||||
'vitest.litegraph.config.ts',
|
||||
// Test setup files
|
||||
'browser_tests/globalSetup.ts',
|
||||
'browser_tests/globalTeardown.ts',
|
||||
'browser_tests/utils/**',
|
||||
// Scripts
|
||||
'scripts/**',
|
||||
// Vite config files
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts',
|
||||
// Auto generated manager types
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
'src/types/comfyRegistryTypes.ts',
|
||||
// Design system components (may not be used immediately)
|
||||
'src/components/button/IconGroup.vue',
|
||||
'src/components/button/MoreButton.vue',
|
||||
'src/components/button/TextButton.vue',
|
||||
'src/components/card/CardTitle.vue',
|
||||
'src/components/card/CardDescription.vue',
|
||||
'src/components/input/SingleSelect.vue',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Generated file: openapi
|
||||
'src/types/comfyRegistryTypes.ts'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
css: (text: string) =>
|
||||
[
|
||||
...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)
|
||||
].join('\n')
|
||||
},
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
},
|
||||
vitest: {
|
||||
config: ['vitest?(.*).config.ts'],
|
||||
entry: [
|
||||
'**/*.{bench,test,test-d,spec}.?(c|m)[jt]s?(x)',
|
||||
'**/__mocks__/**/*.[jt]s?(x)'
|
||||
]
|
||||
},
|
||||
playwright: {
|
||||
config: ['playwright?(.*).config.ts'],
|
||||
entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
|
||||
ignoreExportsUsedInFile: true,
|
||||
// Vue-specific configuration
|
||||
vue: true,
|
||||
tailwind: true,
|
||||
// Only check for unused files, disable all other rules
|
||||
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
|
||||
rules: {
|
||||
classMembers: 'off'
|
||||
},
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch'
|
||||
]
|
||||
],
|
||||
// Include dependencies analysis
|
||||
includeEntryExports: true
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.27.3",
|
||||
"version": "1.27.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -34,13 +34,11 @@
|
||||
"locale": "lobe-i18n locale",
|
||||
"collect-i18n": "npx playwright test --config=playwright.i18n.config.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"import-map": "tsx scripts/generate-import-map.ts",
|
||||
"storybook": "nx storybook -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"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",
|
||||
@@ -64,7 +62,7 @@
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-storybook": "^9.1.1",
|
||||
@@ -78,6 +76,7 @@
|
||||
"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",
|
||||
@@ -85,7 +84,7 @@
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"tsx": "^4.15.6",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"unplugin-icons": "^0.22.0",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"uuid": "^11.1.0",
|
||||
|
||||
714
pnpm-lock.yaml
generated
@@ -1,241 +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
|
||||
|
||||
# Deploy a single browser report, WARN: ensure inputs are sanitized before calling this function
|
||||
deploy_report() {
|
||||
dir="$1"
|
||||
browser="$2"
|
||||
branch="$3"
|
||||
|
||||
[ ! -d "$dir" ] && echo "failed" && return
|
||||
|
||||
|
||||
# Project name with dots converted to dashes for Cloudflare
|
||||
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
|
||||
project="comfyui-playwright-${sanitized_browser}"
|
||||
|
||||
echo "Deploying $browser to project $project on branch $branch..." >&2
|
||||
|
||||
# Try deployment up to 3 times
|
||||
i=1
|
||||
while [ $i -le 3 ]; do
|
||||
echo "Deployment attempt $i of 3..." >&2
|
||||
# Branch and project are already sanitized, use them directly
|
||||
# Branch was sanitized at script start, project uses sanitized_browser
|
||||
if output=$(wrangler pages deploy "$dir" \
|
||||
--project-name="$project" \
|
||||
--branch="$branch" 2>&1); then
|
||||
|
||||
# Extract URL from output (improved regex for valid URL characters)
|
||||
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
|
||||
result="${url:-https://${branch}.${project}.pages.dev}"
|
||||
echo "Success! URL: $result" >&2
|
||||
echo "$result" # Only this goes to stdout for capture
|
||||
return
|
||||
else
|
||||
echo "Deployment failed on attempt $i: $output" >&2
|
||||
fi
|
||||
[ $i -lt 3 ] && sleep 10
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo "failed"
|
||||
}
|
||||
|
||||
# Post or update GitHub comment
|
||||
post_comment() {
|
||||
body="$1"
|
||||
temp_file=$(mktemp)
|
||||
echo "$body" > "$temp_file"
|
||||
|
||||
if command -v gh > /dev/null 2>&1; then
|
||||
# Find existing comment ID
|
||||
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
|
||||
|
||||
if [ -n "$existing" ]; then
|
||||
# Update specific comment by ID
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
|
||||
--field body="$(cat "$temp_file")"
|
||||
else
|
||||
# Create new comment
|
||||
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
|
||||
fi
|
||||
else
|
||||
echo "GitHub CLI not available, outputting comment:"
|
||||
cat "$temp_file"
|
||||
fi
|
||||
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post starting comment
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
### 🚀 Running Tests
|
||||
- 🧪 **chromium**: Running tests...
|
||||
- 🧪 **chromium-0.5x**: Running tests...
|
||||
- 🧪 **chromium-2x**: Running tests...
|
||||
- 🧪 **mobile-chrome**: Running tests...
|
||||
|
||||
---
|
||||
⏱️ Please wait while tests are running...
|
||||
EOF
|
||||
)
|
||||
post_comment "$comment"
|
||||
|
||||
else
|
||||
# Deploy and post completion comment
|
||||
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
|
||||
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
|
||||
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
|
||||
echo "Looking for reports in: $(pwd)/reports"
|
||||
echo "Available reports:"
|
||||
ls -la reports/ 2>/dev/null || echo "Reports directory not found"
|
||||
|
||||
# Deploy all reports in parallel and collect URLs
|
||||
temp_dir=$(mktemp -d)
|
||||
pids=""
|
||||
i=0
|
||||
|
||||
# Start parallel deployments
|
||||
for browser in $BROWSERS; do
|
||||
if [ -d "reports/playwright-report-$browser" ]; then
|
||||
echo "Found report for $browser, deploying in parallel..."
|
||||
(
|
||||
url=$(deploy_report "reports/playwright-report-$browser" "$browser" "$cloudflare_branch")
|
||||
echo "$url" > "$temp_dir/$i.url"
|
||||
echo "Deployment result for $browser: $url"
|
||||
) &
|
||||
pids="$pids $!"
|
||||
else
|
||||
echo "Report not found for $browser at reports/playwright-report-$browser"
|
||||
echo "failed" > "$temp_dir/$i.url"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Wait for all deployments to complete
|
||||
for pid in $pids; do
|
||||
wait $pid
|
||||
done
|
||||
|
||||
# Collect URLs in order
|
||||
urls=""
|
||||
i=0
|
||||
for browser in $BROWSERS; do
|
||||
if [ -f "$temp_dir/$i.url" ]; then
|
||||
url=$(cat "$temp_dir/$i.url")
|
||||
else
|
||||
url="failed"
|
||||
fi
|
||||
if [ -z "$urls" ]; then
|
||||
urls="$url"
|
||||
else
|
||||
urls="$urls $url"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$temp_dir"
|
||||
|
||||
# Generate completion comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
✅ **Tests completed successfully!**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
|
||||
|
||||
### 📊 Test Reports by Browser"
|
||||
|
||||
# Add browser results
|
||||
i=0
|
||||
for browser in $BROWSERS; do
|
||||
# Get URL at position i
|
||||
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
|
||||
|
||||
if [ "$url" != "failed" ] && [ -n "$url" ]; then
|
||||
comment="$comment
|
||||
- ✅ **${browser}**: [View Report](${url})"
|
||||
else
|
||||
comment="$comment
|
||||
- ❌ **${browser}**: Deployment failed"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
comment="$comment
|
||||
|
||||
---
|
||||
🎉 Click on the links above to view detailed test results for each browser configuration."
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,807 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
interface ImportInfo {
|
||||
source: string
|
||||
imports: string[]
|
||||
}
|
||||
|
||||
interface DependencyGraph {
|
||||
nodes: Array<{
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
size: number
|
||||
inCircularDep?: boolean
|
||||
circularChains?: string[][]
|
||||
}>
|
||||
links: Array<{
|
||||
source: string
|
||||
target: string
|
||||
value: number
|
||||
isCircular?: boolean
|
||||
}>
|
||||
circularDependencies?: Array<{
|
||||
chain: string[]
|
||||
edges: Array<{ source: string; target: string }>
|
||||
}>
|
||||
}
|
||||
|
||||
// Extract imports from a TypeScript/Vue file
|
||||
function extractImports(filePath: string): ImportInfo {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const imports: string[] = []
|
||||
|
||||
// Match ES6 import statements
|
||||
const importRegex =
|
||||
/import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"]/g
|
||||
let match
|
||||
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
imports.push(match[1])
|
||||
}
|
||||
|
||||
// Also match dynamic imports
|
||||
const dynamicImportRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
||||
while ((match = dynamicImportRegex.exec(content)) !== null) {
|
||||
imports.push(match[1])
|
||||
}
|
||||
|
||||
return {
|
||||
source: filePath,
|
||||
imports: [...new Set(imports)] // Remove duplicates
|
||||
}
|
||||
}
|
||||
|
||||
// Categorize file by its path
|
||||
function getFileGroup(filePath: string): string {
|
||||
const relativePath = path.relative(process.cwd(), filePath)
|
||||
|
||||
if (relativePath.includes('node_modules')) return 'external'
|
||||
if (relativePath.startsWith('src/components')) return 'components'
|
||||
if (relativePath.startsWith('src/stores')) return 'stores'
|
||||
if (relativePath.startsWith('src/services')) return 'services'
|
||||
if (relativePath.startsWith('src/views')) return 'views'
|
||||
if (relativePath.startsWith('src/composables')) return 'composables'
|
||||
if (relativePath.startsWith('src/utils')) return 'utils'
|
||||
if (relativePath.startsWith('src/types')) return 'types'
|
||||
if (relativePath.startsWith('src/extensions')) return 'extensions'
|
||||
if (relativePath.startsWith('src/lib')) return 'lib'
|
||||
if (relativePath.startsWith('src/scripts')) return 'scripts'
|
||||
if (relativePath.startsWith('tests')) return 'tests'
|
||||
if (relativePath.startsWith('browser_tests')) return 'browser_tests'
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// Resolve import path to actual file
|
||||
function resolveImportPath(importPath: string, sourceFile: string): string {
|
||||
// Handle aliases
|
||||
if (importPath.startsWith('@/')) {
|
||||
return path.join(process.cwd(), 'src', importPath.slice(2))
|
||||
}
|
||||
|
||||
// Handle relative paths
|
||||
if (importPath.startsWith('.')) {
|
||||
const sourceDir = path.dirname(sourceFile)
|
||||
return path.resolve(sourceDir, importPath)
|
||||
}
|
||||
|
||||
// External module
|
||||
return importPath
|
||||
}
|
||||
|
||||
// Detect circular dependencies using DFS
|
||||
function detectCircularDependencies(
|
||||
nodes: Map<string, any>,
|
||||
links: Map<string, any>
|
||||
): Array<{
|
||||
chain: string[]
|
||||
edges: Array<{ source: string; target: string }>
|
||||
}> {
|
||||
const adjacencyList = new Map<string, Set<string>>()
|
||||
const circularDeps: Array<{
|
||||
chain: string[]
|
||||
edges: Array<{ source: string; target: string }>
|
||||
}> = []
|
||||
|
||||
// Build adjacency list
|
||||
for (const link of links.values()) {
|
||||
if (!adjacencyList.has(link.source)) {
|
||||
adjacencyList.set(link.source, new Set())
|
||||
}
|
||||
adjacencyList.get(link.source)!.add(link.target)
|
||||
}
|
||||
|
||||
// DFS to find cycles
|
||||
const visited = new Set<string>()
|
||||
const recStack = new Set<string>()
|
||||
const parent = new Map<string, string>()
|
||||
|
||||
function findCycle(node: string, path: string[] = []): void {
|
||||
visited.add(node)
|
||||
recStack.add(node)
|
||||
path.push(node)
|
||||
|
||||
const neighbors = adjacencyList.get(node) || new Set()
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
parent.set(neighbor, node)
|
||||
findCycle(neighbor, [...path])
|
||||
} else if (recStack.has(neighbor)) {
|
||||
// Found a cycle
|
||||
const cycleStartIndex = path.indexOf(neighbor)
|
||||
if (cycleStartIndex !== -1) {
|
||||
const chain = path.slice(cycleStartIndex)
|
||||
chain.push(neighbor) // Complete the cycle
|
||||
|
||||
// Create edges for the circular dependency
|
||||
const edges: Array<{ source: string; target: string }> = []
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
edges.push({ source: chain[i], target: chain[i + 1] })
|
||||
}
|
||||
|
||||
// Check if this cycle is already recorded (avoid duplicates)
|
||||
const chainStr = [...chain].sort().join('->')
|
||||
const isNew = !circularDeps.some((dep) => {
|
||||
const existingChainStr = [...dep.chain].sort().join('->')
|
||||
return existingChainStr === chainStr
|
||||
})
|
||||
|
||||
if (isNew) {
|
||||
circularDeps.push({ chain, edges })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recStack.delete(node)
|
||||
}
|
||||
|
||||
// Run DFS from each unvisited node
|
||||
for (const node of nodes.keys()) {
|
||||
if (!visited.has(node) && !node.startsWith('external:')) {
|
||||
findCycle(node)
|
||||
}
|
||||
}
|
||||
|
||||
return circularDeps
|
||||
}
|
||||
|
||||
// Generate dependency graph
|
||||
async function generateDependencyGraph(): Promise<DependencyGraph> {
|
||||
const sourceFiles = await glob('src/**/*.{ts,tsx,vue,mts}', {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/*.d.ts',
|
||||
'**/*.spec.ts',
|
||||
'**/*.test.ts',
|
||||
'**/*.stories.ts'
|
||||
]
|
||||
})
|
||||
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
size: number
|
||||
inCircularDep?: boolean
|
||||
circularChains?: string[][]
|
||||
}
|
||||
>()
|
||||
const links = new Map<
|
||||
string,
|
||||
{ source: string; target: string; value: number; isCircular?: boolean }
|
||||
>()
|
||||
|
||||
// Process each file
|
||||
for (const file of sourceFiles) {
|
||||
const importInfo = extractImports(file)
|
||||
const sourceId = path.relative(process.cwd(), file)
|
||||
|
||||
// Add source node
|
||||
if (!nodes.has(sourceId)) {
|
||||
nodes.set(sourceId, {
|
||||
id: sourceId,
|
||||
label: path.basename(file),
|
||||
group: getFileGroup(file),
|
||||
size: 1
|
||||
})
|
||||
}
|
||||
|
||||
// Process imports
|
||||
for (const importPath of importInfo.imports) {
|
||||
const resolvedPath = resolveImportPath(importPath, file)
|
||||
let targetId: string
|
||||
|
||||
// Check if it's an external module
|
||||
if (!resolvedPath.startsWith('/') && !resolvedPath.startsWith('.')) {
|
||||
targetId = `external:${importPath}`
|
||||
if (!nodes.has(targetId)) {
|
||||
nodes.set(targetId, {
|
||||
id: targetId,
|
||||
label: importPath,
|
||||
group: 'external',
|
||||
size: 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Try to find the actual file
|
||||
const possibleExtensions = [
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.vue',
|
||||
'.mts',
|
||||
'.js',
|
||||
'.json',
|
||||
'/index.ts',
|
||||
'/index.js'
|
||||
]
|
||||
let actualFile = resolvedPath
|
||||
|
||||
for (const ext of possibleExtensions) {
|
||||
if (fs.existsSync(resolvedPath + ext)) {
|
||||
actualFile = resolvedPath + ext
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(actualFile)) {
|
||||
targetId = path.relative(process.cwd(), actualFile)
|
||||
if (!nodes.has(targetId)) {
|
||||
nodes.set(targetId, {
|
||||
id: targetId,
|
||||
label: path.basename(actualFile),
|
||||
group: getFileGroup(actualFile),
|
||||
size: 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
continue // Skip unresolved imports
|
||||
}
|
||||
}
|
||||
|
||||
// Add link
|
||||
const linkKey = `${sourceId}->${targetId}`
|
||||
if (links.has(linkKey)) {
|
||||
links.get(linkKey)!.value++
|
||||
} else {
|
||||
links.set(linkKey, {
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
value: 1
|
||||
})
|
||||
}
|
||||
|
||||
// Increase target node size
|
||||
const targetNode = nodes.get(targetId)
|
||||
if (targetNode) {
|
||||
targetNode.size++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies
|
||||
const circularDeps = detectCircularDependencies(nodes, links)
|
||||
|
||||
// Mark nodes and links involved in circular dependencies
|
||||
const nodesInCircularDeps = new Set<string>()
|
||||
const circularLinkKeys = new Set<string>()
|
||||
|
||||
for (const dep of circularDeps) {
|
||||
// Mark all nodes in the chain
|
||||
for (const nodeId of dep.chain) {
|
||||
nodesInCircularDeps.add(nodeId)
|
||||
const node = nodes.get(nodeId)
|
||||
if (node) {
|
||||
node.inCircularDep = true
|
||||
if (!node.circularChains) {
|
||||
node.circularChains = []
|
||||
}
|
||||
node.circularChains.push(dep.chain)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all edges in the chain
|
||||
for (const edge of dep.edges) {
|
||||
const linkKey = `${edge.source}->${edge.target}`
|
||||
circularLinkKeys.add(linkKey)
|
||||
const link = links.get(linkKey)
|
||||
if (link) {
|
||||
link.isCircular = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${circularDeps.length} circular dependencies:`)
|
||||
circularDeps.forEach((dep, index) => {
|
||||
console.log(` ${index + 1}. ${dep.chain.join(' → ')}`)
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodes.values()),
|
||||
links: Array.from(links.values()),
|
||||
circularDependencies: circularDeps
|
||||
}
|
||||
}
|
||||
|
||||
// Generate HTML visualization
|
||||
function generateHTML(graph: DependencyGraph): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ComfyUI Frontend Import Map</title>
|
||||
<script src="https://unpkg.com/d3@7"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#graph {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 300px;
|
||||
background: #2a2a2a;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 1.5em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.node-tooltip {
|
||||
position: absolute;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.circular-dep-warning {
|
||||
color: #ff6b6b;
|
||||
font-weight: bold;
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
.circular-chain {
|
||||
color: #ffa500;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 20px 0;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
stroke: #ff0 !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="graph">
|
||||
<svg id="svg"></svg>
|
||||
<div class="node-tooltip"></div>
|
||||
</div>
|
||||
<div id="sidebar">
|
||||
<h1>Import Map</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<span>Total Files:</span>
|
||||
<span id="total-nodes">${graph.nodes.length}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Total Dependencies:</span>
|
||||
<span id="total-links">${graph.links.length}</span>
|
||||
</div>
|
||||
<div class="stat-item" style="color: #ff6b6b;">
|
||||
<span>Circular Dependencies:</span>
|
||||
<span id="circular-deps">${graph.circularDependencies?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="text" class="search-box" placeholder="Search files..." id="search">
|
||||
|
||||
<div class="legend">
|
||||
<h3>Categories</h3>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ff6b6b;"></div>
|
||||
<span>Components</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #4ecdc4;"></div>
|
||||
<span>Stores</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #45b7d1;"></div>
|
||||
<span>Services</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #96ceb4;"></div>
|
||||
<span>Views</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ffeaa7;"></div>
|
||||
<span>Composables</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #dfe6e9;"></div>
|
||||
<span>Utils</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #fab1a0;"></div>
|
||||
<span>Types</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #a29bfe;"></div>
|
||||
<span>External</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #636e72;"></div>
|
||||
<span>Other</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: none; border: 2px solid #ff0000;"></div>
|
||||
<span>Has Circular Dep</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="resetZoom()">Reset View</button>
|
||||
<button onclick="toggleSimulation()">Toggle Physics</button>
|
||||
<button onclick="exportData()">Export Data</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const graphData = ${JSON.stringify(graph, null, 2)};
|
||||
|
||||
// Color scheme for different groups
|
||||
const colorScale = d3.scaleOrdinal()
|
||||
.domain(['components', 'stores', 'services', 'views', 'composables', 'utils', 'types', 'external', 'other'])
|
||||
.range(['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9', '#fab1a0', '#a29bfe', '#636e72']);
|
||||
|
||||
// Setup SVG
|
||||
const width = window.innerWidth - 300;
|
||||
const height = window.innerHeight;
|
||||
|
||||
const svg = d3.select('#svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg.append('g');
|
||||
|
||||
// Setup zoom
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 10])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Create force simulation
|
||||
const simulation = d3.forceSimulation(graphData.nodes)
|
||||
.force('link', d3.forceLink(graphData.links)
|
||||
.id(d => d.id)
|
||||
.distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(d => Math.sqrt(d.size) * 5));
|
||||
|
||||
// Create links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(graphData.links)
|
||||
.enter().append('line')
|
||||
.attr('stroke', d => d.isCircular ? '#ff6666' : '#999')
|
||||
.attr('stroke-opacity', d => d.isCircular ? 0.8 : 0.6)
|
||||
.attr('stroke-width', d => d.isCircular ? Math.sqrt(d.value) * 1.5 : Math.sqrt(d.value));
|
||||
|
||||
// Create nodes
|
||||
const node = g.append('g')
|
||||
.selectAll('circle')
|
||||
.data(graphData.nodes)
|
||||
.enter().append('circle')
|
||||
.attr('r', d => Math.sqrt(d.size) * 3 + 3)
|
||||
.attr('fill', d => colorScale(d.group))
|
||||
.attr('stroke', d => d.inCircularDep ? '#ff0000' : '#fff')
|
||||
.attr('stroke-width', d => d.inCircularDep ? 3 : 1.5)
|
||||
.call(drag(simulation));
|
||||
|
||||
// Add labels for important nodes
|
||||
const label = g.append('g')
|
||||
.selectAll('text')
|
||||
.data(graphData.nodes.filter(d => d.size > 10))
|
||||
.enter().append('text')
|
||||
.text(d => d.label)
|
||||
.style('font-size', '10px')
|
||||
.style('fill', '#fff')
|
||||
.attr('dx', 15)
|
||||
.attr('dy', 4);
|
||||
|
||||
// Tooltip
|
||||
const tooltip = d3.select('.node-tooltip');
|
||||
|
||||
node.on('mouseover', (event, d) => {
|
||||
const connections = graphData.links.filter(l => l.source.id === d.id || l.target.id === d.id);
|
||||
|
||||
let tooltipContent = \`
|
||||
<strong>\${d.label}</strong><br>
|
||||
Type: \${d.group}<br>
|
||||
Connections: \${connections.length}<br>
|
||||
Path: \${d.id}
|
||||
\`;
|
||||
|
||||
// Add circular dependency information if applicable
|
||||
if (d.inCircularDep && d.circularChains) {
|
||||
tooltipContent += '<div class="circular-dep-warning">⚠️ Circular Dependency Detected!</div>';
|
||||
d.circularChains.forEach((chain, index) => {
|
||||
// Only show chains that include this node
|
||||
if (chain.includes(d.id)) {
|
||||
// Format the chain to show the cycle clearly
|
||||
const nodeIndex = chain.indexOf(d.id);
|
||||
const formattedChain = chain.map((node, i) => {
|
||||
const basename = node.split('/').pop();
|
||||
if (i === nodeIndex) {
|
||||
return \`<strong>\${basename}</strong>\`;
|
||||
}
|
||||
return basename;
|
||||
}).join(' → ');
|
||||
|
||||
tooltipContent += \`<div class="circular-chain">Chain \${index + 1}: \${formattedChain}</div>\`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tooltip
|
||||
.style('opacity', 1)
|
||||
.style('left', (event.pageX + 10) + 'px')
|
||||
.style('top', (event.pageY - 10) + 'px')
|
||||
.html(tooltipContent);
|
||||
})
|
||||
.on('mouseout', () => {
|
||||
tooltip.style('opacity', 0);
|
||||
});
|
||||
|
||||
// Update positions
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
node
|
||||
.attr('cx', d => d.x)
|
||||
.attr('cy', d => d.y);
|
||||
|
||||
label
|
||||
.attr('x', d => d.x)
|
||||
.attr('y', d => d.y);
|
||||
});
|
||||
|
||||
// Drag behavior
|
||||
function drag(simulation) {
|
||||
function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
event.subject.fx = event.subject.x;
|
||||
event.subject.fy = event.subject.y;
|
||||
}
|
||||
|
||||
function dragged(event) {
|
||||
event.subject.fx = event.x;
|
||||
event.subject.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
event.subject.fx = null;
|
||||
event.subject.fy = null;
|
||||
}
|
||||
|
||||
return d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended);
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
document.getElementById('search').addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
|
||||
node.classed('highlighted', false);
|
||||
|
||||
if (searchTerm) {
|
||||
node.classed('highlighted', d =>
|
||||
d.label.toLowerCase().includes(searchTerm) ||
|
||||
d.id.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Control functions
|
||||
let simulationRunning = true;
|
||||
|
||||
function resetZoom() {
|
||||
svg.transition()
|
||||
.duration(750)
|
||||
.call(zoom.transform, d3.zoomIdentity);
|
||||
}
|
||||
|
||||
function toggleSimulation() {
|
||||
if (simulationRunning) {
|
||||
simulation.stop();
|
||||
} else {
|
||||
simulation.restart();
|
||||
}
|
||||
simulationRunning = !simulationRunning;
|
||||
}
|
||||
|
||||
function exportData() {
|
||||
const dataStr = JSON.stringify(graphData, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = 'import-map.json';
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
}
|
||||
|
||||
// Resize handler
|
||||
window.addEventListener('resize', () => {
|
||||
const newWidth = window.innerWidth - 300;
|
||||
const newHeight = window.innerHeight;
|
||||
|
||||
svg.attr('width', newWidth).attr('height', newHeight);
|
||||
simulation.force('center', d3.forceCenter(newWidth / 2, newHeight / 2));
|
||||
simulation.alpha(0.3).restart();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
console.log('Generating import map...')
|
||||
|
||||
try {
|
||||
const graph = await generateDependencyGraph()
|
||||
console.log(
|
||||
`Found ${graph.nodes.length} nodes and ${graph.links.length} dependencies`
|
||||
)
|
||||
|
||||
if (graph.circularDependencies && graph.circularDependencies.length > 0) {
|
||||
console.log(
|
||||
`\n⚠️ Warning: Found ${graph.circularDependencies.length} circular dependencies!`
|
||||
)
|
||||
}
|
||||
|
||||
// Save JSON data
|
||||
const jsonPath = path.join(
|
||||
process.cwd(),
|
||||
'scripts',
|
||||
'map',
|
||||
'import-map.json'
|
||||
)
|
||||
fs.mkdirSync(path.dirname(jsonPath), { recursive: true })
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(graph, null, 2))
|
||||
console.log(`Saved JSON data to ${jsonPath}`)
|
||||
|
||||
// Generate and save HTML visualization
|
||||
const html = generateHTML(graph)
|
||||
const htmlPath = path.join(
|
||||
process.cwd(),
|
||||
'scripts',
|
||||
'map',
|
||||
'import-map.html'
|
||||
)
|
||||
fs.writeFileSync(htmlPath, html)
|
||||
console.log(`Saved HTML visualization to ${htmlPath}`)
|
||||
|
||||
console.log('✅ Import map generation complete!')
|
||||
console.log(
|
||||
'Open scripts/map/import-map.html in a browser to view the visualization'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error generating import map:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
void main()
|
||||
@@ -7,6 +7,66 @@
|
||||
|
||||
@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;
|
||||
@@ -47,91 +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);
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -874,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;
|
||||
@@ -887,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;
|
||||
@@ -964,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 */
|
||||
@@ -998,3 +971,4 @@ audio.comfy-audio.empty-audio-widget {
|
||||
/* Use solid colors only */
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbIt
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
@@ -53,9 +52,6 @@ const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
)
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
@@ -93,7 +89,6 @@ const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
@@ -49,7 +48,6 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu, { MenuState } from 'primevue/menu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -123,7 +121,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot && !props.item.isBlueprint
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
@@ -155,26 +153,12 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: props.item.isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
label: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -16,21 +16,6 @@
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex gap-4 justify-start"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="doNotAskAgain"
|
||||
class="flex gap-4 justify-start"
|
||||
input-id="doNotAskAgain"
|
||||
binary
|
||||
/>
|
||||
<label for="doNotAskAgain" severity="secondary">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('g.cancel')"
|
||||
icon="pi pi-undo"
|
||||
@@ -53,7 +38,7 @@
|
||||
@click="onConfirm"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="type === 'overwrite' || type === 'overwriteBlueprint'"
|
||||
v-else-if="type === 'overwrite'"
|
||||
:label="$t('g.overwrite')"
|
||||
severity="warn"
|
||||
icon="pi pi-save"
|
||||
@@ -89,14 +74,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
@@ -106,20 +87,14 @@ const props = defineProps<{
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
const onDeny = () => {
|
||||
props.onConfirm(false)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
|
||||
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
|
||||
props.onConfirm(true)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<!-- Conflict Warning Banner -->
|
||||
<div
|
||||
v-if="shouldShowManagerBanner"
|
||||
class="bg-yellow-500/20 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
|
||||
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
|
||||
>
|
||||
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
@@ -46,15 +46,14 @@
|
||||
{{ $t('manager.conflicts.warningBanner.button') }}
|
||||
</p>
|
||||
</div>
|
||||
<IconButton
|
||||
class="absolute top-0 right-0"
|
||||
type="transparent"
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-2 right-2 w-6 h-6 border-none outline-none bg-transparent flex items-center justify-center text-yellow-600 rounded transition-colors"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="dismissWarningBanner"
|
||||
>
|
||||
<i
|
||||
class="pi pi-times text-neutral-900 dark-theme:text-white text-xs"
|
||||
></i>
|
||||
</IconButton>
|
||||
<i class="pi pi-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<RegistrySearchBar
|
||||
v-model:searchQuery="searchQuery"
|
||||
@@ -139,7 +138,6 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.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
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: Migrate to Select component
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
|
||||
import type { SearchOption } from '@/types/comfyManagerTypes'
|
||||
|
||||
@@ -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,7 +36,6 @@
|
||||
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<VueGraphNode
|
||||
@@ -45,6 +44,7 @@
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:selected="nodeData.selected"
|
||||
:readonly="false"
|
||||
:executing="executionStore.executingNodeId === nodeData.id"
|
||||
:error="
|
||||
@@ -79,7 +79,6 @@ import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@@ -97,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'
|
||||
@@ -113,11 +112,9 @@ import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
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'
|
||||
@@ -149,8 +146,6 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
@@ -194,17 +189,6 @@ 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
|
||||
.filter((item) => item.id !== undefined)
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
)
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
<Load3DViewerButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<PublishSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshSelectionButton />
|
||||
<ExtensionCommandButton
|
||||
@@ -50,7 +49,6 @@ import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewer
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
|
||||
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_PublishSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="() => commandStore.execute('Comfy.PublishSubgraph')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:book-open />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.selectedItems?.length === 1 &&
|
||||
canvasStore.selectedItems[0] instanceof SubgraphNode
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="container" class="node-lib-node-container">
|
||||
<TreeExplorerTreeNode :node="node" @contextmenu="handleContextMenu">
|
||||
<TreeExplorerTreeNode :node="node">
|
||||
<template #before-label>
|
||||
<Tag
|
||||
v-if="nodeDef.experimental"
|
||||
@@ -13,30 +13,7 @@
|
||||
severity="danger"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
|
||||
#actions
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
@click.stop="editBlueprint"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:square-pen />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else #actions>
|
||||
<template #actions>
|
||||
<Button
|
||||
class="bookmark-button"
|
||||
size="small"
|
||||
@@ -63,13 +40,10 @@
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import {
|
||||
CSSProperties,
|
||||
@@ -79,18 +53,14 @@ import {
|
||||
onUnmounted,
|
||||
ref
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
@@ -110,33 +80,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const toggleBookmark = async () => {
|
||||
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
const editBlueprint = async () => {
|
||||
if (!props.node.data)
|
||||
throw new Error(
|
||||
'Failed to edit subgraph blueprint lacking backing node data'
|
||||
)
|
||||
await useSubgraphStore().editBlueprint(props.node.data.name)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
severity: 'error',
|
||||
command: deleteBlueprint
|
||||
}
|
||||
]
|
||||
return items
|
||||
})
|
||||
function handleContextMenu(event: Event) {
|
||||
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
|
||||
menu.value?.show(event)
|
||||
}
|
||||
function deleteBlueprint() {
|
||||
if (!props.node.data) return
|
||||
void useSubgraphStore().deleteBlueprint(props.node.data.name)
|
||||
}
|
||||
|
||||
const previewRef = ref<InstanceType<typeof NodePreview> | null>(null)
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
|
||||
@@ -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,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"
|
||||
>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { type ComputedRef, computed } from 'vue'
|
||||
|
||||
import { type ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
type SubcategoryRule = {
|
||||
export type SubcategoryRule = {
|
||||
pattern: string | RegExp
|
||||
subcategory: string
|
||||
}
|
||||
|
||||
type SubcategoryConfig = {
|
||||
export type SubcategoryConfig = {
|
||||
defaultSubcategory: string
|
||||
rules: SubcategoryRule[]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -23,7 +23,7 @@ function intersect(a: Rect, b: Rect): [number, number, number, number] | null {
|
||||
return [x1, y1, x2 - x1, y2 - y1]
|
||||
}
|
||||
|
||||
interface ClippingOptions {
|
||||
export interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
interface CanvasTransformSyncOptions {
|
||||
export interface CanvasTransformSyncOptions {
|
||||
/**
|
||||
* Whether to automatically start syncing when canvas is available
|
||||
* @default true
|
||||
@@ -10,7 +10,7 @@ interface CanvasTransformSyncOptions {
|
||||
autoStart?: boolean
|
||||
}
|
||||
|
||||
interface CanvasTransformSyncCallbacks {
|
||||
export interface CanvasTransformSyncCallbacks {
|
||||
/**
|
||||
* Called when sync starts
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
@@ -20,14 +19,14 @@ export interface NodeState {
|
||||
culled: boolean
|
||||
}
|
||||
|
||||
interface NodeMetadata {
|
||||
export interface NodeMetadata {
|
||||
lastRenderTime: number
|
||||
cachedBounds: DOMRect | null
|
||||
lodLevel: 'high' | 'medium' | 'low'
|
||||
spatialIndex?: QuadTree<string>
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
export interface PerformanceMetrics {
|
||||
fps: number
|
||||
frameTime: number
|
||||
updateTime: number
|
||||
@@ -61,12 +60,12 @@ export interface VueNodeData {
|
||||
}
|
||||
}
|
||||
|
||||
interface SpatialMetrics {
|
||||
export interface SpatialMetrics {
|
||||
queryTime: number
|
||||
nodesInIndex: number
|
||||
}
|
||||
|
||||
interface GraphNodeManager {
|
||||
export interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
nodeState: ReadonlyMap<string, NodeState>
|
||||
@@ -236,15 +235,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
if (Array.isArray(value) && value.every((item) => item instanceof File)) {
|
||||
return value as File[]
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
return value as object
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
@@ -596,7 +591,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,
|
||||
@@ -620,48 +614,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,
|
||||
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,46 +21,35 @@ 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)
|
||||
if (!node) return
|
||||
|
||||
const isMultiSelect = event.ctrlKey || event.metaKey
|
||||
|
||||
if (isMultiSelect) {
|
||||
// Ctrl/Cmd+click -> toggle selection
|
||||
if (node.selected) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
} else {
|
||||
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
|
||||
// Handle multi-select with Ctrl/Cmd key
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
canvasStore.canvas.deselectAllNodes()
|
||||
}
|
||||
|
||||
canvasStore.canvas.selectNode(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)
|
||||
}
|
||||
|
||||
// Ensure node selection state is set
|
||||
node.selected = true
|
||||
|
||||
// Update canvas selection tracking
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
@@ -114,7 +104,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 +123,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 +148,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 +166,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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useDebounceFn, useEventListener, useThrottleFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface TransformSettlingOptions {
|
||||
export interface TransformSettlingOptions {
|
||||
/**
|
||||
* Delay in ms before transform is considered "settled" after last interaction
|
||||
* @default 200
|
||||
|
||||
@@ -6,7 +6,10 @@ import { type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
|
||||
export interface UseWidgetValueOptions<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
U = T
|
||||
> {
|
||||
/** The widget configuration from LiteGraph */
|
||||
widget: SimplifiedWidget<T>
|
||||
/** The current value from parent component */
|
||||
@@ -19,7 +22,10 @@ interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
|
||||
transform?: (value: U) => T
|
||||
}
|
||||
|
||||
interface UseWidgetValueReturn<T extends WidgetValue = WidgetValue, U = T> {
|
||||
export interface UseWidgetValueReturn<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
U = T
|
||||
> {
|
||||
/** Local value for immediate UI updates */
|
||||
localValue: Ref<T>
|
||||
/** Handler for user interactions */
|
||||
|
||||
@@ -35,7 +35,7 @@ const createContainer = () => {
|
||||
const createTimeout = (ms: number) =>
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
|
||||
|
||||
const useNodePreview = <T extends MediaElement>(
|
||||
export const useNodePreview = <T extends MediaElement>(
|
||||
node: LGraphNode,
|
||||
options: NodePreviewOptions<T>
|
||||
) => {
|
||||
|
||||
@@ -1062,7 +1062,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !generateAudioWidget) {
|
||||
return '$0.80-3.20/Run (varies with model & audio generation)'
|
||||
return '$2.00-6.00/Run (varies with model & audio generation)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
@@ -1070,13 +1070,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
|
||||
if (model.includes('veo-3.0-fast-generate-001')) {
|
||||
return generateAudio ? '$1.20/Run' : '$0.80/Run'
|
||||
return generateAudio ? '$3.20/Run' : '$2.00/Run'
|
||||
} else if (model.includes('veo-3.0-generate-001')) {
|
||||
return generateAudio ? '$3.20/Run' : '$1.60/Run'
|
||||
return generateAudio ? '$6.00/Run' : '$4.00/Run'
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return '$0.80-3.20/Run'
|
||||
return '$2.00-6.00/Run'
|
||||
}
|
||||
},
|
||||
LumaImageNode: {
|
||||
@@ -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'],
|
||||
|
||||