Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
96869ed27a fix merge error 2025-09-07 15:24:03 -07:00
bymyself
b50a5bd459 let canvas continue to own selection state management 2025-09-07 15:11:46 -07:00
189 changed files with 2891 additions and 7604 deletions

View File

@@ -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}"

View File

@@ -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 }}

View File

@@ -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

View File

@@ -229,13 +229,7 @@ jobs:
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: |
# Run tests with both HTML and JSON reporters
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
npx playwright test --project=${{ matrix.browser }} \
--reporter=list \
--reporter=html \
--reporter=json
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
working-directory: ComfyUI_frontend
- uses: actions/upload-artifact@v4
@@ -281,12 +275,7 @@ jobs:
merge-multiple: true
- name: Merge into HTML Report
run: |
# Generate HTML report
npx playwright merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
npx playwright merge-reports --reporter=json ./all-blob-reports
run: npx playwright merge-reports --reporter html ./all-blob-reports
working-directory: ComfyUI_frontend
- name: Upload HTML report
@@ -295,65 +284,3 @@ jobs:
name: playwright-report-chromium
path: ComfyUI_frontend/playwright-report/
retention-days: 30
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get start time
id: start-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"${{ steps.start-time.outputs.time }}"
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all playwright reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
path: reports
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
- name: Deploy reports and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
run: |
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
#### END Deployment and commenting (non-forked PRs only)

View File

@@ -40,7 +40,7 @@ jobs:
- name: Get new version
id: get-version
run: |
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].dependencies."@comfyorg/comfyui-electron-types".version')
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version')
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request

1
.gitignore vendored
View File

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

1
.npmrc
View File

@@ -1 +0,0 @@
ignore-workspace-root-check=true

View File

@@ -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>

View File

@@ -12,7 +12,6 @@ import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { SettingDialog } from './components/SettingDialog'
import {
@@ -145,7 +144,6 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -174,7 +172,6 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {
@@ -1424,7 +1421,7 @@ export class ComfyPage {
}
async closeDialog() {
await this.page.locator('.p-dialog-close-button').click({ force: true })
await this.page.locator('.p-dialog-close-button').click()
await expect(this.page.locator('.p-dialog')).toBeHidden()
}

View File

@@ -1,108 +0,0 @@
/**
* Vue Node Test Helpers
*/
import type { Locator, Page } from '@playwright/test'
export class VueNodeHelpers {
constructor(private page: Page) {}
/**
* Get locator for all Vue node components in the DOM
*/
get nodes(): Locator {
return this.page.locator('[data-node-id]')
}
/**
* Get locator for selected Vue node components (using visual selection indicators)
*/
get selectedNodes(): Locator {
return this.page.locator('[data-node-id].border-blue-500')
}
/**
* Get total count of Vue nodes in the DOM
*/
async getNodeCount(): Promise<number> {
return await this.nodes.count()
}
/**
* Get count of selected Vue nodes
*/
async getSelectedNodeCount(): Promise<number> {
return await this.selectedNodes.count()
}
/**
* Get all Vue node IDs currently in the DOM
*/
async getNodeIds(): Promise<string[]> {
return await this.nodes.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute('data-node-id'))
.filter((id): id is string => id !== null)
)
}
/**
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
}
/**
* Select multiple Vue nodes by IDs using Ctrl+click
*/
async selectNodes(nodeIds: string[]): Promise<void> {
if (nodeIds.length === 0) return
// Select first node normally
await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click
for (let i = 1; i < nodeIds.length; i++) {
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
modifiers: ['Control']
})
}
}
/**
* Clear all selections by clicking empty space
*/
async clearSelection(): Promise<void> {
await this.page.mouse.click(50, 50)
}
/**
* Delete selected Vue nodes using Delete key
*/
async deleteSelected(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Delete')
}
/**
* Delete selected Vue nodes using Backspace key
*/
async deleteSelectedWithBackspace(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Backspace')
}
/**
* Wait for Vue nodes to be rendered
*/
async waitForNodes(expectedCount?: number): Promise<void> {
if (expectedCount !== undefined) {
await this.page.waitForFunction(
(count) => document.querySelectorAll('[data-node-id]').length >= count,
expectedCount
)
} else {
await this.page.waitForSelector('[data-node-id]')
}
}
}

View File

@@ -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')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -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 ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,141 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Vue Nodes - Delete Key Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
// Enable Vue nodes rendering
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setup()
})
test('Can select all and delete Vue nodes with Delete key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Select all Vue nodes
await comfyPage.ctrlA()
// Verify all Vue nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialNodeCount)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify all Vue nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(0)
})
test('Can select specific Vue node and delete it', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Get first Vue node ID and select it
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Verify selection
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(1)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify one Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Can select and delete Vue node with Backspace key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Select first Vue node
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Delete with Backspace key instead of Delete
await comfyPage.vueNodes.deleteSelectedWithBackspace()
// Verify Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Delete key does not delete node when typing in Vue node widgets', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
// Find a text input widget in a Vue node
const textWidget = comfyPage.page
.locator('input[type="text"], textarea')
.first()
// Click on text widget to focus it
await textWidget.click()
await textWidget.fill('test text')
// Press Delete while focused on widget - should delete text, not node
await textWidget.press('Delete')
// Node count should remain the same
const finalNodeCount = await comfyPage.getGraphNodesCount()
expect(finalNodeCount).toBe(initialNodeCount)
})
test('Delete key does not delete node when nothing is selected', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Ensure no Vue nodes are selected
await comfyPage.vueNodes.clearSelection()
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(0)
// Press Delete key - should not crash and should handle gracefully
await comfyPage.page.keyboard.press('Delete')
// Vue node count should remain the same
const nodeCount = await comfyPage.vueNodes.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
})
test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Multi-select first two Vue nodes using Ctrl+click
const nodeIds = await comfyPage.vueNodes.getNodeIds()
const nodesToSelect = nodeIds.slice(0, 2)
await comfyPage.vueNodes.selectNodes(nodesToSelect)
// Verify expected nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(nodesToSelect.length)
// Delete selected Vue nodes
await comfyPage.vueNodes.deleteSelected()
// Verify expected nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length)
})
})

View File

@@ -264,13 +264,7 @@ test.describe('Animated image widget', () => {
expect(filename).toContain('animated_webp.webp')
})
// FIXME: This test keeps flip-flopping because it relies on animated webp timing,
// which is inherently unreliable in CI environments. The test asset is an animated
// webp with 2 frames, and the test depends on animation frame timing to verify that
// animated webp images are properly displayed (as opposed to being treated as static webp).
// While the underlying functionality works (animated webp are correctly distinguished
// from static webp), the test is flaky due to timing dependencies with webp animation frames.
test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => {
test('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@@ -1,20 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/assets/css/style.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -1,62 +0,0 @@
# 4. Fork PrimeVue UI Library
Date: 2025-08-27
## Status
Rejected
## Context
ComfyUI's frontend requires modifications to PrimeVue components that cannot be achieved through the library's customization APIs. Two specific technical incompatibilities have been identified with the transform-based canvas architecture:
**Screen Coordinate Hit-Testing Conflicts:**
- PrimeVue components use `getBoundingClientRect()` for screen coordinate calculations that don't account for CSS transforms
- The Slider component directly uses raw `pageX/pageY` coordinates ([lines 102-103](https://github.com/primefaces/primevue/blob/master/packages/primevue/src/slider/Slider.vue#L102-L103)) without transform-aware positioning
- This breaks interaction in transformed coordinate spaces where screen coordinates don't match logical element positions
**Virtual Canvas Scroll Interference:**
- LiteGraph's infinite canvas uses scroll coordinates semantically for graph navigation via the `DragAndScale` coordinate system
- PrimeVue overlay components automatically trigger `scrollIntoView` behavior which interferes with this virtual positioning
- This issue is documented in [PrimeVue discussion #4270](https://github.com/orgs/primefaces/discussions/4270) where the feature request was made to disable this behavior
**Historical Overlay Issues:**
- Previous z-index positioning conflicts required manual workarounds (commit `6d4eafb0`) where PrimeVue Dialog components needed `autoZIndex: false` and custom mask styling, later resolved by removing PrimeVue's automatic z-index management entirely
**Minimal Update Overhead:**
- Analysis of git history shows only 2 PrimeVue version updates in 2+ years, indicating that upstream sync overhead is negligible for this project
**Future Interaction System Requirements:**
- The ongoing canvas architecture evolution will require more granular control over component interaction and event handling as the transform-based system matures
- Predictable need for additional component modifications beyond current identified issues
## Decision
We will **NOT** fork PrimeVue. After evaluation, forking was determined to be unnecessarily complex and costly.
**Rationale for Rejection:**
- **Significant Implementation Complexity**: PrimeVue is structured as a monorepo ([primefaces/primevue](https://github.com/primefaces/primevue)) with significant code in a separate monorepo ([PrimeUIX](https://github.com/primefaces/primeuix)). Forking would require importing both repositories whole and selectively pruning or exempting components from our workspace tooling, adding substantial complexity.
- **Alternative Solutions Available**: The modifications we identified (e.g., scroll interference issues, coordinate system conflicts) have less costly solutions that don't require maintaining a full fork. For example, coordinate issues could be addressed through event interception and synthetic event creation with scaled values.
- **Maintenance Burden**: Ongoing maintenance and upgrades would be very painful, requiring manual conflict resolution and keeping pace with upstream changes across multiple repositories.
- **Limited Tooling Support**: There isn't adequate tooling that provides the granularity needed to cleanly manage a PrimeVue fork within our existing infrastructure.
## Consequences
### Alternative Approach
- **Use PrimeVue as External Dependency**: Continue using PrimeVue as a standard npm dependency
- **Targeted Workarounds**: Implement specific solutions for identified issues (coordinate system conflicts, scroll interference) without forking the entire library
- **Selective Component Replacement**: Use libraries like shadcn/ui to replace specific problematic PrimeVue components and adjust them to match our design system
- **Upstream Engagement**: Continue engaging with PrimeVue community for feature requests and bug reports
- **Maintain Flexibility**: Preserve ability to upgrade PrimeVue versions without fork maintenance overhead
## Notes
- Technical issues documented in the Context section remain valid concerns
- Solutions will be pursued through targeted fixes rather than wholesale forking
- Future re-evaluation possible if PrimeVue's architecture significantly changes or if alternative tooling becomes available
- This decision prioritizes maintainability and development velocity over maximum customization control

View File

@@ -13,7 +13,6 @@ An Architecture Decision Record captures an important architectural decision mad
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
## Creating a New ADR

View File

@@ -64,9 +64,6 @@ export default [
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/one-component-per-file': 'off', // TODO: fix
// Restrict deprecated PrimeVue components
'no-restricted-imports': [
'error',

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.27.3",
"version": "1.27.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -25,8 +25,8 @@
"preinstall": "npx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint": "eslint src --cache --concurrency=auto",
"lint:fix": "eslint src --cache --fix --concurrency=auto",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip --cache",
@@ -39,7 +39,6 @@
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
@@ -77,13 +76,13 @@
"jsdom": "^26.1.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.1",
"tailwindcss": "^4.1.12",
"tailwindcss-primeui": "^0.6.1",
"tsx": "^4.15.6",
"tw-animate-css": "^1.3.8",
"typescript": "^5.4.5",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^0.22.0",
@@ -101,7 +100,7 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.72",
"@comfyorg/comfyui-electron-types": "^0.4.69",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",
@@ -141,7 +140,6 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"reka-ui": "^2.5.0",
"semver": "^7.7.2",
"tailwind-merge": "^3.3.1",
"three": "^0.170.0",

190
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: ^0.4.72
version: 0.4.72
specifier: ^0.4.69
version: 0.4.69
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
@@ -134,9 +134,6 @@ importers:
primevue:
specifier: ^4.2.5
version: 4.2.5(vue@3.5.13(typescript@5.9.2))
reka-ui:
specifier: ^2.5.0
version: 2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2))
semver:
specifier: ^7.7.2
version: 7.7.2
@@ -174,9 +171,6 @@ importers:
'@eslint/js':
specifier: ^9.8.0
version: 9.12.0
'@iconify-json/lucide':
specifier: ^1.2.66
version: 1.2.66
'@iconify/tailwind':
specifier: ^1.2.0
version: 1.2.0
@@ -288,6 +282,9 @@ importers:
lint-staged:
specifier: ^15.2.7
version: 15.2.7
lucide-vue-next:
specifier: ^0.540.0
version: 0.540.0(vue@3.5.13(typescript@5.9.2))
nx:
specifier: 21.4.1
version: 21.4.1
@@ -306,9 +303,6 @@ importers:
tsx:
specifier: ^4.15.6
version: 4.19.4
tw-animate-css:
specifier: ^1.3.8
version: 1.3.8
typescript:
specifier: ^5.4.5
version: 5.9.2
@@ -986,8 +980,8 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@comfyorg/comfyui-electron-types@0.4.72':
resolution: {integrity: sha512-Ecf0XYOKDqqIcnjSWL8GHLo6MOsuwqs0I1QgWc3Hv+BZm+qUE4vzOXCyhfFoTIGHLZFTwe37gnygPPKFzMu00Q==}
'@comfyorg/comfyui-electron-types@0.4.69':
resolution: {integrity: sha512-emEapJvbbx8lXiJ/84gmk+fYU73MmqkQKgBDQkyDwctcOb+eNe347PaH/+0AIjX8A/DtFHfnwgh9J8k3RVdqZA==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -1576,18 +1570,6 @@ packages:
'@firebase/webchannel-wrapper@1.0.3':
resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==}
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/vue@1.1.9':
resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==}
'@grpc/grpc-js@1.9.15':
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
engines: {node: ^8.13.0 || >=10.10.0}
@@ -1613,9 +1595,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/lucide@1.2.66':
resolution: {integrity: sha512-TrhmfThWY2FHJIckjz7g34gUx3+cmja61DcHNdmu0rVDBQHIjPMYO1O8mMjoDSqIXEllz9wDZxCqT3lFuI+f/A==}
'@iconify/json@2.2.380':
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
@@ -1628,12 +1607,6 @@ packages:
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@internationalized/date@3.9.0':
resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==}
'@internationalized/number@3.6.5':
resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==}
'@intlify/core-base@9.14.3':
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
engines: {node: '>= 16'}
@@ -2267,9 +2240,6 @@ packages:
storybook: ^9.1.1
vue: ^3.0.0
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
'@tailwindcss/node@4.1.12':
resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==}
@@ -2360,14 +2330,6 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@tanstack/vue-virtual@3.13.12':
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -2644,9 +2606,6 @@ packages:
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/webxr@0.5.20':
resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==}
@@ -2897,21 +2856,12 @@ packages:
'@vueuse/core@11.0.0':
resolution: {integrity: sha512-shibzNGjmRjZucEm97B8V0NO5J3vPHMCE/mltxQ3vHezbDoFQBMtK11XsfwfPionxSbo+buqPmsCljtYuXIBpw==}
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
'@vueuse/metadata@11.0.0':
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
'@vueuse/shared@11.0.0':
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
@@ -3065,10 +3015,6 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
@@ -3539,9 +3485,6 @@ packages:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -4793,6 +4736,11 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lucide-vue-next@0.540.0:
resolution: {integrity: sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==}
peerDependencies:
vue: '>=3.0.1'
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -5159,9 +5107,6 @@ packages:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -5612,11 +5557,6 @@ packages:
resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==}
hasBin: true
reka-ui@2.5.0:
resolution: {integrity: sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==}
peerDependencies:
vue: '>= 3.2.0'
relateurl@0.2.7:
resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
engines: {node: '>= 0.10'}
@@ -6051,9 +5991,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tw-animate-css@1.3.8:
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -6356,8 +6293,8 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.0.6:
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -7502,7 +7439,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@comfyorg/comfyui-electron-types@0.4.72': {}
'@comfyorg/comfyui-electron-types@0.4.69': {}
'@csstools/color-helpers@5.1.0': {}
@@ -8064,26 +8001,6 @@ snapshots:
'@firebase/webchannel-wrapper@1.0.3': {}
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/utils@0.2.10': {}
'@floating-ui/vue@1.1.9(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@floating-ui/dom': 1.7.4
'@floating-ui/utils': 0.2.10
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@grpc/grpc-js@1.9.15':
dependencies:
'@grpc/proto-loader': 0.7.13
@@ -8107,10 +8024,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/lucide@1.2.66':
dependencies:
'@iconify/types': 2.0.0
'@iconify/json@2.2.380':
dependencies:
'@iconify/types': 2.0.0
@@ -8135,14 +8048,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@internationalized/date@3.9.0':
dependencies:
'@swc/helpers': 0.5.17
'@internationalized/number@3.6.5':
dependencies:
'@swc/helpers': 0.5.17
'@intlify/core-base@9.14.3':
dependencies:
'@intlify/message-compiler': 9.14.3
@@ -8925,11 +8830,7 @@ snapshots:
storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.0.7
'@swc/helpers@0.5.17':
dependencies:
tslib: 2.8.1
vue-component-type-helpers: 3.0.6
'@tailwindcss/node@4.1.12':
dependencies:
@@ -9002,13 +8903,6 @@ snapshots:
tailwindcss: 4.1.12
vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
'@tanstack/virtual-core@3.13.12': {}
'@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@tanstack/virtual-core': 3.13.12
vue: 3.5.13(typescript@5.9.2)
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.27.1
@@ -9316,8 +9210,6 @@ snapshots:
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
'@types/webxr@0.5.20': {}
'@typescript-eslint/eslint-plugin@8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)':
@@ -9724,19 +9616,8 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/core@12.8.2(typescript@5.9.2)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 12.8.2
'@vueuse/shared': 12.8.2(typescript@5.9.2)
vue: 3.5.13(typescript@5.9.2)
transitivePeerDependencies:
- typescript
'@vueuse/metadata@11.0.0': {}
'@vueuse/metadata@12.8.2': {}
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
@@ -9744,12 +9625,6 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/shared@12.8.2(typescript@5.9.2)':
dependencies:
vue: 3.5.13(typescript@5.9.2)
transitivePeerDependencies:
- typescript
'@webgpu/types@0.1.51': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
@@ -9898,10 +9773,6 @@ snapshots:
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
@@ -10371,8 +10242,6 @@ snapshots:
define-lazy-prop@3.0.0: {}
defu@6.1.4: {}
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
@@ -11694,6 +11563,10 @@ snapshots:
lru-cache@8.0.5: {}
lucide-vue-next@0.540.0(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)
lz-string@1.5.0: {}
magic-string@0.30.17:
@@ -12264,8 +12137,6 @@ snapshots:
object-keys@1.1.1: {}
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -12842,23 +12713,6 @@ snapshots:
dependencies:
jsesc: 3.0.2
reka-ui@2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)):
dependencies:
'@floating-ui/dom': 1.7.4
'@floating-ui/vue': 1.1.9(vue@3.5.13(typescript@5.9.2))
'@internationalized/date': 3.9.0
'@internationalized/number': 3.6.5
'@tanstack/vue-virtual': 3.13.12(vue@3.5.13(typescript@5.9.2))
'@vueuse/core': 12.8.2(typescript@5.9.2)
'@vueuse/shared': 12.8.2(typescript@5.9.2)
aria-hidden: 1.2.6
defu: 6.1.4
ohash: 2.0.11
vue: 3.5.13(typescript@5.9.2)
transitivePeerDependencies:
- '@vue/composition-api'
- typescript
relateurl@0.2.7: {}
remark-frontmatter@5.0.0:
@@ -13306,8 +13160,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tw-animate-css@1.3.8: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -13654,7 +13506,7 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.0.6: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -1,183 +0,0 @@
#!/usr/bin/env tsx
import fs from 'fs'
import path from 'path'
interface TestStats {
expected?: number
unexpected?: number
flaky?: number
skipped?: number
finished?: number
}
interface ReportData {
stats?: TestStats
}
interface TestCounts {
passed: number
failed: number
flaky: number
skipped: number
total: number
}
/**
* Extract test counts from Playwright HTML report
* @param reportDir - Path to the playwright-report directory
* @returns Test counts { passed, failed, flaky, skipped, total }
*/
function extractTestCounts(reportDir: string): TestCounts {
const counts: TestCounts = {
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
total: 0
}
try {
// First, try to find report.json which Playwright generates with JSON reporter
const jsonReportFile = path.join(reportDir, 'report.json')
if (fs.existsSync(jsonReportFile)) {
const reportJson: ReportData = JSON.parse(
fs.readFileSync(jsonReportFile, 'utf-8')
)
if (reportJson.stats) {
const stats = reportJson.stats
counts.total = stats.expected || 0
counts.passed =
(stats.expected || 0) -
(stats.unexpected || 0) -
(stats.flaky || 0) -
(stats.skipped || 0)
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
return counts
}
}
// Try index.html - Playwright HTML report embeds data in a script tag
const indexFile = path.join(reportDir, 'index.html')
if (fs.existsSync(indexFile)) {
const content = fs.readFileSync(indexFile, 'utf-8')
// Look for the embedded report data in various formats
// Format 1: window.playwrightReportBase64
let dataMatch = content.match(
/window\.playwrightReportBase64\s*=\s*["']([^"']+)["']/
)
if (dataMatch) {
try {
const decodedData = Buffer.from(dataMatch[1], 'base64').toString(
'utf-8'
)
const reportData: ReportData = JSON.parse(decodedData)
if (reportData.stats) {
const stats = reportData.stats
counts.total = stats.expected || 0
counts.passed =
(stats.expected || 0) -
(stats.unexpected || 0) -
(stats.flaky || 0) -
(stats.skipped || 0)
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
return counts
}
} catch (e) {
// Continue to try other formats
}
}
// Format 2: window.playwrightReport
dataMatch = content.match(/window\.playwrightReport\s*=\s*({[\s\S]*?});/)
if (dataMatch) {
try {
// Use Function constructor instead of eval for safety
const reportData = new Function(
'return ' + dataMatch[1]
)() as ReportData
if (reportData.stats) {
const stats = reportData.stats
counts.total = stats.expected || 0
counts.passed =
(stats.expected || 0) -
(stats.unexpected || 0) -
(stats.flaky || 0) -
(stats.skipped || 0)
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
return counts
}
} catch (e) {
// Continue to try other formats
}
}
// Format 3: Look for stats in the HTML content directly
// Playwright sometimes renders stats in the UI
const statsMatch = content.match(
/(\d+)\s+passed[^0-9]*(\d+)\s+failed[^0-9]*(\d+)\s+flaky[^0-9]*(\d+)\s+skipped/i
)
if (statsMatch) {
counts.passed = parseInt(statsMatch[1]) || 0
counts.failed = parseInt(statsMatch[2]) || 0
counts.flaky = parseInt(statsMatch[3]) || 0
counts.skipped = parseInt(statsMatch[4]) || 0
counts.total =
counts.passed + counts.failed + counts.flaky + counts.skipped
return counts
}
// Format 4: Try to extract from summary text patterns
const passedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+passed/i)
const failedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+failed/i)
const flakyMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+flaky/i)
const skippedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+skipped/i)
const totalMatch = content.match(
/(\d+)\s+(?:tests?|specs?)\s+(?:total|ran)/i
)
if (passedMatch) counts.passed = parseInt(passedMatch[1]) || 0
if (failedMatch) counts.failed = parseInt(failedMatch[1]) || 0
if (flakyMatch) counts.flaky = parseInt(flakyMatch[1]) || 0
if (skippedMatch) counts.skipped = parseInt(skippedMatch[1]) || 0
if (totalMatch) {
counts.total = parseInt(totalMatch[1]) || 0
} else if (
counts.passed ||
counts.failed ||
counts.flaky ||
counts.skipped
) {
counts.total =
counts.passed + counts.failed + counts.flaky + counts.skipped
}
}
} catch (error) {
console.error(`Error reading report from ${reportDir}:`, error)
}
return counts
}
// Main execution
const reportDir = process.argv[2]
if (!reportDir) {
console.error('Usage: extract-playwright-counts.ts <report-directory>')
process.exit(1)
}
const counts = extractTestCounts(reportDir)
// Output as JSON for easy parsing in shell script
console.log(JSON.stringify(counts))
export { extractTestCounts }

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
`

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }
}
}

View File

@@ -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>

View File

@@ -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>
`
})
}

View File

@@ -1,3 +1,4 @@
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
<template>
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
<div class="text-2xl font-medium mb-2">

View File

@@ -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>

View File

@@ -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'

View File

@@ -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'

View File

@@ -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()
})
})
})

View File

@@ -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 =

View File

@@ -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

View File

@@ -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'

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -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'

View File

@@ -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'

View File

@@ -28,7 +28,7 @@
id="graph-canvas"
ref="canvasRef"
tabindex="1"
class="align-top w-full h-full touch-none"
class="w-full h-full touch-none"
/>
<!-- TransformPane for Vue node rendering -->
@@ -36,17 +36,21 @@
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
v-for="nodeData in allNodes"
v-for="nodeData in nodesToRender"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@@ -92,7 +96,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -112,7 +116,6 @@ import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -144,8 +147,6 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const canvasInteractions = useCanvasInteractions()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -178,27 +179,31 @@ const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const allNodes = viewportCulling.allNodes
const nodesToRender = viewportCulling.nodesToRender
const handleTransformUpdate = () => {
viewportCulling.handleTransformUpdate()
// TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value()
viewportCulling.handleTransformUpdate(
vueNodeLifecycle.detectChangesInRAF.value
)
}
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
const selectedNodeIds = ref(new Set<string>())
provide(SelectedNodeIdsKey, selectedNodeIds)
watch(
() => canvasStore.selectedItems,
(newSelectedItems) => {
selectedNodeIds.value = new Set(
newSelectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
},
{ immediate: true }
)
provide(SelectedNodeIdsKey, selectedNodeIds)
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')

View File

@@ -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 }
}
}

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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 }
}
}

View File

@@ -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

View File

@@ -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
})

View File

@@ -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'

View File

@@ -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'

View File

@@ -1,80 +0,0 @@
<script setup lang="ts">
import { reactiveOmit } from '@vueuse/core'
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import {
SliderRange,
SliderRoot,
SliderThumb,
SliderTrack,
useForwardPropsEmits
} from 'reka-ui'
import { type HTMLAttributes, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
SliderRootProps & { class?: HTMLAttributes['class'] }
>()
const pressed = ref(false)
const setPressed = (val: boolean) => {
pressed.value = val
}
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SliderRoot
v-slot="{ modelValue }"
data-slot="slider"
:class="
cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
'data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
props.class
)
"
v-bind="forwarded"
@slide-start="() => setPressed(true)"
@slide-move="() => setPressed(true)"
@slide-end="() => setPressed(false)"
>
<SliderTrack
data-slot="slider-track"
:class="
cn(
'bg-node-stroke relative grow overflow-hidden rounded-full',
'cursor-pointer overflow-visible',
`before:absolute before:-inset-2 before:block before:bg-transparent`,
'data-[orientation=horizontal]:h-0.5 data-[orientation=horizontal]:w-full',
'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-0.5'
)
"
>
<SliderRange
data-slot="slider-range"
class="bg-node-component-surface-highlight absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
/>
</SliderTrack>
<SliderThumb
v-for="(_, key) in modelValue"
:key="key"
data-slot="slider-thumb"
:class="
cn(
'bg-node-component-surface-highlight ring-node-component-surface-selected block size-3.5 shrink-0 rounded-full shadow-sm transition-[color,box-shadow]',
'cursor-grab',
'before:absolute before:-inset-1 before:block before:bg-transparent before:rounded-full',
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
)
"
/>
</SliderRoot>
</template>

View File

@@ -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)
})

View File

@@ -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>
`
})

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 }
}
}

View File

@@ -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
}>()

View File

@@ -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>

View 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>
`
})
}

View File

@@ -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>
`
})
}

View File

@@ -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"
>

View File

@@ -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

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -4,7 +4,6 @@
*/
import { nextTick, reactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
@@ -56,7 +55,6 @@ export interface VueNodeData {
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
hasErrors?: boolean
flags?: {
collapsed?: boolean
}
@@ -202,21 +200,13 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
}
})
const nodeType =
node.type ||
node.constructor?.comfyClass ||
node.constructor?.title ||
node.constructor?.name ||
'Unknown'
return {
id: String(node.id),
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
title: node.title || 'Untitled',
type: node.type || 'Unknown',
mode: node.mode || 0,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
@@ -605,7 +595,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
@@ -619,7 +608,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract initial data for Vue (may be incomplete during graph configure)
// Extract safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
// Set up reactive tracking state
@@ -629,52 +618,27 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
lastUpdate: performance.now(),
culled: false
})
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
const initializeVueNodeLayout = () => {
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
nodePositions.set(id, nodePosition)
nodeSizes.set(id, nodeSize)
attachMetadata(node)
// Add to spatial index for viewport culling with final positions
const nodeBounds: Bounds = {
x: nodePosition.x,
y: nodePosition.y,
width: nodeSize.width,
height: nodeSize.height
}
spatialIndex.insert(id, nodeBounds, id)
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
// Add to spatial index for viewport culling
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Add node to layout store
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
// Call original callback if provided
if (originalCallback) {

View File

@@ -11,7 +11,8 @@
import type { Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useCanvasStore } from '@/stores/graphStore'
interface NodeManager {
@@ -20,17 +21,13 @@ interface NodeManager {
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const layoutMutations = useLayoutMutations()
/**
* Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd
*/
const handleNodeSelect = (
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
) => {
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
@@ -46,18 +43,16 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
canvasStore.canvas.select(node)
}
} else {
// If it wasn't a drag: single-select the node
if (!wasDragging) {
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
}
// Regular click -> single select
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
}
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) {
bringNodeToFront(nodeData.id)
layoutMutations.setSource(LayoutSource.Vue)
layoutMutations.bringNodeToFront(nodeData.id)
}
// Update canvas selection tracking
@@ -114,7 +109,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
// TODO: add custom double-click behavior here
// For now, ensure node is selected
if (!node.selected) {
handleNodeSelect(event, nodeData, false)
handleNodeSelect(event, nodeData)
}
}
@@ -133,7 +128,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
// Select the node if not already selected
if (!node.selected) {
handleNodeSelect(event, nodeData, false)
handleNodeSelect(event, nodeData)
}
// Let LiteGraph handle the context menu
@@ -158,7 +153,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
metaKey: event.metaKey,
bubbles: true
})
handleNodeSelect(syntheticEvent, nodeData, false)
handleNodeSelect(syntheticEvent, nodeData)
}
// Set drag data for potential drop operations
@@ -176,13 +171,14 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) {
canvasStore.canvas.deselectAll()
canvasStore.canvas.deselectAllNodes()
}
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.select(node)
canvasStore.canvas.selectNode(node)
node.selected = true
}
})

View File

@@ -1,14 +1,16 @@
/**
* Vue Nodes Viewport Culling
* Viewport Culling Composable
*
* Principles:
* 1. Query DOM directly using data attributes (no cache to maintain)
* 2. Set display none on element to avoid cascade resolution overhead
* 3. Only run when transform changes (event driven)
* Handles viewport culling optimization for Vue nodes including:
* - Transform state synchronization
* - Visible node calculation with screen space transforms
* - Adaptive margin computation based on zoom level
* - Performance optimizations for large graphs
*/
import { type Ref, computed } from 'vue'
import { type Ref, computed, readonly, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useTransformState } from '@/renderer/core/layout/useTransformState'
import { app as comfyApp } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
@@ -23,84 +25,188 @@ export function useViewportCulling(
nodeManager: Ref<NodeManager | null>
) {
const canvasStore = useCanvasStore()
const { syncWithCanvas } = useTransformState()
const allNodes = computed(() => {
if (!isVueNodesEnabled.value) return []
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
return Array.from(vueNodeData.value.values())
// Transform tracking for performance optimization
const lastScale = ref(1)
const lastOffsetX = ref(0)
const lastOffsetY = ref(0)
// Current transform state
const currentTransformState = computed(() => ({
scale: lastScale.value,
offsetX: lastOffsetX.value,
offsetY: lastOffsetY.value
}))
/**
* Computed property that returns nodes visible in the current viewport
* Implements sophisticated culling algorithm with adaptive margins
*/
const nodesToRender = computed(() => {
if (!isVueNodesEnabled.value) {
return []
}
// Access trigger to force re-evaluation after nodeManager initialization
void nodeDataTrigger.value
if (!comfyApp.graph) {
return []
}
const allNodes = Array.from(vueNodeData.value.values())
// Apply viewport culling - check if node bounds intersect with viewport
// TODO: use quadtree
if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) {
const canvas = canvasStore.canvas
const manager = nodeManager.value
// Ensure transform is synced before checking visibility
syncWithCanvas(comfyApp.canvas)
const ds = canvas.ds
// Work in screen space - viewport is simply the canvas element size
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
// Add margin that represents a constant distance in canvas space
// Convert canvas units to screen pixels by multiplying by scale
const canvasMarginDistance = 200 // Fixed margin in canvas units
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const filtered = allNodes.filter((nodeData) => {
const node = manager.getNode(nodeData.id)
if (!node) return false
// Transform node position to screen space (same as DOM widgets)
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Check if node bounds intersect with expanded viewport (in screen space)
const isVisible = !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
return isVisible
})
return filtered
}
return allNodes
})
/**
* Update visibility of all nodes based on viewport
* Queries DOM directly - no cache maintenance needed
* Handle transform updates with performance optimization
* Only syncs when transform actually changes to avoid unnecessary reflows
*/
const updateVisibility = () => {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
const handleTransformUpdate = (detectChangesInRAF: () => void) => {
// Skip all work if Vue nodes are disabled
if (!isVueNodesEnabled.value) {
return
}
const canvas = canvasStore.canvas
const manager = nodeManager.value
const ds = canvas.ds
// Sync transform state only when it changes (avoids reflows)
if (comfyApp.canvas?.ds) {
const currentScale = comfyApp.canvas.ds.scale
const currentOffsetX = comfyApp.canvas.ds.offset[0]
const currentOffsetY = comfyApp.canvas.ds.offset[1]
// Viewport bounds
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
const margin = 500 * ds.scale
// Get all node elements at once
const nodeElements = document.querySelectorAll('[data-node-id]')
// Update each element's visibility
for (const element of nodeElements) {
const nodeId = element.getAttribute('data-node-id')
if (!nodeId) continue
const node = manager.getNode(nodeId)
if (!node) continue
// Calculate if node is outside viewport
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
const isNodeOutsideViewport =
screen_x + screen_width < -margin ||
screen_x > viewport_width + margin ||
screen_y + screen_height < -margin ||
screen_y > viewport_height + margin
// Setting display none directly avoid potential cascade resolution
if (element instanceof HTMLElement) {
element.style.display = isNodeOutsideViewport ? 'none' : ''
if (
currentScale !== lastScale.value ||
currentOffsetX !== lastOffsetX.value ||
currentOffsetY !== lastOffsetY.value
) {
syncWithCanvas(comfyApp.canvas)
lastScale.value = currentScale
lastOffsetX.value = currentOffsetX
lastOffsetY.value = currentOffsetY
}
}
// Detect node changes during transform updates
detectChangesInRAF()
// Trigger reactivity for nodesToRender
void nodesToRender.value.length
}
// RAF throttling for smooth updates during continuous panning
let rafId: number | null = null
/**
* Handle transform update - called by TransformPane event
* Uses RAF to batch updates for smooth performance
* Calculate if a specific node is visible in viewport
* Useful for individual node visibility checks
*/
const handleTransformUpdate = () => {
if (!isVueNodesEnabled.value) return
// Cancel previous RAF if still pending
if (rafId !== null) {
cancelAnimationFrame(rafId)
const isNodeVisible = (nodeData: VueNodeData): boolean => {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) {
return true // Default to visible if culling not available
}
// Schedule update in next animation frame
rafId = requestAnimationFrame(() => {
updateVisibility()
rafId = null
})
const canvas = canvasStore.canvas
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return false
syncWithCanvas(comfyApp.canvas)
const ds = canvas.ds
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
const canvasMarginDistance = 200
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
return !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
}
/**
* Get viewport bounds information for debugging
*/
const getViewportInfo = () => {
if (!canvasStore.canvas || !comfyApp.canvas) {
return null
}
const canvas = canvasStore.canvas
const ds = canvas.ds
return {
viewport_width: canvas.canvas.width,
viewport_height: canvas.canvas.height,
scale: ds.scale,
offset: [ds.offset[0], ds.offset[1]],
margin_distance: 200,
margin_x: 200 * ds.scale,
margin_y: 200 * ds.scale
}
}
return {
allNodes,
nodesToRender,
handleTransformUpdate,
updateVisibility
isNodeVisible,
getViewportInfo,
// Transform state
currentTransformState: readonly(currentTransformState),
lastScale: readonly(lastScale),
lastOffsetX: readonly(lastOffsetX),
lastOffsetY: readonly(lastOffsetY)
}
}

View File

@@ -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'],

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import arc from '@/assets/palettes/arc.json' with { type: 'json' }
import dark from '@/assets/palettes/dark.json' with { type: 'json' }
import github from '@/assets/palettes/github.json' with { type: 'json' }
import light from '@/assets/palettes/light.json' with { type: 'json' }
import nord from '@/assets/palettes/nord.json' with { type: 'json' }
import solarized from '@/assets/palettes/solarized.json' with { type: 'json' }
import arc from '@/assets/palettes/arc.json'
import dark from '@/assets/palettes/dark.json'
import github from '@/assets/palettes/github.json'
import light from '@/assets/palettes/light.json'
import nord from '@/assets/palettes/nord.json'
import solarized from '@/assets/palettes/solarized.json'
import type {
ColorPalettes,
CompletedPalette

View File

@@ -972,13 +972,5 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',
type: 'boolean',
tooltip: 'Use new Asset API for model browsing',
defaultValue: false,
experimental: true
}
]

View File

@@ -1,41 +1,41 @@
import { createI18n } from 'vue-i18n'
import arCommands from './locales/ar/commands.json' with { type: 'json' }
import ar from './locales/ar/main.json' with { type: 'json' }
import arNodes from './locales/ar/nodeDefs.json' with { type: 'json' }
import arSettings from './locales/ar/settings.json' with { type: 'json' }
import enCommands from './locales/en/commands.json' with { type: 'json' }
import en from './locales/en/main.json' with { type: 'json' }
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from './locales/en/settings.json' with { type: 'json' }
import esCommands from './locales/es/commands.json' with { type: 'json' }
import es from './locales/es/main.json' with { type: 'json' }
import esNodes from './locales/es/nodeDefs.json' with { type: 'json' }
import esSettings from './locales/es/settings.json' with { type: 'json' }
import frCommands from './locales/fr/commands.json' with { type: 'json' }
import fr from './locales/fr/main.json' with { type: 'json' }
import frNodes from './locales/fr/nodeDefs.json' with { type: 'json' }
import frSettings from './locales/fr/settings.json' with { type: 'json' }
import jaCommands from './locales/ja/commands.json' with { type: 'json' }
import ja from './locales/ja/main.json' with { type: 'json' }
import jaNodes from './locales/ja/nodeDefs.json' with { type: 'json' }
import jaSettings from './locales/ja/settings.json' with { type: 'json' }
import koCommands from './locales/ko/commands.json' with { type: 'json' }
import ko from './locales/ko/main.json' with { type: 'json' }
import koNodes from './locales/ko/nodeDefs.json' with { type: 'json' }
import koSettings from './locales/ko/settings.json' with { type: 'json' }
import ruCommands from './locales/ru/commands.json' with { type: 'json' }
import ru from './locales/ru/main.json' with { type: 'json' }
import ruNodes from './locales/ru/nodeDefs.json' with { type: 'json' }
import ruSettings from './locales/ru/settings.json' with { type: 'json' }
import zhTWCommands from './locales/zh-TW/commands.json' with { type: 'json' }
import zhTW from './locales/zh-TW/main.json' with { type: 'json' }
import zhTWNodes from './locales/zh-TW/nodeDefs.json' with { type: 'json' }
import zhTWSettings from './locales/zh-TW/settings.json' with { type: 'json' }
import zhCommands from './locales/zh/commands.json' with { type: 'json' }
import zh from './locales/zh/main.json' with { type: 'json' }
import zhNodes from './locales/zh/nodeDefs.json' with { type: 'json' }
import zhSettings from './locales/zh/settings.json' with { type: 'json' }
import arCommands from './locales/ar/commands.json'
import ar from './locales/ar/main.json'
import arNodes from './locales/ar/nodeDefs.json'
import arSettings from './locales/ar/settings.json'
import enCommands from './locales/en/commands.json'
import en from './locales/en/main.json'
import enNodes from './locales/en/nodeDefs.json'
import enSettings from './locales/en/settings.json'
import esCommands from './locales/es/commands.json'
import es from './locales/es/main.json'
import esNodes from './locales/es/nodeDefs.json'
import esSettings from './locales/es/settings.json'
import frCommands from './locales/fr/commands.json'
import fr from './locales/fr/main.json'
import frNodes from './locales/fr/nodeDefs.json'
import frSettings from './locales/fr/settings.json'
import jaCommands from './locales/ja/commands.json'
import ja from './locales/ja/main.json'
import jaNodes from './locales/ja/nodeDefs.json'
import jaSettings from './locales/ja/settings.json'
import koCommands from './locales/ko/commands.json'
import ko from './locales/ko/main.json'
import koNodes from './locales/ko/nodeDefs.json'
import koSettings from './locales/ko/settings.json'
import ruCommands from './locales/ru/commands.json'
import ru from './locales/ru/main.json'
import ruNodes from './locales/ru/nodeDefs.json'
import ruSettings from './locales/ru/settings.json'
import zhTWCommands from './locales/zh-TW/commands.json'
import zhTW from './locales/zh-TW/main.json'
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
import zhTWSettings from './locales/zh-TW/settings.json'
import zhCommands from './locales/zh/commands.json'
import zh from './locales/zh/main.json'
import zhNodes from './locales/zh/nodeDefs.json'
import zhSettings from './locales/zh/settings.json'
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
return {

View File

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

View File

@@ -3833,12 +3833,33 @@ export class LGraphNode
? this.getInputPos(slotIndex)
: this.getOutputPos(slotIndex)
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
if (LiteGraph.vueNodesMode) {
// Vue-based slot dimensions
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
if (slot.isWidgetInputSlot) {
// Widget slots have a 20x20 clickable area centered at the position
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - 10
slot.boundingRect[2] = 20
slot.boundingRect[3] = 20
} else {
// Regular slots have a 20x20 clickable area for the connector
// but the full slot height for vertical spacing
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
slot.boundingRect[2] = 20
slot.boundingRect[3] = dimensions.SLOT_HEIGHT
}
} else {
// Traditional LiteGraph dimensions
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
}
#measureSlots(): ReadOnlyRect | null {

View File

@@ -24,6 +24,26 @@ import {
} from './types/globalEnums'
import { createUuidv4 } from './utils/uuid'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*
* IMPORTANT: These values must match the actual rendered dimensions of Vue components
* for the positioning contract to work correctly.
*/
export const COMFY_VUE_NODE_DIMENSIONS = {
spacing: {
BETWEEN_SLOTS_AND_BODY: 8,
BETWEEN_WIDGETS: 8
},
components: {
HEADER_HEIGHT: 34, // 18 header + 16 padding
SLOT_HEIGHT: 24,
STANDARD_WIDGET_HEIGHT: 30
}
} as const
/**
* The Global Scope. It contains all the registered node classes.
*/
@@ -75,6 +95,14 @@ export class LiteGraphGlobal {
WIDGET_SECONDARY_TEXT_COLOR = '#999'
WIDGET_DISABLED_TEXT_COLOR = '#666'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*/
// WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
LINK_COLOR = '#9A9'
EVENT_LINK_COLOR = '#A86'
CONNECTING_LINK_COLOR = '#AFA'

View File

@@ -104,6 +104,7 @@ export { BadgePosition, LGraphBadge } from './LGraphBadge'
export { LGraphCanvas } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'
export { LGraphNode, type NodeId } from './LGraphNode'
export { COMFY_VUE_NODE_DIMENSIONS } from './LiteGraphGlobal'
export { LLink } from './LLink'
export { createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'

View File

@@ -272,7 +272,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
const matchingIndex = this.#getBypassSlotIndex(slot, type)
// No input types match
if (matchingIndex === -1) {
if (matchingIndex === undefined) {
console.debug(
`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`,
this
@@ -331,7 +331,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
* Used when bypassing nodes.
* @param slot The output slot index on this node
* @param type The type of the final target input (so type list matches are accurate)
* @returns The index of the input slot on this node, otherwise `-1`.
* @returns The index of the input slot on this node, otherwise `undefined`.
*/
#getBypassSlotIndex(slot: number, type: ISlotType) {
const { inputs } = this
@@ -352,15 +352,15 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
return slot
}
// Preserve legacy behaviour; use exact match first.
const exactMatch = inputs.findIndex((input) => input.type === type)
if (exactMatch !== -1) return exactMatch
// Find first matching slot - prefer exact type
return inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
return (
// Preserve legacy behaviour; use exact match first.
inputs.findIndex((input) => input.type === type) ??
inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
)
)
}

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