Compare commits
4 Commits
coderabbit
...
fix/queue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c669ffb07b | ||
|
|
9454759012 | ||
|
|
35978e1462 | ||
|
|
be1b87d95d |
61
.cursorrules
Normal file
@@ -0,0 +1,61 @@
|
||||
# Vue 3 Composition API Project Rules
|
||||
|
||||
## Vue 3 Composition API Best Practices
|
||||
- Use setup() function for component logic
|
||||
- Utilize ref and reactive for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Use vue 3.5 style of default prop declaration. Example:
|
||||
|
||||
```typescript
|
||||
const { nodes, showTotal = true } = defineProps<{
|
||||
nodes: ApiNodeCost[]
|
||||
showTotal?: boolean
|
||||
}>()
|
||||
```
|
||||
|
||||
- Organize vue component in <template> <script> <style> order
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
components/
|
||||
constants/
|
||||
composables/
|
||||
views/
|
||||
stores/
|
||||
services/
|
||||
App.vue
|
||||
main.ts
|
||||
```
|
||||
|
||||
## Styling Guidelines
|
||||
- Use Tailwind CSS for styling
|
||||
- Implement responsive design with Tailwind CSS
|
||||
|
||||
## PrimeVue Component Guidelines
|
||||
DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
- Dropdown → Use Select (import from 'primevue/select')
|
||||
- OverlayPanel → Use Popover (import from 'primevue/popover')
|
||||
- Calendar → Use DatePicker (import from 'primevue/datepicker')
|
||||
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
|
||||
- Sidebar → Use Drawer (import from 'primevue/drawer')
|
||||
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
|
||||
- TabMenu → Use Tabs without panels
|
||||
- Steps → Use Stepper without panels
|
||||
- InlineMessage → Use Message component
|
||||
|
||||
## Development Guidelines
|
||||
1. Leverage VueUse functions for performance-enhancing styles
|
||||
2. Use es-toolkit for utility functions
|
||||
3. Use TypeScript for type safety
|
||||
4. Implement proper props and emits definitions
|
||||
5. Utilize Vue 3's Teleport component when needed
|
||||
6. Use Suspense for async components
|
||||
7. Implement proper error handling
|
||||
8. Follow Vue 3 style guide and naming conventions
|
||||
9. Use Vite for fast development and building
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Never use deprecated PrimeVue components listed above
|
||||
17
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -124,19 +124,12 @@ jobs:
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: |
|
||||
# Run tests with blob reporter first
|
||||
pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
|
||||
- name: Generate HTML and JSON reports
|
||||
if: always()
|
||||
run: |
|
||||
# Generate HTML report from blob
|
||||
pnpm exec playwright merge-reports --reporter=html ./blob-report
|
||||
# Generate JSON report separately with explicit output path
|
||||
# Run tests with both HTML and JSON reporters
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm exec playwright merge-reports --reporter=json ./blob-report
|
||||
pnpm exec playwright test --project=${{ matrix.browser }} \
|
||||
--reporter=list \
|
||||
--reporter=html \
|
||||
--reporter=json
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
69
.github/workflows/cloud-backport-tag.yaml
vendored
@@ -1,69 +0,0 @@
|
||||
---
|
||||
name: Cloud Backport Tag
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['closed']
|
||||
branches: [cloud/*]
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'backport')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout merge commit
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Create tag for cloud backport
|
||||
id: tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
if [[ ! "$BRANCH" =~ ^cloud/([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "::error::Base branch '$BRANCH' is not a cloud/x.y branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "$VERSION" =~ ^${MAJOR}\.${MINOR}\.([0-9]+)(-.+)?$ ]]; then
|
||||
PATCH="${BASH_REMATCH[1]}"
|
||||
SUFFIX="${BASH_REMATCH[2]:-}"
|
||||
else
|
||||
echo "::error::Version '${VERSION}' does not match cloud branch '${BRANCH}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="cloud/v${VERSION}"
|
||||
|
||||
if git ls-remote --tags origin "${TAG}" | grep -Fq "refs/tags/${TAG}"; then
|
||||
echo "::notice::Tag ${TAG} already exists; skipping"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${{ github.event.pull_request.merge_commit_sha }}"
|
||||
git push origin "${TAG}"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "Created tag: ${TAG}"
|
||||
echo "Branch: ${BRANCH}"
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Commit: ${{ github.event.pull_request.merge_commit_sha }}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
14
.github/workflows/pr-backport.yaml
vendored
@@ -236,8 +236,8 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
fi
|
||||
|
||||
for target in ${{ steps.filter-targets.outputs.pending-targets }}; do
|
||||
@@ -326,8 +326,8 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
else
|
||||
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
fi
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
@@ -364,9 +364,9 @@ jobs:
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_NUMBER=$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")
|
||||
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
fi
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
|
||||
292
.github/workflows/release-weekly-comfyui.yaml
vendored
@@ -1,292 +0,0 @@
|
||||
# Automated weekly workflow to bump ComfyUI frontend RC releases
|
||||
name: "Release: Weekly ComfyUI"
|
||||
|
||||
on:
|
||||
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
|
||||
schedule:
|
||||
- cron: '0 20 * * 1'
|
||||
|
||||
# Allow manual triggering
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
comfyui_fork:
|
||||
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
|
||||
required: false
|
||||
default: 'Comfy-Org/ComfyUI'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
resolve-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
current_version: ${{ steps.resolve.outputs.current_version }}
|
||||
target_version: ${{ steps.resolve.outputs.target_version }}
|
||||
target_minor: ${{ steps.resolve.outputs.target_minor }}
|
||||
target_branch: ${{ steps.resolve.outputs.target_branch }}
|
||||
needs_release: ${{ steps.resolve.outputs.needs_release }}
|
||||
diff_url: ${{ steps.resolve.outputs.diff_url }}
|
||||
latest_patch_tag: ${{ steps.resolve.outputs.latest_patch_tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: frontend
|
||||
|
||||
- name: Checkout ComfyUI (sparse)
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
sparse-checkout: |
|
||||
requirements.txt
|
||||
path: comfyui
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve release information
|
||||
id: resolve
|
||||
working-directory: frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Run the resolver script
|
||||
if ! RESULT=$(pnpm exec tsx scripts/cicd/resolve-comfyui-release.ts ../comfyui .); then
|
||||
echo "Failed to resolve release information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Resolver output:"
|
||||
echo "$RESULT"
|
||||
|
||||
# Validate JSON output
|
||||
if ! echo "$RESULT" | jq empty 2>/dev/null; then
|
||||
echo "Invalid JSON output from resolver"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse JSON output and set outputs
|
||||
echo "current_version=$(echo "$RESULT" | jq -r '.current_version')" >> $GITHUB_OUTPUT
|
||||
echo "target_version=$(echo "$RESULT" | jq -r '.target_version')" >> $GITHUB_OUTPUT
|
||||
echo "target_minor=$(echo "$RESULT" | jq -r '.target_minor')" >> $GITHUB_OUTPUT
|
||||
echo "target_branch=$(echo "$RESULT" | jq -r '.target_branch')" >> $GITHUB_OUTPUT
|
||||
echo "needs_release=$(echo "$RESULT" | jq -r '.needs_release')" >> $GITHUB_OUTPUT
|
||||
echo "diff_url=$(echo "$RESULT" | jq -r '.diff_url')" >> $GITHUB_OUTPUT
|
||||
echo "latest_patch_tag=$(echo "$RESULT" | jq -r '.latest_patch_tag')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Release Information" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Current version: ${{ steps.resolve.outputs.current_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target version: ${{ steps.resolve.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target branch: ${{ steps.resolve.outputs.target_branch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Needs release: ${{ steps.resolve.outputs.needs_release }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Diff: [${{ steps.resolve.outputs.current_version }}...${{ steps.resolve.outputs.target_version }}](${{ steps.resolve.outputs.diff_url }})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
trigger-release-if-needed:
|
||||
needs: resolve-version
|
||||
if: needs.resolve-version.outputs.needs_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Trigger release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Triggering release workflow for branch ${{ needs.resolve-version.outputs.target_branch }}"
|
||||
|
||||
# Trigger the release-version-bump workflow
|
||||
if ! gh workflow run release-version-bump.yaml \
|
||||
--repo Comfy-Org/ComfyUI_frontend \
|
||||
--ref main \
|
||||
--field version_type=patch \
|
||||
--field branch=${{ needs.resolve-version.outputs.target_branch }}; then
|
||||
echo "Failed to trigger release workflow"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release workflow triggered successfully for ${{ needs.resolve-version.outputs.target_branch }}"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Release Workflow Triggered" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch: ${{ needs.resolve-version.outputs.target_branch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
create-comfyui-pr:
|
||||
needs: [resolve-version, trigger-release-if-needed]
|
||||
if: always() && needs.resolve-version.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout ComfyUI fork
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.comfyui_fork || 'Comfy-Org/ComfyUI' }}
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
path: comfyui
|
||||
|
||||
- name: Sync with upstream
|
||||
working-directory: comfyui
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch latest upstream to base our branch on fresh code
|
||||
# Note: This only affects the local checkout, NOT the fork's master branch
|
||||
# We only push the automation branch, leaving the fork's master untouched
|
||||
echo "Fetching upstream master..."
|
||||
if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
|
||||
echo "Failed to fetch upstream master"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking out upstream master..."
|
||||
if ! git checkout FETCH_HEAD; then
|
||||
echo "Failed to checkout FETCH_HEAD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Successfully synced with upstream master"
|
||||
|
||||
- name: Update requirements.txt
|
||||
working-directory: comfyui
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
echo "Updating comfyui-frontend-package to ${TARGET_VERSION}"
|
||||
|
||||
# Update the comfyui-frontend-package version (POSIX-compatible)
|
||||
sed -i.bak "s/comfyui-frontend-package==[0-9.][0-9.]*/comfyui-frontend-package==${TARGET_VERSION}/" requirements.txt
|
||||
rm requirements.txt.bak
|
||||
|
||||
# Verify the change was made
|
||||
if ! grep -q "comfyui-frontend-package==${TARGET_VERSION}" requirements.txt; then
|
||||
echo "Failed to update requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updated requirements.txt:"
|
||||
grep comfyui-frontend-package requirements.txt
|
||||
|
||||
- name: Build PR description
|
||||
id: pr-body
|
||||
run: |
|
||||
BODY=$(cat <<'EOF'
|
||||
Bumps frontend to ${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
Test quickly:
|
||||
|
||||
```bash
|
||||
python main.py --front-end-version Comfy-Org/ComfyUI_frontend@${{ needs.resolve-version.outputs.target_version }}
|
||||
```
|
||||
|
||||
- Diff: [v${{ needs.resolve-version.outputs.current_version }}...v${{ needs.resolve-version.outputs.target_version }}](${{ needs.resolve-version.outputs.diff_url }})
|
||||
- PyPI: https://pypi.org/project/comfyui-frontend-package/${{ needs.resolve-version.outputs.target_version }}/
|
||||
- npm: https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/${{ needs.resolve-version.outputs.target_version }}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Add release PR note if release was triggered
|
||||
if [ "${{ needs.resolve-version.outputs.needs_release }}" = "true" ]; then
|
||||
RELEASE_NOTE="⚠️ **Release PR must be merged first** - check [release workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)"
|
||||
BODY=$''"${RELEASE_NOTE}"$'\n\n'"${BODY}"
|
||||
fi
|
||||
|
||||
# Save to file for later use
|
||||
printf '%s\n' "$BODY" > pr-body.txt
|
||||
cat pr-body.txt
|
||||
|
||||
- name: Create PR to ComfyUI
|
||||
working-directory: comfyui
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
COMFYUI_FORK: ${{ inputs.comfyui_fork || 'Comfy-Org/ComfyUI' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Extract fork owner from repository name
|
||||
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
|
||||
|
||||
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
|
||||
|
||||
# Configure git
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Create/update branch (reuse same branch name each week)
|
||||
BRANCH="automation/comfyui-frontend-bump"
|
||||
git checkout -B "$BRANCH"
|
||||
git add requirements.txt
|
||||
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}"
|
||||
else
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Force push to fork (overwrites previous week's branch)
|
||||
# Note: This intentionally destroys branch history to maintain a single PR
|
||||
# Any review comments or manual commits will need to be re-applied
|
||||
if ! git push -f origin "$BRANCH"; then
|
||||
echo "Failed to push branch to fork"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create draft PR from fork to upstream
|
||||
PR_BODY=$(cat ../pr-body.txt)
|
||||
|
||||
# Try to create PR, ignore error if it already exists
|
||||
if ! gh pr create \
|
||||
--repo comfyanonymous/ComfyUI \
|
||||
--head "${FORK_OWNER}:${BRANCH}" \
|
||||
--base master \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
--body "$PR_BODY" \
|
||||
--draft 2>&1; then
|
||||
|
||||
# Check if PR already exists
|
||||
set +e
|
||||
EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
PR_LIST_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ $PR_LIST_EXIT -ne 0 ]; then
|
||||
echo "Failed to check for existing PR: $EXISTING_PR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
||||
echo "PR already exists (#${EXISTING_PR}), updating branch will update the PR"
|
||||
else
|
||||
echo "Failed to create PR and no existing PR found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
|
||||
cat pr-body.txt >> $GITHUB_STEP_SUMMARY
|
||||
@@ -9,7 +9,7 @@ module.exports = defineConfig({
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/restrict-template-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
"typescript/no-floating-promises": "error"
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const config: StorybookConfig = {
|
||||
deep: true,
|
||||
extensions: ['vue']
|
||||
})
|
||||
// Note: Explicitly NOT including generateImportMapPlugin to avoid externalization
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true
|
||||
|
||||
232
AGENTS.md
@@ -1,224 +1,38 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source: `src/`
|
||||
- Vue 3.5+
|
||||
- TypeScript
|
||||
- Tailwind 4
|
||||
- Key areas:
|
||||
- `components/`
|
||||
- `views/`
|
||||
- `stores/` (Pinia)
|
||||
- `composables/`
|
||||
- `services/`
|
||||
- `utils/`
|
||||
- `assets/`
|
||||
- `locales/`
|
||||
- Routing: `src/router.ts`,
|
||||
- i18n: `src/i18n.ts`,
|
||||
- Entry Point: `src/main.ts`.
|
||||
- Tests:
|
||||
- unit/component in `tests-ui/` and `src/**/*.test.ts`
|
||||
- E2E (Playwright) in `browser_tests/**/*.spec.ts`
|
||||
- Public assets: `public/`
|
||||
- Build output: `dist/`
|
||||
- Configs
|
||||
- `vite.config.mts`
|
||||
- `vitest.config.ts`
|
||||
- `playwright.config.ts`
|
||||
- `eslint.config.ts`
|
||||
- `.prettierrc`
|
||||
- etc.
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project uses **Nx** for build orchestration and task management:
|
||||
|
||||
- **Task Orchestration**: Commands like `dev`, `build`, `lint`, and `test:browser` run via Nx
|
||||
- **Caching**: Nx provides intelligent caching for faster rebuilds
|
||||
- **Configuration**: Managed through `nx.json` with plugins for ESLint, Storybook, Vite, and Playwright
|
||||
- **Dependencies**: Nx handles dependency graph analysis and parallel execution
|
||||
|
||||
Key Nx features:
|
||||
|
||||
- Build target caching and incremental builds
|
||||
- Parallel task execution across the monorepo
|
||||
- Plugin-based architecture for different tools
|
||||
- Source: `src/` (Vue 3 + TypeScript). Key areas: `components/`, `views/`, `stores/` (Pinia), `composables/`, `services/`, `utils/`, `assets/`, `locales/`.
|
||||
- Routing/i18n/entry: `src/router.ts`, `src/i18n.ts`, `src/main.ts`.
|
||||
- Tests: unit/component in `tests-ui/` and `src/components/**/*.{test,spec}.ts`; E2E in `browser_tests/`.
|
||||
- Public assets: `public/`. Build output: `dist/`.
|
||||
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.ts`, `.prettierrc`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
- `pnpm dev:electron`: Dev server with Electron API mocks
|
||||
- `pnpm build`: Type-check then production build to `dist/`
|
||||
- `pnpm preview`: Preview the production build locally
|
||||
- `pnpm test:unit`: Run Vitest unit tests
|
||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm format` / `pnpm format:check`: Prettier
|
||||
- `pnpm typecheck`: Vue TSC type checking
|
||||
- `pnpm dev:electron`: Dev server with Electron API mocks.
|
||||
- `pnpm build`: Type-check then production build to `dist/`.
|
||||
- `pnpm preview`: Preview the production build locally.
|
||||
- `pnpm test:unit`: Run Vitest unit tests.
|
||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`).
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint). `pnpm format` / `format:check`: Prettier.
|
||||
- `pnpm typecheck`: Vue TSC type checking.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Language:
|
||||
- TypeScript (exclusive, no new JavaScript)
|
||||
- Vue 3 SFCs (`.vue`)
|
||||
- Composition API only
|
||||
- Tailwind 4 styling
|
||||
- Avoid `<style>` blocks
|
||||
- Style: (see `.prettierrc`)
|
||||
- Indent 2 spaces
|
||||
- single quotes
|
||||
- no trailing semicolons
|
||||
- width 80
|
||||
- Imports:
|
||||
- sorted/grouped by plugin
|
||||
- run `pnpm format` before committing
|
||||
- ESLint:
|
||||
- Vue + TS rules
|
||||
- no floating promises
|
||||
- unused imports disallowed
|
||||
- i18n raw text restrictions in templates
|
||||
- Naming:
|
||||
- Vue components in PascalCase (e.g., `MenuHamburger.vue`)
|
||||
- composables `useXyz.ts`
|
||||
- Pinia stores `*Store.ts`
|
||||
- Language: TypeScript, Vue SFCs (`.vue`). Indent 2 spaces; single quotes; no semicolons; width 80 (see `.prettierrc`).
|
||||
- Imports: sorted/grouped by plugin; run `pnpm format` before committing.
|
||||
- ESLint: Vue + TS rules; no floating promises; unused imports disallowed; i18n raw text restrictions in templates.
|
||||
- Naming: Vue components in PascalCase (e.g., `MenuHamburger.vue`); composables `useXyz.ts`; Pinia stores `*Store.ts`.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
- Test files:
|
||||
- Unit/Component: `**/*.test.ts`
|
||||
- E2E: `browser_tests/**/*.spec.ts`
|
||||
- Litegraph Specific: `src/lib/litegraph/test/`
|
||||
- Coverage: text/json/html reporters enabled
|
||||
- aim to cover critical logic and new features
|
||||
- Playwright:
|
||||
- optional tags like `@mobile`, `@2x` are respected by config
|
||||
- Tests to avoid
|
||||
- Change detector tests
|
||||
- e.g. a test that just asserts that the defaults are certain values
|
||||
- Tests that are dependent on non-behavioral features like utility classes or styles
|
||||
- Redundant tests
|
||||
- Frameworks: Vitest (unit/component, happy-dom) and Playwright (E2E).
|
||||
- Test files: `**/*.{test,spec}.{ts,tsx,js}` under `tests-ui/`, `src/components/`, and `src/lib/litegraph/test/`.
|
||||
- Coverage: text/json/html reporters enabled; aim to cover critical logic and new features.
|
||||
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- PRs:
|
||||
- Include clear description
|
||||
- Reference linked issues (e.g. `- Fixes #123`)
|
||||
- Keep it extremely concise and information-dense
|
||||
- Don't use emojis or add excessive headers/sections
|
||||
- Follow the PR description template in the `.github/` folder.
|
||||
- Quality gates:
|
||||
- `pnpm lint`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm knip`
|
||||
- Relevant tests must pass
|
||||
- Never use `--no-verify` to bypass failing tests
|
||||
- Identify the issue and present root cause analysis and possible solutions if you are unable to solve quickly yourself
|
||||
- Keep PRs focused and small
|
||||
- If it looks like the current changes will have 300+ lines of non-test code, suggest ways it could be broken into multiple PRs
|
||||
- Commits: Use `[skip ci]` for locale-only updates when appropriate.
|
||||
- PRs: Include clear description, linked issues (`- Fixes #123`), and screenshots/GIFs for UI changes.
|
||||
- Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
|
||||
|
||||
## Vue 3 Composition API Best Practices
|
||||
|
||||
- Use `<script setup lang="ts">` for component logic
|
||||
- Utilize `ref` for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Avoid using a `ref` and a `watch` if a `computed` would work instead
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Do not use dependency injection if a Store or a shared composable would be simpler
|
||||
- Use Vue 3.5 TypeScript style of default prop declaration
|
||||
- Example:
|
||||
|
||||
```typescript
|
||||
const { nodes, showTotal = true } = defineProps<{
|
||||
nodes: ApiNodeCost[]
|
||||
showTotal?: boolean
|
||||
}>()
|
||||
```
|
||||
|
||||
- Do not use `withDefaults` or runtime props declaration
|
||||
- Do not import Vue macros unnecessarily
|
||||
- Prefer `useModel` to separately defining a prop and emit
|
||||
- Be judicious with addition of new refs or other state
|
||||
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
||||
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
||||
- If it's possible to use a `computed` to name and reuse a derived value, don't use a `watch`
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
1. Leverage VueUse functions for performance-enhancing styles
|
||||
2. Use es-toolkit for utility functions
|
||||
3. Use TypeScript for type safety
|
||||
4. Implement proper props and emits definitions
|
||||
5. Utilize Vue 3's Teleport component when needed
|
||||
6. Use Suspense for async components
|
||||
7. Implement proper error handling
|
||||
8. Follow Vue 3 style guide and naming conventions
|
||||
9. Use Vite for fast development and building
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Avoid new usage of PrimeVue components
|
||||
12. Write tests for all changes, especially bug fixes to catch future regressions
|
||||
13. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
|
||||
14. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
|
||||
15. Refactoring should be used to make complex code simpler
|
||||
|
||||
## External Resources
|
||||
|
||||
- Vue: <https://vuejs.org/api/>
|
||||
- Tailwind: <https://tailwindcss.com/docs/styling-with-utility-classes>
|
||||
- shadcn/vue: <https://www.shadcn-vue.com/>
|
||||
- Reka UI: <https://reka-ui.com/>
|
||||
- PrimeVue: <https://primevue.org>
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
- Follow good software engineering principles
|
||||
- YAGNI
|
||||
- AHA
|
||||
- DRY
|
||||
- SOLID
|
||||
- Clean, stable public APIs
|
||||
- Domain-driven design
|
||||
- Thousands of users and extensions
|
||||
- Prioritize clean interfaces that restrict extension access
|
||||
|
||||
## Repository Navigation
|
||||
|
||||
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
|
||||
- Prefer running single tests for performance
|
||||
- Use --help for unfamiliar CLI tools
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
When referencing Comfy-Org repos:
|
||||
|
||||
1. Check for local copy
|
||||
2. Use GitHub API for branches/PRs/metadata
|
||||
3. Curl GitHub website if needed
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- NEVER use `any` type - use proper TypeScript types
|
||||
- NEVER use `as any` type assertions - fix the underlying type issue
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
- NEVER use `:class="[]"` to merge class names
|
||||
- Always use `import { cn } from '@/utils/tailwindUtil'`
|
||||
- e.g. `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
|
||||
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
|
||||
|
||||
120
CLAUDE.md
@@ -1,19 +1,44 @@
|
||||
# Claude Code specific instructions
|
||||
|
||||
@Agents.md
|
||||
# ComfyUI Frontend Project Guidelines
|
||||
|
||||
## Repository Setup
|
||||
|
||||
For first-time setup, use the Claude command:
|
||||
|
||||
```sh
|
||||
```
|
||||
/setup_repo
|
||||
```
|
||||
|
||||
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
|
||||
|
||||
**Prerequisites:** Node.js >= 24, Git repository, available ports (5173, 6006)
|
||||
|
||||
## Quick Commands
|
||||
|
||||
- `pnpm`: See all available commands
|
||||
- `pnpm dev`: Start development server (port 5173, via nx)
|
||||
- `pnpm typecheck`: Type checking
|
||||
- `pnpm build`: Build for production (via nx)
|
||||
- `pnpm lint`: Linting (via nx)
|
||||
- `pnpm oxlint`: Fast Rust-based linting with Oxc
|
||||
- `pnpm format`: Prettier formatting
|
||||
- `pnpm test:unit`: Run all unit tests
|
||||
- `pnpm test:browser`: Run E2E tests via Playwright
|
||||
- `pnpm test:unit -- tests-ui/tests/example.test.ts`: Run single test file
|
||||
- `pnpm storybook`: Start Storybook development server (port 6006)
|
||||
- `pnpm knip`: Detect unused code and dependencies
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project now uses **Nx** for build orchestration and task management:
|
||||
|
||||
- **Task Orchestration**: Commands like `dev`, `build`, `lint`, and `test:browser` run via Nx
|
||||
- **Caching**: Nx provides intelligent caching for faster rebuilds
|
||||
- **Configuration**: Managed through `nx.json` with plugins for ESLint, Storybook, Vite, and Playwright
|
||||
- **Dependencies**: Nx handles dependency graph analysis and parallel execution
|
||||
|
||||
Key Nx features:
|
||||
- Build target caching and incremental builds
|
||||
- Parallel task execution across the monorepo
|
||||
- Plugin-based architecture for different tools
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **First-time setup**: Run `/setup_repo` Claude command
|
||||
@@ -25,6 +50,87 @@ This bootstraps the monorepo with dependencies, builds, tests, and dev server ve
|
||||
|
||||
## Git Conventions
|
||||
|
||||
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
||||
- Use [prefix] format: [feat], [bugfix], [docs]
|
||||
- Add "Fixes #n" to PR descriptions
|
||||
- Never mention Claude/AI in commits
|
||||
|
||||
## External Resources
|
||||
|
||||
- PrimeVue docs: <https://primevue.org>
|
||||
- ComfyUI docs: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
- Follow good software engineering principles
|
||||
- YAGNI
|
||||
- AHA
|
||||
- DRY
|
||||
- SOLID
|
||||
- Clean, stable public APIs
|
||||
- Domain-driven design
|
||||
- Thousands of users and extensions
|
||||
- Prioritize clean interfaces that restrict extension access
|
||||
|
||||
## Repository Navigation
|
||||
|
||||
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
|
||||
- Prefer running single tests for performance
|
||||
- Use --help for unfamiliar CLI tools
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
When referencing Comfy-Org repos:
|
||||
|
||||
1. Check for local copy
|
||||
2. Use GitHub API for branches/PRs/metadata
|
||||
3. Curl GitHub website if needed
|
||||
|
||||
## Settings and Feature Flags Quick Reference
|
||||
|
||||
### Settings Usage
|
||||
```typescript
|
||||
const settingStore = useSettingStore()
|
||||
const value = settingStore.get('Comfy.SomeSetting') // Get setting
|
||||
await settingStore.set('Comfy.SomeSetting', newValue) // Update setting
|
||||
```
|
||||
|
||||
### Dynamic Defaults
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Example.Setting',
|
||||
defaultValue: () => window.innerWidth < 1024 ? 'small' : 'large' // Runtime context
|
||||
}
|
||||
```
|
||||
|
||||
### Version-Based Defaults
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Example.Feature',
|
||||
defaultValue: 'legacy',
|
||||
defaultsByInstallVersion: { '1.25.0': 'enhanced' } // Gradual rollout
|
||||
}
|
||||
```
|
||||
|
||||
### Feature Flags
|
||||
```typescript
|
||||
if (api.serverSupportsFeature('feature_name')) { // Check capability
|
||||
// Use enhanced feature
|
||||
}
|
||||
const value = api.getServerFeature('config_name', defaultValue) // Get config
|
||||
```
|
||||
|
||||
**Documentation:**
|
||||
- Settings system: `docs/SETTINGS.md`
|
||||
- Feature flags system: `docs/FEATURE_FLAGS.md`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- NEVER use `any` type - use proper TypeScript types
|
||||
- NEVER use `as any` type assertions - fix the underlying type issue
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
|
||||
- NEVER use `:class="[]"` to merge class names - always use `import { cn } from '@/utils/tailwindUtil'`, for example: `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
|
||||
|
||||
21
CODEOWNERS
@@ -1,11 +1,8 @@
|
||||
# Global Ownership
|
||||
* @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
/apps/desktop-ui/ @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
@@ -34,7 +31,10 @@
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
/src/renderer/extensions/minimap/ @jtydhr88
|
||||
|
||||
# Assets
|
||||
/src/platform/assets/ @arjansingh
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
@@ -53,12 +53,11 @@
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
**/AGENTS.md
|
||||
**/CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/desktop-ui",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"tags": [
|
||||
|
||||
@@ -59,8 +59,7 @@ const LOCALES = [
|
||||
['fr', 'Français'],
|
||||
['es', 'Español'],
|
||||
['ar', 'عربي'],
|
||||
['tr', 'Türkçe'],
|
||||
['pt-BR', 'Português (BR)']
|
||||
['tr', 'Türkçe']
|
||||
] as const satisfies ReadonlyArray<[string, string]>
|
||||
|
||||
type SupportedLocale = (typeof LOCALES)[number][0]
|
||||
|
||||
@@ -40,8 +40,7 @@ const localeLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/main.json'),
|
||||
tr: () => import('@frontend-locales/tr/main.json'),
|
||||
zh: () => import('@frontend-locales/zh/main.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/main.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/main.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/main.json')
|
||||
}
|
||||
|
||||
const nodeDefsLoaders: Record<
|
||||
@@ -56,8 +55,7 @@ const nodeDefsLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/nodeDefs.json'),
|
||||
tr: () => import('@frontend-locales/tr/nodeDefs.json'),
|
||||
zh: () => import('@frontend-locales/zh/nodeDefs.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/nodeDefs.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json')
|
||||
}
|
||||
|
||||
const commandsLoaders: Record<
|
||||
@@ -72,8 +70,7 @@ const commandsLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/commands.json'),
|
||||
tr: () => import('@frontend-locales/tr/commands.json'),
|
||||
zh: () => import('@frontend-locales/zh/commands.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/commands.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json')
|
||||
}
|
||||
|
||||
const settingsLoaders: Record<
|
||||
@@ -88,8 +85,7 @@ const settingsLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/settings.json'),
|
||||
tr: () => import('@frontend-locales/tr/settings.json'),
|
||||
zh: () => import('@frontend-locales/zh/settings.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/settings.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json')
|
||||
}
|
||||
|
||||
// Track which locales have been loaded
|
||||
|
||||
@@ -1651,10 +1651,7 @@ export const comfyPageFixture = base.extend<{
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true
|
||||
'Comfy.VueNodes.AutoScaleLayout': false
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
@@ -94,7 +94,7 @@ async function connectSlots(
|
||||
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
|
||||
const toLoc = slotLocator(page, to.nodeId, to.index, true)
|
||||
await expectVisibleAll(fromLoc, toLoc)
|
||||
await fromLoc.dragTo(toLoc, { force: true })
|
||||
await fromLoc.dragTo(toLoc)
|
||||
await nextFrame()
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
@@ -210,7 +210,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
@@ -828,55 +828,55 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
})
|
||||
|
||||
test.describe('Release actions (Shift-drop)', () => {
|
||||
test.fixme(
|
||||
'Context menu opens and endpoint is pinned on Shift-drop',
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
)
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
|
||||
comfyPage,
|
||||
@@ -897,7 +897,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 100 }
|
||||
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Resizing', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should resize node without position drift after selecting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get a Vue node fixture
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const initialBox = await node.boundingBox()
|
||||
if (!initialBox) throw new Error('Node bounding box not found')
|
||||
|
||||
// Select the node first (this was causing the bug)
|
||||
await node.header.click()
|
||||
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
|
||||
|
||||
// Get position after selection
|
||||
const selectedBox = await node.boundingBox()
|
||||
if (!selectedBox)
|
||||
throw new Error('Node bounding box not found after select')
|
||||
|
||||
// Verify position unchanged after selection
|
||||
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Now resize from bottom-right corner
|
||||
const resizeStartX = selectedBox.x + selectedBox.width - 5
|
||||
const resizeStartY = selectedBox.y + selectedBox.height - 5
|
||||
|
||||
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// Get final position and size
|
||||
const finalBox = await node.boundingBox()
|
||||
if (!finalBox) throw new Error('Node bounding box not found after resize')
|
||||
|
||||
// Position should NOT have changed (the bug was position drift)
|
||||
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Size should have increased
|
||||
expect(finalBox.width).toBeGreaterThan(initialBox.width)
|
||||
expect(finalBox.height).toBeGreaterThan(initialBox.height)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
49
browser_tests/tests/vueNodes/nodeStates/lod.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 81 KiB |
@@ -88,14 +88,12 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
|
||||
if (result.exports.length > 0) {
|
||||
const projectRoot = process.cwd()
|
||||
const relativePath = path
|
||||
.relative(path.join(projectRoot, 'src'), id)
|
||||
.replace(/\\/g, '/')
|
||||
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
|
||||
const shimFileName = relativePath.replace(/\.ts$/, '.js')
|
||||
|
||||
let shimContent = `// Shim for ${relativePath}\n`
|
||||
|
||||
const fileKey = relativePath.replace(/\.ts$/, '')
|
||||
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
|
||||
const warningMessage = getWarningMessage(fileKey, shimFileName)
|
||||
|
||||
if (warningMessage) {
|
||||
|
||||
154
build/plugins/generateImportMapPlugin.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
|
||||
|
||||
interface ImportMapSource {
|
||||
name: string
|
||||
pattern: string | RegExp
|
||||
entry: string
|
||||
recursiveDependence?: boolean
|
||||
override?: Record<string, Partial<ImportMapSource>>
|
||||
}
|
||||
|
||||
const parseDeps = (root: string, pkg: string) => {
|
||||
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const content = fs.readFileSync(pkgPath, 'utf-8')
|
||||
const pkg = JSON.parse(content)
|
||||
return Object.keys(pkg.dependencies || {})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that generates an import map for vendor chunks.
|
||||
*
|
||||
* This plugin creates a browser-compatible import map that maps module specifiers
|
||||
* (like 'vue' or 'primevue') to their actual file locations in the build output.
|
||||
* This improves module loading in modern browsers and enables better caching.
|
||||
*
|
||||
* The plugin:
|
||||
* 1. Tracks vendor chunks during bundle generation
|
||||
* 2. Creates mappings between module names and their file paths
|
||||
* 3. Injects an import map script tag into the HTML head
|
||||
* 4. Configures manual chunk splitting for vendor libraries
|
||||
*
|
||||
* @param vendorLibraries - An array of vendor libraries to split into separate chunks
|
||||
* @returns {Plugin} A Vite plugin that generates and injects an import map
|
||||
*/
|
||||
export function generateImportMapPlugin(
|
||||
importMapSources: ImportMapSource[]
|
||||
): Plugin {
|
||||
const importMapEntries: Record<string, string> = {}
|
||||
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
|
||||
const assetDir = 'assets/lib'
|
||||
let root: string
|
||||
|
||||
return {
|
||||
name: 'generate-import-map-plugin',
|
||||
|
||||
// Configure manual chunks during the build process
|
||||
configResolved(config) {
|
||||
root = config.root
|
||||
|
||||
if (config.build) {
|
||||
// Ensure rollupOptions exists
|
||||
if (!config.build.rollupOptions) {
|
||||
config.build.rollupOptions = {}
|
||||
}
|
||||
|
||||
for (const source of importMapSources) {
|
||||
resolvedImportMapSources.set(source.name, source)
|
||||
if (source.recursiveDependence) {
|
||||
const deps = parseDeps(root, source.name)
|
||||
|
||||
while (deps.length) {
|
||||
const dep = deps.shift()!
|
||||
const depSource = Object.assign({}, source, {
|
||||
name: dep,
|
||||
pattern: dep,
|
||||
...source.override?.[dep]
|
||||
})
|
||||
resolvedImportMapSources.set(depSource.name, depSource)
|
||||
|
||||
const _deps = parseDeps(root, depSource.name)
|
||||
deps.unshift(..._deps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const external: (string | RegExp)[] = []
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
external.push(source.pattern)
|
||||
}
|
||||
config.build.rollupOptions.external = external
|
||||
}
|
||||
},
|
||||
|
||||
generateBundle(_options) {
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
if (source.entry) {
|
||||
const moduleFile = join(source.name, source.entry)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
|
||||
importMapEntries[source.name] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
}
|
||||
|
||||
if (source.recursiveDependence) {
|
||||
const files = glob.sync(['**/*.{js,mjs}'], {
|
||||
cwd: join(root, 'node_modules', source.name)
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
const moduleFile = join(source.name, file)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
|
||||
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml(html) {
|
||||
if (Object.keys(importMapEntries).length === 0) {
|
||||
console.warn(
|
||||
'[ImportMap Plugin] No vendor chunks found to create import map.'
|
||||
)
|
||||
return html
|
||||
}
|
||||
|
||||
const importMap = {
|
||||
imports: importMapEntries
|
||||
}
|
||||
|
||||
const importMapTag: HtmlTagDescriptor = {
|
||||
tag: 'script',
|
||||
attrs: { type: 'importmap' },
|
||||
children: JSON.stringify(importMap, null, 2),
|
||||
injectTo: 'head'
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
tags: [importMapTag]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
@@ -191,19 +191,6 @@ export default defineConfig([
|
||||
'@intlify/vue-i18n/no-raw-text': [
|
||||
'error',
|
||||
{
|
||||
attributes: {
|
||||
'/.+/': [
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-roledescription',
|
||||
'aria-valuetext',
|
||||
'label',
|
||||
'placeholder',
|
||||
'title',
|
||||
'v-tooltip'
|
||||
],
|
||||
img: ['alt']
|
||||
},
|
||||
// Ignore strings that are:
|
||||
// 1. Less than 2 characters
|
||||
// 2. Only symbols/numbers/whitespace (no letters)
|
||||
@@ -213,27 +200,24 @@ export default defineConfig([
|
||||
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
|
||||
// Brand names and technical terms that shouldn't be translated
|
||||
ignoreText: [
|
||||
'API',
|
||||
'App Data:',
|
||||
'App Path:',
|
||||
'ComfyUI',
|
||||
'CPU',
|
||||
'fps',
|
||||
'GB',
|
||||
'GitHub',
|
||||
'GPU',
|
||||
'JSON',
|
||||
'KB',
|
||||
'LoRA',
|
||||
'MB',
|
||||
'ms',
|
||||
'OpenAI',
|
||||
'png',
|
||||
'px',
|
||||
'RAM',
|
||||
'API',
|
||||
'URL',
|
||||
'JSON',
|
||||
'YAML',
|
||||
'1.2 MB'
|
||||
'GPU',
|
||||
'CPU',
|
||||
'RAM',
|
||||
'GB',
|
||||
'MB',
|
||||
'KB',
|
||||
'ms',
|
||||
'fps',
|
||||
'px',
|
||||
'App Data:',
|
||||
'App Path:'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -27,7 +27,7 @@ const config: KnipConfig = {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3', 'gh'],
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify/json',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.34.6",
|
||||
"version": "1.33.8",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -164,6 +164,7 @@
|
||||
"es-toolkit": "^1.39.9",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"fast-glob": "^3.3.3",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-header-surface: var(--color-white);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -1190,21 +1190,26 @@ dialog::backdrop {
|
||||
.litegraph.litecontextmenu,
|
||||
.litegraph.litecontextmenu.dark {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
}
|
||||
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-entry.submenu,
|
||||
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
.litegraph.litecontextmenu
|
||||
.litemenu-entry:hover:not(.disabled):not(.separator) {
|
||||
background-color: var(--comfy-menu-hover-bg, var(--border-color)) !important;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu input {
|
||||
background-color: var(--comfy-input-bg);
|
||||
.litegraph.litecontextmenu .litemenu-entry.submenu,
|
||||
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu input {
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
color: var(--input-text) !important;
|
||||
}
|
||||
|
||||
.comfy-context-menu-filter {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #999;
|
||||
@@ -1243,14 +1248,14 @@ dialog::backdrop {
|
||||
|
||||
.litegraph.litesearchbox {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox input,
|
||||
.litegraph.litesearchbox select {
|
||||
background-color: var(--comfy-input-bg);
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
@@ -1315,6 +1320,66 @@ audio.comfy-audio.empty-audio-widget {
|
||||
font-size 0.1s ease;
|
||||
}
|
||||
|
||||
/* Performance optimization during canvas interaction */
|
||||
.transform-pane--interacting .lg-node * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.transform-pane--interacting .lg-node {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
2713
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -75,17 +75,6 @@ export function formatSize(value?: number) {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a commit hash by truncating long (40-char) hashes to 7 chars.
|
||||
* Returns the original string if not a valid full commit hash.
|
||||
*/
|
||||
export function formatCommitHash(value: string): string {
|
||||
if (/^[a-f0-9]{40}$/i.test(value)) {
|
||||
return value.slice(0, 7)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns various filename components.
|
||||
* Example:
|
||||
|
||||
17
pnpm-lock.yaml
generated
@@ -15,9 +15,15 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.35.0
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.178
|
||||
version: 1.2.66
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.1.3
|
||||
version: 1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
@@ -425,6 +431,9 @@ importers:
|
||||
extendable-media-recorder-wav-encoder:
|
||||
specifier: ^7.0.129
|
||||
version: 7.0.129
|
||||
fast-glob:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
firebase:
|
||||
specifier: 'catalog:'
|
||||
version: 11.6.0
|
||||
@@ -7868,8 +7877,8 @@ packages:
|
||||
vue-component-type-helpers@3.1.1:
|
||||
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
|
||||
|
||||
vue-component-type-helpers@3.1.5:
|
||||
resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==}
|
||||
vue-component-type-helpers@3.1.4:
|
||||
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -10672,7 +10681,7 @@ snapshots:
|
||||
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.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.1.5
|
||||
vue-component-type-helpers: 3.1.4
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -16439,7 +16448,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.1.1: {}
|
||||
|
||||
vue-component-type-helpers@3.1.5: {}
|
||||
vue-component-type-helpers@3.1.4: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
Node usage data merged from two sources:
|
||||
- Mixpanel "app:node_search_result_selected" events (Nov 2025): 1,112 nodes, 46,514 selections
|
||||
Reflects actual user search behavior - what users choose when searching.
|
||||
- OpenArt workflow data (Sept 2024): 2,600 nodes, 118,676 uses
|
||||
Reflects overall popularity - what's used in workflows.
|
||||
|
||||
Merge strategy: New data overwrites old for 514 overlapping nodes. Old data
|
||||
normalized by 2.55x scale factor to match new data. Total: 3,198 nodes.
|
||||
Search-selected nodes prioritized in ranking.
|
||||
Thanks to OpenArt (https://openart.ai) for providing the sorted-custom-node-map data, captured in September 2024.
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { execSync } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
interface ReleaseInfo {
|
||||
current_version: string
|
||||
target_minor: number
|
||||
target_version: string
|
||||
target_branch: string
|
||||
needs_release: boolean
|
||||
latest_patch_tag: string | null
|
||||
branch_head_sha: string | null
|
||||
tag_commit_sha: string | null
|
||||
diff_url: string
|
||||
release_pr_url: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout
|
||||
*/
|
||||
function exec(command: string, cwd?: string): string {
|
||||
try {
|
||||
return execSync(command, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const cwdInfo = cwd ? ` in directory: ${cwd}` : ''
|
||||
console.error(
|
||||
`Command failed: ${command}${cwdInfo}\nError: ${errorMessage}`
|
||||
)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version from requirements.txt
|
||||
* Handles formats: comfyui-frontend-package==1.2.3, comfyui-frontend-package>=1.2.3, etc.
|
||||
*/
|
||||
function parseRequirementsVersion(requirementsPath: string): string | null {
|
||||
if (!fs.existsSync(requirementsPath)) {
|
||||
console.error(`Requirements file not found: ${requirementsPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(requirementsPath, 'utf-8')
|
||||
const match = content.match(
|
||||
/comfyui-frontend-package\s*(?:==|>=|<=|~=|>|<)\s*([0-9]+\.[0-9]+\.[0-9]+)/
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
console.error(
|
||||
'Could not find comfyui-frontend-package version in requirements.txt'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate semantic version string
|
||||
*/
|
||||
function isValidSemver(version: string): boolean {
|
||||
if (!version || typeof version !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = version.split('.')
|
||||
if (parts.length !== 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
return parts.every((part) => {
|
||||
const num = Number(part)
|
||||
return Number.isFinite(num) && num >= 0 && String(num) === part
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest patch tag for a given minor version
|
||||
*/
|
||||
function getLatestPatchTag(repoPath: string, minor: number): string | null {
|
||||
// Fetch all tags
|
||||
exec('git fetch --tags', repoPath)
|
||||
|
||||
// Use git's native version sorting to get the latest tag
|
||||
const latestTag = exec(
|
||||
`git tag -l 'v1.${minor}.*' --sort=-version:refname | head -n 1`,
|
||||
repoPath
|
||||
)
|
||||
|
||||
if (!latestTag) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate the tag is a valid semver (vX.Y.Z format)
|
||||
const validTagRegex = /^v\d+\.\d+\.\d+$/
|
||||
if (!validTagRegex.test(latestTag)) {
|
||||
console.error(
|
||||
`Latest tag for minor version ${minor} is not valid semver: ${latestTag}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return latestTag
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ComfyUI release information
|
||||
*/
|
||||
function resolveRelease(
|
||||
comfyuiRepoPath: string,
|
||||
frontendRepoPath: string
|
||||
): ReleaseInfo | null {
|
||||
// Parse current version from ComfyUI requirements.txt
|
||||
const requirementsPath = path.join(comfyuiRepoPath, 'requirements.txt')
|
||||
const currentVersion = parseRequirementsVersion(requirementsPath)
|
||||
|
||||
if (!currentVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if (!isValidSemver(currentVersion)) {
|
||||
console.error(
|
||||
`Invalid semantic version format: ${currentVersion}. Expected format: X.Y.Z`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
|
||||
|
||||
// Calculate target minor version (next minor)
|
||||
const targetMinor = currentMinor + 1
|
||||
const targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
// Check if target branch exists in frontend repo
|
||||
exec('git fetch origin', frontendRepoPath)
|
||||
const branchExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!branchExists) {
|
||||
console.error(
|
||||
`Target branch ${targetBranch} does not exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Get latest patch tag for target minor
|
||||
const latestPatchTag = getLatestPatchTag(frontendRepoPath, targetMinor)
|
||||
|
||||
let needsRelease = false
|
||||
let branchHeadSha: string | null = null
|
||||
let tagCommitSha: string | null = null
|
||||
let targetVersion = currentVersion
|
||||
|
||||
if (latestPatchTag) {
|
||||
// Get commit SHA for the tag
|
||||
tagCommitSha = exec(`git rev-list -n 1 ${latestPatchTag}`, frontendRepoPath)
|
||||
|
||||
// Get commit SHA for branch head
|
||||
branchHeadSha = exec(
|
||||
`git rev-parse origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
// Check if there are commits between tag and branch head
|
||||
const commitsBetween = exec(
|
||||
`git rev-list ${latestPatchTag}..origin/${targetBranch} --count`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
const commitCount = parseInt(commitsBetween, 10)
|
||||
needsRelease = !isNaN(commitCount) && commitCount > 0
|
||||
|
||||
// Parse existing patch number and increment if needed
|
||||
const tagVersion = latestPatchTag.replace('v', '')
|
||||
|
||||
// Validate tag version format
|
||||
if (!isValidSemver(tagVersion)) {
|
||||
console.error(
|
||||
`Invalid tag version format: ${tagVersion}. Expected format: X.Y.Z`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const [, , existingPatch] = tagVersion.split('.').map(Number)
|
||||
|
||||
// Validate existingPatch is a valid number
|
||||
if (!Number.isFinite(existingPatch) || existingPatch < 0) {
|
||||
console.error(`Invalid patch number in tag: ${existingPatch}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (needsRelease) {
|
||||
targetVersion = `1.${targetMinor}.${existingPatch + 1}`
|
||||
} else {
|
||||
targetVersion = tagVersion
|
||||
}
|
||||
} else {
|
||||
// No tags exist for this minor version, need to create v1.{targetMinor}.0
|
||||
needsRelease = true
|
||||
targetVersion = `1.${targetMinor}.0`
|
||||
branchHeadSha = exec(
|
||||
`git rev-parse origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
}
|
||||
|
||||
const diffUrl = `https://github.com/Comfy-Org/ComfyUI_frontend/compare/v${currentVersion}...v${targetVersion}`
|
||||
|
||||
return {
|
||||
current_version: currentVersion,
|
||||
target_minor: targetMinor,
|
||||
target_version: targetVersion,
|
||||
target_branch: targetBranch,
|
||||
needs_release: needsRelease,
|
||||
latest_patch_tag: latestPatchTag,
|
||||
branch_head_sha: branchHeadSha,
|
||||
tag_commit_sha: tagCommitSha,
|
||||
diff_url: diffUrl,
|
||||
release_pr_url: null // Will be populated by workflow if release is triggered
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const comfyuiRepoPath = process.argv[2]
|
||||
const frontendRepoPath = process.argv[3] || process.cwd()
|
||||
|
||||
if (!comfyuiRepoPath) {
|
||||
console.error(
|
||||
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
|
||||
|
||||
if (!releaseInfo) {
|
||||
console.error('Failed to resolve release information')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
@@ -1,8 +1,5 @@
|
||||
import * as fs from 'fs'
|
||||
|
||||
// Import Vite define shim to make __DISTRIBUTION__ and other define variables available
|
||||
import './vite-define-shim'
|
||||
|
||||
import { DESKTOP_DIALOGS } from '../apps/desktop-ui/src/constants/desktopDialogs'
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Shim for Vite define variables to make them available during Playwright test execution
|
||||
* This file should be imported before any code that uses Vite define variables
|
||||
*/
|
||||
|
||||
// Define global constants that Vite would normally replace at build time
|
||||
declare global {
|
||||
const __COMFYUI_FRONTEND_VERSION__: string
|
||||
const __SENTRY_ENABLED__: boolean
|
||||
const __SENTRY_DSN__: string
|
||||
const __ALGOLIA_APP_ID__: string
|
||||
const __ALGOLIA_API_KEY__: string
|
||||
const __USE_PROD_CONFIG__: boolean
|
||||
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
}
|
||||
|
||||
type GlobalWithDefines = typeof globalThis & {
|
||||
__COMFYUI_FRONTEND_VERSION__: string
|
||||
__SENTRY_ENABLED__: boolean
|
||||
__SENTRY_DSN__: string
|
||||
__ALGOLIA_APP_ID__: string
|
||||
__ALGOLIA_API_KEY__: string
|
||||
__USE_PROD_CONFIG__: boolean
|
||||
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
window?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const globalWithDefines = globalThis as GlobalWithDefines
|
||||
|
||||
// Set default values for Playwright test environment
|
||||
globalWithDefines.__COMFYUI_FRONTEND_VERSION__ =
|
||||
process.env.npm_package_version || '1.0.0'
|
||||
globalWithDefines.__SENTRY_ENABLED__ = false
|
||||
globalWithDefines.__SENTRY_DSN__ = ''
|
||||
globalWithDefines.__ALGOLIA_APP_ID__ = ''
|
||||
globalWithDefines.__ALGOLIA_API_KEY__ = ''
|
||||
globalWithDefines.__USE_PROD_CONFIG__ = false
|
||||
globalWithDefines.__DISTRIBUTION__ = 'localhost'
|
||||
|
||||
// Provide a minimal window shim for Node environment
|
||||
// This is needed for code that checks window existence during imports
|
||||
if (typeof window === 'undefined') {
|
||||
globalWithDefines.window = {}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -14,12 +14,11 @@
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
key="main-splitter-stable"
|
||||
class="splitter-overlay flex-1 overflow-hidden"
|
||||
:pt:gutter="getSplitterGutterClasses"
|
||||
:state-key="sidebarStateKey"
|
||||
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
|
||||
:state-key="sidebarStateKey || 'main-splitter'"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="sidebarLocation === 'left'"
|
||||
@@ -51,7 +50,6 @@
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative">
|
||||
<slot name="graph-canvas-panel" />
|
||||
@@ -82,16 +80,6 @@
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Right Side Panel - independent of sidebar -->
|
||||
<SplitterPanel
|
||||
v-if="rightSidePanelVisible"
|
||||
class="right-side-panel pointer-events-auto"
|
||||
:min-size="15"
|
||||
:size="20"
|
||||
>
|
||||
<slot name="right-side-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,17 +87,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
@@ -124,7 +109,6 @@ const sidebarPanelVisible = computed(
|
||||
const bottomPanelVisible = computed(
|
||||
() => useBottomPanelStore().bottomPanelVisible
|
||||
)
|
||||
const rightSidePanelVisible = computed(() => rightSidePanelStore.isOpen)
|
||||
const activeSidebarTabId = computed(
|
||||
() => useSidebarTabStore().activeSidebarTabId
|
||||
)
|
||||
@@ -136,28 +120,6 @@ const sidebarStateKey = computed(() => {
|
||||
// When no tab is active, use a default key to maintain state
|
||||
return activeSidebarTabId.value ?? 'default-sidebar'
|
||||
})
|
||||
|
||||
/**
|
||||
* Force refresh the splitter when right panel visibility changes to recalculate the width
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return rightSidePanelVisible.value
|
||||
? 'main-splitter-with-right-panel'
|
||||
: 'main-splitter'
|
||||
})
|
||||
|
||||
// Gutter visibility should be controlled by CSS targeting specific gutters
|
||||
const getSplitterGutterClasses = computed(() => {
|
||||
// Empty string - let individual gutter styles handle visibility
|
||||
return ''
|
||||
})
|
||||
|
||||
/**
|
||||
* Avoid triggering default behaviors during drag-and-drop, such as text selection.
|
||||
*/
|
||||
function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -173,20 +135,10 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Hide sidebar gutter when sidebar is not visible */
|
||||
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.side-bar-panel {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.right-side-panel {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.bottom-panel {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-panel-border-color);
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
class="ml-1 flex gap-x-0.5 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
>
|
||||
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
@@ -44,25 +39,8 @@
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<IconButton
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-pressed="isRightSidePanelOpen"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="toggleRightSidePanel"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--panel-right] block size-4 text-muted-foreground"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -82,18 +60,15 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
@@ -104,16 +79,6 @@ const queueHistoryButtonBackgroundClass = computed(() =>
|
||||
: 'bg-secondary-background'
|
||||
)
|
||||
|
||||
// Right side panel toggle
|
||||
const isRightSidePanelOpen = computed(() => rightSidePanelStore.isOpen)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
|
||||
const toggleRightSidePanel = () => {
|
||||
rightSidePanelStore.togglePanel()
|
||||
}
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
onMounted(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
class="pointer-events-auto"
|
||||
class="pointer-events-auto z-1010"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
@@ -66,7 +66,12 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
style: style,
|
||||
isDragging
|
||||
} = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body,
|
||||
@@ -252,7 +257,7 @@ watch(isDragging, (dragging) => {
|
||||
})
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
'w-[265px] border-dashed border-blue-500 opacity-80',
|
||||
'm-1.5 flex items-center justify-center self-stretch',
|
||||
'rounded-md before:w-50 before:-ml-50 before:h-full',
|
||||
'pointer-events-auto',
|
||||
@@ -262,7 +267,7 @@ const actionbarClass = computed(() =>
|
||||
)
|
||||
const panelClass = computed(() =>
|
||||
cn(
|
||||
'actionbar pointer-events-auto z-1300',
|
||||
'actionbar pointer-events-auto z1000',
|
||||
isDragging.value && 'select-none pointer-events-none',
|
||||
isDocked.value
|
||||
? 'p-0 static mr-2 border-none bg-transparent'
|
||||
|
||||
@@ -44,22 +44,17 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
@@ -32,22 +32,27 @@ const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
// Auto-size terminal to fill container width.
|
||||
// minCols: 80 ensures minimum width for colab environments.
|
||||
// `autoCols` is false because we don't want the progress bar in the terminal
|
||||
// to render incorrectly as the progress bar is rendered based on the
|
||||
// server's terminal size.
|
||||
// Apply a min cols of 80 for colab environments
|
||||
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
|
||||
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
|
||||
useAutoSize({ root, autoRows: true, autoCols: false, minCols: 80 })
|
||||
|
||||
const update = (entries: Array<LogEntry>) => {
|
||||
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
|
||||
if (size) {
|
||||
terminal.resize(size.cols, terminal.rows)
|
||||
}
|
||||
terminal.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
||||
update(e.detail.entries)
|
||||
update(e.detail.entries, e.detail.size)
|
||||
}
|
||||
|
||||
const loadLogEntries = async () => {
|
||||
const logs = await api.getRawLogs()
|
||||
update(logs.entries)
|
||||
update(logs.entries, logs.size)
|
||||
}
|
||||
|
||||
const watchLogs = async () => {
|
||||
|
||||
@@ -64,13 +64,11 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -81,10 +79,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div :class="iconGroupClasses">
|
||||
<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-secondary-background shadow-sm',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton :size="size" :type="type" @click="popover?.toggle">
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
@@ -25,18 +25,8 @@
|
||||
)
|
||||
}
|
||||
}"
|
||||
@show="
|
||||
() => {
|
||||
isOpen = true
|
||||
$emit('menuOpened')
|
||||
}
|
||||
"
|
||||
@hide="
|
||||
() => {
|
||||
isOpen = false
|
||||
$emit('menuClosed')
|
||||
}
|
||||
"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
@@ -58,6 +48,8 @@ interface MoreButtonProps extends BaseButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
@@ -69,15 +61,15 @@ defineEmits<{
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const hide = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
hide,
|
||||
isOpen
|
||||
hide
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter.capture.stop="blurInputElement"
|
||||
@keyup.escape.stop="cancelEditing"
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@click.stop
|
||||
@pointerdown.stop.capture
|
||||
@pointermove.stop.capture
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, string>
|
||||
inputAttrs?: Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
|
||||
@@ -9,31 +9,29 @@
|
||||
<div class="font-medium">
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div>{{ getDisplayValue(col) }}</div>
|
||||
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="hasDevices">
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
>
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
<DeviceInfo v-else :device="props.stats.devices[0]" />
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
>
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
<DeviceInfo v-else :device="props.stats.devices[0]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,9 +42,8 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -57,53 +54,20 @@ const systemInfo = computed(() => ({
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
}))
|
||||
|
||||
const hasDevices = computed(() => props.stats.devices.length > 0)
|
||||
const systemColumns: { field: keyof SystemStats['system']; header: string }[] =
|
||||
[
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total' },
|
||||
{ field: 'ram_free', header: 'RAM Free' }
|
||||
]
|
||||
|
||||
type SystemInfoKey = keyof SystemStats['system']
|
||||
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
header: string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
}
|
||||
|
||||
/** Columns for local distribution */
|
||||
const localColumns: ColumnDef[] = [
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
|
||||
]
|
||||
|
||||
/** Columns for cloud distribution */
|
||||
const cloudColumns: ColumnDef[] = [
|
||||
{ field: 'cloud_version', header: 'Cloud Version' },
|
||||
{
|
||||
field: 'comfyui_version',
|
||||
header: 'ComfyUI Version',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
header: 'Frontend Version',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
]
|
||||
|
||||
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
}
|
||||
if (column.format && typeof value === 'string') {
|
||||
return column.format(value)
|
||||
const formatValue = (value: any, field: string) => {
|
||||
if (['ram_total', 'ram_free'].includes(field)) {
|
||||
return formatSize(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
{{ promptTextReal }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const { promptText } = defineProps<{
|
||||
promptText?: MaybeRefOrGetter<string>
|
||||
}>()
|
||||
|
||||
const promptTextReal = computed(() => toValue(promptText))
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<section class="w-full flex gap-2 justify-end px-2 pb-2">
|
||||
<TextButton
|
||||
:label="cancelTextX"
|
||||
:disabled
|
||||
type="transparent"
|
||||
autofocus
|
||||
@click="$emit('cancel')"
|
||||
/>
|
||||
<TextButton
|
||||
:label="confirmTextX"
|
||||
:disabled
|
||||
type="transparent"
|
||||
:class="confirmClass"
|
||||
@click="$emit('confirm')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
confirmClass?: string
|
||||
optionsDisabled?: MaybeRefOrGetter<boolean>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
cancel: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const confirmTextX = computed(() => confirmText || t('g.confirm'))
|
||||
const cancelTextX = computed(() => cancelText || t('g.cancel'))
|
||||
const disabled = computed(() => toValue(optionsDisabled))
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
|
||||
>
|
||||
<span v-if="title" class="flex-auto">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,31 +0,0 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
interface ConfirmDialogOptions {
|
||||
headerProps?: ComponentAttrs<typeof ConfirmHeader>
|
||||
props?: ComponentAttrs<typeof ConfirmBody>
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
headerComponent: ConfirmHeader,
|
||||
component: ConfirmBody,
|
||||
footerComponent: ConfirmFooter,
|
||||
headerProps,
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,9 +14,7 @@
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-[var(--comfy-topbar-height)] w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
>
|
||||
<div class="flex h-full items-center">
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
</div>
|
||||
@@ -38,9 +36,6 @@
|
||||
<template v-if="showUI" #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<NodePropertiesPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
<MiniMap
|
||||
@@ -60,6 +55,7 @@
|
||||
<TransformPane
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
@@ -77,9 +73,6 @@
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
<!-- Selection rectangle overlay for Vue nodes mode -->
|
||||
<SelectionRectangle v-if="shouldRenderVueNodes && comfyAppReady" />
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
|
||||
@@ -117,13 +110,13 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -162,7 +155,6 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
|
||||
import TryVueNodeBanner from '../topbar/TryVueNodeBanner.vue'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
@@ -208,6 +200,7 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
const { handleTransformUpdate } = useViewportCulling()
|
||||
|
||||
const handleVueNodeLifecycleReset = async () => {
|
||||
if (shouldRenderVueNodes.value) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="pointer-events-none absolute border border-blue-400 bg-blue-500/20"
|
||||
:style="rectangleStyle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const selectionRect = ref<{
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
} | null>(null)
|
||||
|
||||
useRafFn(() => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) {
|
||||
selectionRect.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const { pointer, dragging_rectangle } = canvas
|
||||
|
||||
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
|
||||
const x = pointer.eDown.safeOffsetX
|
||||
const y = pointer.eDown.safeOffsetY
|
||||
const w = pointer.eMove.safeOffsetX - x
|
||||
const h = pointer.eMove.safeOffsetY - y
|
||||
|
||||
selectionRect.value = { x, y, w, h }
|
||||
} else {
|
||||
selectionRect.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isVisible = computed(() => selectionRect.value !== null)
|
||||
|
||||
const rectangleStyle = computed(() => {
|
||||
const rect = selectionRect.value
|
||||
if (!rect) return {}
|
||||
|
||||
const left = rect.w >= 0 ? rect.x : rect.x + rect.w
|
||||
const top = rect.h >= 0 ? rect.y : rect.y + rect.h
|
||||
const width = Math.abs(rect.w)
|
||||
const height = Math.abs(rect.h)
|
||||
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -7,17 +7,11 @@
|
||||
severity="secondary"
|
||||
text
|
||||
icon="icon-[lucide--settings-2]"
|
||||
@click="handleClick"
|
||||
@click="showSubgraphNodeDialog"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const handleClick = () => {
|
||||
rightSidePanelStore.openPanel('subgraph')
|
||||
}
|
||||
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
|
||||
</script>
|
||||
|
||||
@@ -280,8 +280,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.docs'),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
const path = isCloud ? '/get_started/cloud' : '/'
|
||||
openExternalLink(buildDocsUrl(path, { includeLocale: true }))
|
||||
openExternalLink(buildDocsUrl('/', { includeLocale: true }))
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
popoverMaxWidth?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
const selectedItem = defineModel<string | null>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative size-full"
|
||||
class="widget-expands relative h-full w-full"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@pointerdown.stop
|
||||
@@ -17,7 +17,7 @@
|
||||
:on-model-drop="isPreview ? undefined : handleModelDrop"
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 size-full">
|
||||
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
|
||||
<Load3DControls
|
||||
v-model:scene-config="sceneConfig"
|
||||
v-model:model-config="modelConfig"
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
<button :class="textButtonClass" @click="resetToDefault">
|
||||
<button
|
||||
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
@@ -77,7 +80,7 @@
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="$t('maskEditor.stepSize')"
|
||||
label="Stepsize"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
@@ -96,9 +99,6 @@ import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-32 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
|
||||
const setBrushShape = (shape: BrushShape) => {
|
||||
store.brushSettings.type = shape
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
<template>
|
||||
<div class="node-help-content mx-auto w-full">
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="m-auto"
|
||||
:aria-label="$t('g.loading')"
|
||||
/>
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div v-else class="fallback-content space-y-6 text-sm">
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="inputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
|
||||
|
||||
const inputList = computed(() =>
|
||||
Object.values(node.inputs).map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
|
||||
const outputList = computed(() =>
|
||||
node.outputs.map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference './../../assets/css/style.css';
|
||||
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm overflow-visible;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.markdown-content :deep(h5),
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content td {
|
||||
color: var(--drag-text);
|
||||
}
|
||||
|
||||
.markdown-content :deep(a),
|
||||
.fallback-content a {
|
||||
color: var(--drag-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.fallback-content th {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
@apply pl-8 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
.markdown-content :deep(ol ol),
|
||||
.markdown-content :deep(ul ol),
|
||||
.markdown-content :deep(ol ul),
|
||||
.fallback-content ul ul,
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
@apply pl-6 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
color: var(--code-text-color);
|
||||
background-color: var(--code-bg-color);
|
||||
@apply rounded px-1.5 py-0.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
.fallback-content tr {
|
||||
border-bottom: 1px solid var(--content-bg);
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr:last-child),
|
||||
.fallback-content tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(thead),
|
||||
.fallback-content thead {
|
||||
border-bottom: 1px solid var(--p-text-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre),
|
||||
.fallback-content pre {
|
||||
@apply rounded p-4 my-4 overflow-x-auto;
|
||||
background-color: var(--code-block-bg-color);
|
||||
|
||||
code {
|
||||
@apply bg-transparent p-0;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content :deep(table) {
|
||||
@apply overflow-x-auto;
|
||||
}
|
||||
</style>
|
||||
@@ -25,11 +25,7 @@
|
||||
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
|
||||
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
|
||||
>
|
||||
<img
|
||||
:src="url"
|
||||
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<img :src="url" alt="preview" class="h-full w-full object-cover" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
|
||||
@@ -85,15 +85,9 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>(),
|
||||
{
|
||||
menuHovered: false
|
||||
}
|
||||
)
|
||||
const props = defineProps<{
|
||||
expanded?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
@@ -116,7 +110,6 @@ const {
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
@@ -149,7 +142,7 @@ const showBackground = computed(
|
||||
() =>
|
||||
overlayState.value === 'expanded' ||
|
||||
overlayState.value === 'empty' ||
|
||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||
(overlayState.value === 'active' && isHovered.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(() => overlayState.value !== 'hidden')
|
||||
@@ -163,7 +156,7 @@ const containerClass = computed(() =>
|
||||
const bottomRowClass = computed(
|
||||
() =>
|
||||
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
||||
overlayState.value === 'active' && isOverlayHovered.value
|
||||
overlayState.value === 'active' && isHovered.value
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
|
||||
@@ -82,10 +82,7 @@
|
||||
:src="iconImageUrl"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||
/>
|
||||
<i v-else :class="[iconClass, 'size-4']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,23 +93,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TODO: Refactor action buttons to use a declarative config system.
|
||||
|
||||
Instead of hardcoding button visibility logic in the template, define an array of
|
||||
action button configs with properties like:
|
||||
- icon, label, action, tooltip
|
||||
- visibleStates: JobState[] (which job states show this button)
|
||||
- alwaysVisible: boolean (show without hover)
|
||||
- destructive: boolean (use destructive styling)
|
||||
|
||||
Then render buttons in two groups:
|
||||
1. Always-visible buttons (outside Transition)
|
||||
2. Hover-only buttons (inside Transition)
|
||||
|
||||
This would eliminate the current duplication where the cancel button exists
|
||||
both outside (for running) and inside (for pending) the Transition.
|
||||
-->
|
||||
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
@@ -133,22 +113,18 @@
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="
|
||||
props.state !== 'completed' &&
|
||||
props.state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-else-if="props.state !== 'completed' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
@@ -167,33 +143,17 @@
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-modal-card-button-surface text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="props.state !== 'running'"
|
||||
key="secondary"
|
||||
class="pr-2"
|
||||
>
|
||||
<div v-else key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<IconButton
|
||||
v-if="props.state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +170,6 @@ import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -343,13 +302,6 @@ const iconClass = computed(() => {
|
||||
return iconForJobState(props.state)
|
||||
})
|
||||
|
||||
const shouldSpin = computed(
|
||||
() =>
|
||||
props.state === 'pending' &&
|
||||
iconClass.value === iconForJobState('pending') &&
|
||||
!props.iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabParameters from './parameters/TabParameters.vue'
|
||||
import TabSettings from './settings/TabSettings.vue'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const hasSelection = computed(() => selectedItems.value.length > 0)
|
||||
|
||||
const selectedNodes = computed(() => {
|
||||
return selectedItems.value.filter(isLGraphNode) as LGraphNode[]
|
||||
})
|
||||
|
||||
const isSubgraphNode = computed(() => {
|
||||
return selectedNode.value instanceof SubgraphNode
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(() => selectedNodes.value.length === 1)
|
||||
|
||||
const selectedNode = computed(() => {
|
||||
return isSingleNodeSelected.value ? selectedNodes.value[0] : null
|
||||
})
|
||||
|
||||
const selectionCount = computed(() => selectedItems.value.length)
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (!hasSelection.value) return t('rightSidePanel.properties')
|
||||
if (isSingleNodeSelected.value && selectedNode.value) {
|
||||
return selectedNode.value.title || selectedNode.value.type || 'Node'
|
||||
}
|
||||
return t('rightSidePanel.multipleSelection', { count: selectionCount.value })
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
rightSidePanelStore.closePanel()
|
||||
}
|
||||
|
||||
const tabs = computed<{ label: () => string; value: string }[]>(() => {
|
||||
const list = [
|
||||
{
|
||||
label: () => t('rightSidePanel.parameters'),
|
||||
value: 'parameters'
|
||||
},
|
||||
{
|
||||
label: () => t('rightSidePanel.settings'),
|
||||
value: 'settings'
|
||||
}
|
||||
]
|
||||
if (
|
||||
!hasSelection.value ||
|
||||
(isSingleNodeSelected.value && !isSubgraphNode.value)
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.info'),
|
||||
value: 'info'
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
// Use global state for activeTab and ensure it's valid
|
||||
watchEffect(() => {
|
||||
if (!tabs.value.some((tab) => tab.value === activeTab.value)) {
|
||||
activeTab.value = tabs.value[0].value as 'parameters' | 'settings' | 'info'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col bg-interface-panel-surface">
|
||||
<!-- Panel Header -->
|
||||
<div class="border-b border-interface-stroke pt-1">
|
||||
<div class="flex items-center justify-between pl-4 pr-3">
|
||||
<h3 class="my-3.5 text-sm font-semibold line-clamp-2">
|
||||
{{ panelTitle }}
|
||||
</h3>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<IconButton
|
||||
v-if="isSubgraphNode"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="bg-secondary-background hover:bg-secondary-background-hover"
|
||||
:class="
|
||||
cn(
|
||||
'bg-secondary-background hover:bg-secondary-background-hover',
|
||||
isEditingSubgraph
|
||||
? 'bg-secondary-background-selected'
|
||||
: 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="isEditingSubgraph = !isEditingSubgraph"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2]" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="bg-secondary-background hover:bg-secondary-background-hover"
|
||||
:aria-pressed="rightSidePanelStore.isOpen"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="closePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right]" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasSelection && !(isSubgraphNode && isEditingSubgraph)"
|
||||
class="px-4 pb-2 pt-1"
|
||||
>
|
||||
<TabList v-model="activeTab">
|
||||
<Tab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="text-sm py-1 px-2"
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<SubgraphEditor
|
||||
v-if="isSubgraphNode && isEditingSubgraph"
|
||||
:node="selectedNode"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!hasSelection"
|
||||
class="flex h-full items-center justify-center text-center"
|
||||
>
|
||||
<div class="px-4 text-sm text-base-foreground-muted">
|
||||
{{ $t('rightSidePanel.noSelection') }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<TabParameters
|
||||
v-if="activeTab === 'parameters'"
|
||||
:nodes="selectedNodes"
|
||||
/>
|
||||
<TabInfo v-else-if="activeTab === 'info'" :nodes="selectedNodes" />
|
||||
<TabSettings
|
||||
v-else-if="activeTab === 'settings'"
|
||||
:nodes="selectedNodes"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,426 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
import TabInfo from './TabInfo.vue'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
|
||||
useNodeHelpStore: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock NodeHelpContent component
|
||||
vi.mock('@/components/node/NodeHelpContent.vue', () => ({
|
||||
default: {
|
||||
name: 'NodeHelpContent',
|
||||
template: '<div class="node-help-content">{{ node?.type }}</div>',
|
||||
props: ['node']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('TabInfo', () => {
|
||||
let mockNodeDefStore: any
|
||||
let mockNodeHelpStore: any
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
mockNodeDefStore = {
|
||||
nodeDefsByName: {
|
||||
'KSampler': {
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Sampling node',
|
||||
category: 'sampling'
|
||||
},
|
||||
'CheckpointLoader': {
|
||||
name: 'CheckpointLoader',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads model checkpoint',
|
||||
category: 'loaders'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockNodeHelpStore = {
|
||||
openHelp: vi.fn()
|
||||
}
|
||||
|
||||
vi.mocked(useNodeDefStore).mockReturnValue(mockNodeDefStore)
|
||||
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore)
|
||||
})
|
||||
|
||||
const createMockNode = (type: string, id: number = 1): LGraphNode => ({
|
||||
id,
|
||||
type,
|
||||
title: `${type}_${id}`,
|
||||
properties: {},
|
||||
serialize: vi.fn(),
|
||||
configure: vi.fn()
|
||||
} as any)
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(TabInfo, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
stubs: {
|
||||
NodeHelpContent: true
|
||||
}
|
||||
},
|
||||
props: {
|
||||
nodes: [createMockNode('KSampler')],
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders successfully with single node', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders NodeHelpContent when node info exists', () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders with correct container styling', () => {
|
||||
const wrapper = mountComponent()
|
||||
const container = wrapper.find('div')
|
||||
|
||||
expect(container.classes()).toContain('rounded-lg')
|
||||
expect(container.classes()).toContain('bg-interface-surface')
|
||||
expect(container.classes()).toContain('p-3')
|
||||
})
|
||||
|
||||
it('does not render when node info is not available', () => {
|
||||
mockNodeDefStore.nodeDefsByName = {}
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('UnknownNode')]
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toBe('<!--v-if-->')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Info Computation', () => {
|
||||
it('computes node info from first node in array', () => {
|
||||
const nodes = [
|
||||
createMockNode('KSampler', 1),
|
||||
createMockNode('CheckpointLoader', 2)
|
||||
]
|
||||
const wrapper = mountComponent({ nodes })
|
||||
|
||||
// Should use first node
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns node definition for valid node type', () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.props('node')).toEqual(
|
||||
mockNodeDefStore.nodeDefsByName['KSampler']
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for invalid node type', () => {
|
||||
mockNodeDefStore.nodeDefsByName = {}
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('InvalidNode')]
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toBe('<!--v-if-->')
|
||||
})
|
||||
|
||||
it('handles nodes array with single node', () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('CheckpointLoader')]
|
||||
})
|
||||
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.props('node')).toEqual(
|
||||
mockNodeDefStore.nodeDefsByName['CheckpointLoader']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help Store Integration', () => {
|
||||
it('calls openHelp when nodeInfo exists', async () => {
|
||||
mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalled()
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalledWith(
|
||||
mockNodeDefStore.nodeDefsByName['KSampler']
|
||||
)
|
||||
})
|
||||
|
||||
it('opens help immediately on mount', async () => {
|
||||
mockNodeHelpStore.openHelp.mockClear()
|
||||
|
||||
mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates help when node changes', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
mockNodeHelpStore.openHelp.mockClear()
|
||||
|
||||
// Change to different node
|
||||
await wrapper.setProps({
|
||||
nodes: [createMockNode('CheckpointLoader')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalledWith(
|
||||
mockNodeDefStore.nodeDefsByName['CheckpointLoader']
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call openHelp when nodeInfo is undefined', async () => {
|
||||
mockNodeDefStore.nodeDefsByName = {}
|
||||
mockNodeHelpStore.openHelp.mockClear()
|
||||
|
||||
mountComponent({
|
||||
nodes: [createMockNode('UnknownNode')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockNodeHelpStore.openHelp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Handling', () => {
|
||||
it('accepts nodes prop as array', () => {
|
||||
const nodes = [
|
||||
createMockNode('KSampler', 1),
|
||||
createMockNode('CheckpointLoader', 2)
|
||||
]
|
||||
const wrapper = mountComponent({ nodes })
|
||||
|
||||
expect(wrapper.props('nodes')).toEqual(nodes)
|
||||
})
|
||||
|
||||
it('handles empty nodes array', () => {
|
||||
const wrapper = mountComponent({ nodes: [] })
|
||||
|
||||
expect(wrapper.html()).toBe('<!--v-if-->')
|
||||
})
|
||||
|
||||
it('updates when nodes prop changes', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
await wrapper.setProps({
|
||||
nodes: [createMockNode('CheckpointLoader')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.props('node')).toEqual(
|
||||
mockNodeDefStore.nodeDefsByName['CheckpointLoader']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles node with missing type', () => {
|
||||
const nodeWithoutType = {
|
||||
id: 1,
|
||||
title: 'Node',
|
||||
properties: {}
|
||||
} as any
|
||||
|
||||
const wrapper = mountComponent({
|
||||
nodes: [nodeWithoutType]
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toBe('<!--v-if-->')
|
||||
})
|
||||
|
||||
it('handles rapid node switching', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await wrapper.setProps({
|
||||
nodes: [createMockNode(i % 2 === 0 ? 'KSampler' : 'CheckpointLoader')]
|
||||
})
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Should still be functional
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles nodes with special characters in type', () => {
|
||||
mockNodeDefStore.nodeDefsByName['Node-With-Dashes'] = {
|
||||
name: 'Node-With-Dashes',
|
||||
display_name: 'Node With Dashes'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('Node-With-Dashes')]
|
||||
})
|
||||
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('recomputes nodeInfo when nodes prop changes', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
let helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.props('node').name).toBe('KSampler')
|
||||
|
||||
await wrapper.setProps({
|
||||
nodes: [createMockNode('CheckpointLoader')]
|
||||
})
|
||||
|
||||
helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.props('node').name).toBe('CheckpointLoader')
|
||||
})
|
||||
|
||||
it('recomputes nodeInfo when store updates', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
// Simulate store update
|
||||
mockNodeDefStore.nodeDefsByName['KSampler'] = {
|
||||
...mockNodeDefStore.nodeDefsByName['KSampler'],
|
||||
description: 'Updated description'
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.props('node').description).toBe('Updated description')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Lifecycle', () => {
|
||||
it('calls openHelp on mount', async () => {
|
||||
mockNodeHelpStore.openHelp.mockClear()
|
||||
|
||||
mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cleans up watchers on unmount', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
// Should not throw errors
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('handles remount correctly', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
mockNodeHelpStore.openHelp.mockClear()
|
||||
|
||||
const wrapper2 = mountComponent({
|
||||
nodes: [createMockNode('CheckpointLoader')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalledWith(
|
||||
mockNodeDefStore.nodeDefsByName['CheckpointLoader']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration Scenarios', () => {
|
||||
it('works in typical workflow: select node, view info', async () => {
|
||||
// User selects a node
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Info panel opens and displays help
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalled()
|
||||
const helpContent = wrapper.findComponent({ name: 'NodeHelpContent' })
|
||||
expect(helpContent.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates when user selects different node', async () => {
|
||||
const wrapper = mountComponent({
|
||||
nodes: [createMockNode('KSampler')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
mockNodeHelpStore.openHelp.mockClear()
|
||||
|
||||
// User selects different node
|
||||
await wrapper.setProps({
|
||||
nodes: [createMockNode('CheckpointLoader')]
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Help updates to new node
|
||||
expect(mockNodeHelpStore.openHelp).toHaveBeenCalledWith(
|
||||
mockNodeDefStore.nodeDefsByName['CheckpointLoader']
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
const node = computed(() => props.nodes[0])
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
const nodeInfo = computed(() => {
|
||||
return nodeDefStore.fromLGraphNode(node.value)
|
||||
})
|
||||
|
||||
// Open node help when the selected node changes
|
||||
watch(
|
||||
nodeInfo,
|
||||
(info) => {
|
||||
if (info) {
|
||||
nodeHelpStore.openHelp(info)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="nodeInfo" class="rounded-lg bg-interface-surface p-3">
|
||||
<NodeHelpContent :node="nodeInfo" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,408 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import RightPanelSection from './RightPanelSection.vue'
|
||||
|
||||
describe('RightPanelSection', () => {
|
||||
beforeAll(() => {
|
||||
const app = { use: vi.fn() }
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}, slots = {}) => {
|
||||
return mount(RightPanelSection, {
|
||||
global: {
|
||||
plugins: [PrimeVue]
|
||||
},
|
||||
props: {
|
||||
label: 'Test Section',
|
||||
...props
|
||||
},
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders with default props', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays label from props', () => {
|
||||
const wrapper = mountComponent({ label: 'Custom Label' })
|
||||
const button = wrapper.find('button')
|
||||
expect(button.text()).toContain('Custom Label')
|
||||
})
|
||||
|
||||
it('renders label from slot when provided', () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
label: '<span class="custom-label">Slot Label</span>'
|
||||
}
|
||||
)
|
||||
const button = wrapper.find('button')
|
||||
expect(button.html()).toContain('custom-label')
|
||||
expect(button.text()).toContain('Slot Label')
|
||||
})
|
||||
|
||||
it('renders default slot content when expanded', async () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
default: '<div class="test-content">Test Content</div>'
|
||||
}
|
||||
)
|
||||
|
||||
// Content should be visible by default (not collapsed)
|
||||
expect(wrapper.html()).toContain('test-content')
|
||||
expect(wrapper.text()).toContain('Test Content')
|
||||
})
|
||||
|
||||
it('renders chevron icon in button', () => {
|
||||
const wrapper = mountComponent()
|
||||
const icon = wrapper.find('i.icon-\\[lucide--chevron-down\\]')
|
||||
expect(icon.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collapse/Expand Functionality', () => {
|
||||
it('starts in expanded state by default', () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
default: '<div class="content">Content</div>'
|
||||
}
|
||||
)
|
||||
|
||||
expect(wrapper.html()).toContain('content')
|
||||
})
|
||||
|
||||
it('starts in collapsed state when defaultCollapse is true', async () => {
|
||||
const wrapper = mountComponent(
|
||||
{ defaultCollapse: true },
|
||||
{
|
||||
default: '<div class="content">Content</div>'
|
||||
}
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
expect(wrapper.html()).not.toContain('content')
|
||||
})
|
||||
|
||||
it('toggles collapse state when button is clicked', async () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
default: '<div class="content">Content</div>'
|
||||
}
|
||||
)
|
||||
|
||||
const button = wrapper.find('button')
|
||||
|
||||
// Initially expanded
|
||||
expect(wrapper.html()).toContain('content')
|
||||
|
||||
// Click to collapse
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.html()).not.toContain('content')
|
||||
|
||||
// Click to expand again
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.html()).toContain('content')
|
||||
})
|
||||
|
||||
it('rotates chevron icon when collapsed', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
const icon = wrapper.find('i.icon-\\[lucide--chevron-down\\]')
|
||||
|
||||
// Initially not rotated (expanded state)
|
||||
expect(icon.classes()).not.toContain('rotate-90')
|
||||
|
||||
// Click to collapse
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should be rotated when collapsed
|
||||
expect(icon.classes()).toContain('rotate-90')
|
||||
})
|
||||
|
||||
it('emits update:collapse event when toggled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:collapse')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:collapse')?.[0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('supports v-model:collapse binding', async () => {
|
||||
const wrapper = mountComponent({ collapse: false })
|
||||
const button = wrapper.find('button')
|
||||
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:collapse')?.[0]).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('reacts to defaultCollapse prop changes', async () => {
|
||||
const wrapper = mountComponent(
|
||||
{ defaultCollapse: false },
|
||||
{
|
||||
default: '<div class="content">Content</div>'
|
||||
}
|
||||
)
|
||||
|
||||
// Initially expanded
|
||||
expect(wrapper.html()).toContain('content')
|
||||
|
||||
// Change to collapsed
|
||||
await wrapper.setProps({ defaultCollapse: true })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.html()).not.toContain('content')
|
||||
})
|
||||
|
||||
it('updates when collapse model value changes', async () => {
|
||||
const wrapper = mountComponent(
|
||||
{ collapse: false },
|
||||
{
|
||||
default: '<div class="content">Content</div>'
|
||||
}
|
||||
)
|
||||
|
||||
// Initially expanded
|
||||
expect(wrapper.html()).toContain('content')
|
||||
|
||||
// Programmatically collapse
|
||||
await wrapper.setProps({ collapse: true })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.html()).not.toContain('content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling and Classes', () => {
|
||||
it('applies correct sticky header styles', () => {
|
||||
const wrapper = mountComponent()
|
||||
const header = wrapper.find('.sticky')
|
||||
|
||||
expect(header.exists()).toBe(true)
|
||||
expect(header.classes()).toContain('top-0')
|
||||
expect(header.classes()).toContain('z-10')
|
||||
})
|
||||
|
||||
it('applies button classes correctly', () => {
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.classes()).toContain('group')
|
||||
expect(button.classes()).toContain('cursor-pointer')
|
||||
expect(button.classes()).toContain('w-full')
|
||||
})
|
||||
|
||||
it('applies transition class to chevron icon', () => {
|
||||
const wrapper = mountComponent()
|
||||
const icon = wrapper.find('i')
|
||||
|
||||
expect(icon.classes()).toContain('transition-all')
|
||||
})
|
||||
|
||||
it('applies hover styles to icon through group', () => {
|
||||
const wrapper = mountComponent()
|
||||
const icon = wrapper.find('i')
|
||||
|
||||
expect(icon.classes()).toContain('group-hover:text-base-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('button is keyboard accessible', () => {
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.element.tagName).toBe('BUTTON')
|
||||
})
|
||||
|
||||
it('maintains semantic HTML structure', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Should have a div container
|
||||
expect(wrapper.element.tagName).toBe('DIV')
|
||||
|
||||
// Should have a button for interaction
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('label text is accessible', () => {
|
||||
const wrapper = mountComponent({ label: 'Test Section' })
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.text()).toContain('Test Section')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty label gracefully', () => {
|
||||
const wrapper = mountComponent({ label: '' })
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text().trim()).toBe('')
|
||||
})
|
||||
|
||||
it('handles undefined label', () => {
|
||||
const wrapper = mountComponent({ label: undefined })
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles null default slot', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles multiple rapid toggle clicks', async () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
default: '<div class="content">Content</div>'
|
||||
}
|
||||
)
|
||||
const button = wrapper.find('button')
|
||||
|
||||
// Rapidly click multiple times
|
||||
await button.trigger('click')
|
||||
await button.trigger('click')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should handle all clicks without errors
|
||||
expect(wrapper.emitted('update:collapse')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles very long label text', () => {
|
||||
const longLabel = 'A'.repeat(1000)
|
||||
const wrapper = mountComponent({ label: longLabel })
|
||||
const span = wrapper.find('span.line-clamp-2')
|
||||
|
||||
expect(span.exists()).toBe(true)
|
||||
expect(span.text()).toContain('A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration Scenarios', () => {
|
||||
it('works with multiple sections in sequence', () => {
|
||||
const wrapper1 = mountComponent({ label: 'Section 1' })
|
||||
const wrapper2 = mountComponent({ label: 'Section 2' })
|
||||
|
||||
expect(wrapper1.text()).toContain('Section 1')
|
||||
expect(wrapper2.text()).toContain('Section 2')
|
||||
})
|
||||
|
||||
it('maintains independent state between instances', async () => {
|
||||
const wrapper1 = mountComponent(
|
||||
{ label: 'Section 1' },
|
||||
{ default: '<div>Content 1</div>' }
|
||||
)
|
||||
const wrapper2 = mountComponent(
|
||||
{ label: 'Section 2' },
|
||||
{ default: '<div>Content 2</div>' }
|
||||
)
|
||||
|
||||
// Collapse first section
|
||||
await wrapper1.find('button').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// First should be collapsed
|
||||
expect(wrapper1.html()).not.toContain('Content 1')
|
||||
// Second should remain expanded
|
||||
expect(wrapper2.html()).toContain('Content 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Slot Behavior', () => {
|
||||
it('renders complex nested content', () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
default: `
|
||||
<div class="outer">
|
||||
<div class="inner">
|
||||
<span class="text">Nested Content</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
)
|
||||
|
||||
expect(wrapper.html()).toContain('outer')
|
||||
expect(wrapper.html()).toContain('inner')
|
||||
expect(wrapper.text()).toContain('Nested Content')
|
||||
})
|
||||
|
||||
it('renders multiple child elements', () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
default: `
|
||||
<div class="child-1">Child 1</div>
|
||||
<div class="child-2">Child 2</div>
|
||||
<div class="child-3">Child 3</div>
|
||||
`
|
||||
}
|
||||
)
|
||||
|
||||
expect(wrapper.html()).toContain('child-1')
|
||||
expect(wrapper.html()).toContain('child-2')
|
||||
expect(wrapper.html()).toContain('child-3')
|
||||
})
|
||||
|
||||
it('preserves slot content styling', () => {
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{
|
||||
default: '<div style="color: red;" class="styled-content">Styled</div>'
|
||||
}
|
||||
)
|
||||
|
||||
expect(wrapper.html()).toContain('styled-content')
|
||||
expect(wrapper.html()).toContain('color: red')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
it('handles rapid mount/unmount cycles', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('efficiently handles large content in slot', () => {
|
||||
const largeContent = Array.from({ length: 100 }, (_, i) =>
|
||||
`<div class="item-${i}">Item ${i}</div>`
|
||||
).join('')
|
||||
|
||||
const wrapper = mountComponent(
|
||||
{},
|
||||
{ default: largeContent }
|
||||
)
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.html()).toContain('item-0')
|
||||
expect(wrapper.html()).toContain('item-99')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
defaultCollapse?: boolean
|
||||
}>()
|
||||
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
if (props.defaultCollapse) {
|
||||
isCollapse.value = true
|
||||
}
|
||||
watch(
|
||||
() => props.defaultCollapse,
|
||||
(value) => (isCollapse.value = value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12"
|
||||
>
|
||||
<button
|
||||
class="group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3 cursor-pointer"
|
||||
@click="isCollapse = !isCollapse"
|
||||
>
|
||||
<span class="text-sm font-semibold line-clamp-2">
|
||||
<slot name="label">
|
||||
{{ props.label ?? '' }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-5 min-w-5 transition-all',
|
||||
isCollapse && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="relative top-px text-xs leading-none text-node-component-header-icon group-hover:text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isCollapse" class="pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { ref, toRef, watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
updateKey?: (() => unknown) | unknown
|
||||
}>(),
|
||||
{
|
||||
searcher: async () => {}
|
||||
}
|
||||
)
|
||||
|
||||
const searchQuery = defineModel<string>({ default: '' })
|
||||
|
||||
const isQuerying = ref(false)
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
|
||||
maxWait: 700
|
||||
})
|
||||
watch(searchQuery, (value) => {
|
||||
isQuerying.value = value !== debouncedSearchQuery.value
|
||||
})
|
||||
|
||||
const updateKey =
|
||||
typeof props.updateKey === 'function'
|
||||
? props.updateKey
|
||||
: toRef(props, 'updateKey')
|
||||
|
||||
watch(
|
||||
[debouncedSearchQuery, updateKey],
|
||||
(_, __, onCleanup) => {
|
||||
let isCleanup = false
|
||||
let cleanupFn: undefined | (() => void)
|
||||
onCleanup(() => {
|
||||
isCleanup = true
|
||||
cleanupFn?.()
|
||||
})
|
||||
|
||||
void props
|
||||
.searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
|
||||
.finally(() => {
|
||||
if (!isCleanup) isQuerying.value = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:class="
|
||||
cn(
|
||||
'h-8 bg-zinc-500/20 rounded-lg outline outline-offset-[-1px] outline-node-component-border transition-all duration-150',
|
||||
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
|
||||
searchQuery?.trim() !== '' ? 'text-base-foreground' : '',
|
||||
'hover:outline-component-node-widget-background-highlighted/80',
|
||||
'focus-within:outline-component-node-widget-background-highlighted/80'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isQuerying"
|
||||
class="mr-2 icon-[lucide--loader-circle] size-4 animate-spin"
|
||||
/>
|
||||
<i v-else class="mr-2 icon-[lucide--search] size-4" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="bg-transparent border-0 outline-0 ring-0 text-left"
|
||||
:placeholder="$t('g.search')"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
|
||||
import RightPanelSection from '../layout/RightPanelSection.vue'
|
||||
|
||||
defineProps<{
|
||||
label?: string
|
||||
widgets: { widget: IBaseWidget; node: LGraphNode }[]
|
||||
}>()
|
||||
|
||||
provide('hideLayoutField', true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function getWidgetComponent(widget: IBaseWidget) {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
}
|
||||
|
||||
function onWidgetValueChange(
|
||||
widget: IBaseWidget,
|
||||
value: string | number | boolean | object
|
||||
) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RightPanelSection>
|
||||
<template #label>
|
||||
<slot name="label">
|
||||
{{ label ?? $t('rightSidePanel.inputs') }}
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4 rounded-lg bg-interface-surface px-4">
|
||||
<div
|
||||
v-for="({ widget, node }, index) in widgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="widget-item gap-1.5 col-span-full grid grid-cols-subgrid"
|
||||
>
|
||||
<div class="min-h-8">
|
||||
<p v-if="widget.name" class="text-sm leading-8 p-0 m-0 line-clamp-1">
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
</div>
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
:widget="widget"
|
||||
:model-value="widget.value"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
class="col-span-1"
|
||||
@update:model-value="
|
||||
(value: string | number | boolean | object) =>
|
||||
onWidgetValueChange(widget, value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RightPanelSection>
|
||||
</template>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
const widgetsSectionDataList = computed(() => {
|
||||
const list: {
|
||||
widgets: { node: LGraphNode; widget: IBaseWidget }[]
|
||||
node: LGraphNode
|
||||
}[] = []
|
||||
for (const node of props.nodes) {
|
||||
const shownWidgets: IBaseWidget[] = []
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.options?.canvasOnly || widget.options?.hidden) continue
|
||||
shownWidgets.push(widget)
|
||||
}
|
||||
list.push({
|
||||
widgets: shownWidgets?.map((widget) => ({ node, widget })) ?? [],
|
||||
node
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<
|
||||
{
|
||||
widgets: { node: LGraphNode; widget: IBaseWidget }[]
|
||||
node: LGraphNode
|
||||
}[]
|
||||
>([])
|
||||
|
||||
/**
|
||||
* Searches widgets in all selected nodes and returns search results.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
async function searcher(query: string) {
|
||||
if (query.trim() === '') {
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
return
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
widgets: item.widgets.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.filter((item) => item.widgets.length > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 flex gap-2 border-b border-interface-stroke">
|
||||
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="section in searchedWidgetsSectionDataList"
|
||||
:key="section.node.id"
|
||||
:label="widgetsSectionDataList.length > 1 ? section.node.title : undefined"
|
||||
:widgets="section.widgets"
|
||||
:default-collapse="
|
||||
widgetsSectionDataList.length > 1 &&
|
||||
widgetsSectionDataList === searchedWidgetsSectionDataList
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,230 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4 rounded-lg bg-interface-surface p-3">
|
||||
<!-- Node State -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-text-secondary">
|
||||
{{ t('rightSidePanel.nodeState') }}
|
||||
</span>
|
||||
<FormSelectButton
|
||||
v-model="nodeState"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{
|
||||
label: t('rightSidePanel.normal'),
|
||||
value: LGraphEventMode.ALWAYS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.bypass'),
|
||||
value: LGraphEventMode.BYPASS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.mute'),
|
||||
value: LGraphEventMode.NEVER
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-text-secondary">
|
||||
{{ t('rightSidePanel.color') }}
|
||||
</span>
|
||||
<div
|
||||
class="bg-component-node-widget-background text-component-node-foreground border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
|
||||
>
|
||||
<button
|
||||
v-for="option of colorOptions"
|
||||
:key="option.name"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
|
||||
{
|
||||
'bg-interface-menu-component-surface-selected':
|
||||
option.name === nodeColor,
|
||||
'hover:bg-interface-menu-component-surface-selected':
|
||||
option.name !== nodeColor
|
||||
}
|
||||
)
|
||||
"
|
||||
@click="nodeColor = option.name"
|
||||
>
|
||||
<div
|
||||
v-tooltip.top="option.localizedName()"
|
||||
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
||||
:style="{
|
||||
backgroundColor: isLightTheme
|
||||
? option.value.light
|
||||
: option.value.dark,
|
||||
'--tw-ring-color':
|
||||
option.name === nodeColor
|
||||
? isLightTheme
|
||||
? option.value.ringLight
|
||||
: option.value.ringDark
|
||||
: undefined
|
||||
}"
|
||||
:data-testid="option.name"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinned Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-text-secondary">
|
||||
{{ t('rightSidePanel.pinned') }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="isPinned" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ColorOption, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
node?: LGraphNode
|
||||
nodes?: LGraphNode[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const targetNodes = shallowRef<LGraphNode[]>([])
|
||||
watchEffect(() => {
|
||||
if (props.node) {
|
||||
targetNodes.value = [props.node]
|
||||
} else {
|
||||
targetNodes.value = props.nodes || []
|
||||
}
|
||||
})
|
||||
|
||||
const nodeState = computed({
|
||||
get() {
|
||||
let mode: LGraphNode['mode'] | null = null
|
||||
|
||||
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
|
||||
if (targetNodes.value.length > 1) {
|
||||
mode = targetNodes.value[0].mode
|
||||
if (!targetNodes.value.every((node) => node.mode === mode)) {
|
||||
mode = null
|
||||
}
|
||||
} else {
|
||||
mode = targetNodes.value[0].mode
|
||||
}
|
||||
|
||||
return mode
|
||||
},
|
||||
set(value: LGraphNode['mode']) {
|
||||
targetNodes.value.forEach((node) => {
|
||||
node.mode = value
|
||||
})
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
// Pinned state
|
||||
const isPinned = computed<boolean>({
|
||||
get() {
|
||||
return targetNodes.value.some((node) => node.pinned)
|
||||
},
|
||||
set(value) {
|
||||
targetNodes.value.forEach((node) => node.pin(value))
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
type NodeColorOption = {
|
||||
name: string
|
||||
localizedName: () => string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
ringDark: string
|
||||
ringLight: string
|
||||
}
|
||||
}
|
||||
|
||||
function getColorValue(color: string): NodeColorOption['value'] {
|
||||
return {
|
||||
dark: adjustColor(color, { lightness: 0.3 }),
|
||||
light: adjustColor(color, { lightness: 0.4 }),
|
||||
ringDark: adjustColor(color, { lightness: 0.5 }),
|
||||
ringLight: adjustColor(color, { lightness: 0.1 })
|
||||
}
|
||||
}
|
||||
|
||||
const NO_COLOR_OPTION: NodeColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: () => t('color.noColor'),
|
||||
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
|
||||
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
||||
|
||||
const colorOptions: NodeColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...nodeColorEntries.map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: () => t(`color.${name}`),
|
||||
value: getColorValue(color.bgcolor)
|
||||
}))
|
||||
]
|
||||
|
||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
get() {
|
||||
if (targetNodes.value.length === 0) return null
|
||||
const theColorOptions = targetNodes.value.map((item) =>
|
||||
item.getColorOption()
|
||||
)
|
||||
|
||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
||||
colorOption = false
|
||||
}
|
||||
|
||||
if (colorOption === false) return null
|
||||
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
|
||||
return NO_COLOR_OPTION.name
|
||||
return (
|
||||
nodeColorEntries.find(
|
||||
([_, color]) =>
|
||||
color.bgcolor === colorOption.bgcolor &&
|
||||
color.color === colorOption.color
|
||||
)?.[0] ?? null
|
||||
)
|
||||
},
|
||||
set(colorName) {
|
||||
if (colorName === null) return
|
||||
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of targetNodes.value) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||