Compare commits
70 Commits
chore/incl
...
fix/node-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e7da6669 | ||
|
|
3ee0d394ca | ||
|
|
6a70152220 | ||
|
|
5c1e00ff8e | ||
|
|
78d585eca0 | ||
|
|
a6600aa109 | ||
|
|
c033e9e4d7 | ||
|
|
ac93a6ba3f | ||
|
|
961af8731e | ||
|
|
703de3e669 | ||
|
|
415ebfd67b | ||
|
|
97542efc9b | ||
|
|
13ce23399c | ||
|
|
a0c06bd723 | ||
|
|
3fc17ebdac | ||
|
|
0db2a2c03e | ||
|
|
b96bf3871c | ||
|
|
bc4549244e | ||
|
|
f3e68804e8 | ||
|
|
1749cfa678 | ||
|
|
0fea54c542 | ||
|
|
cd7666e3bc | ||
|
|
8d1261133a | ||
|
|
0919856a05 | ||
|
|
b0f81b2245 | ||
|
|
6449d26cee | ||
|
|
80cabc61ee | ||
|
|
76dd935b35 | ||
|
|
f7f3240100 | ||
|
|
e4022c455a | ||
|
|
c6e50d8f1b | ||
|
|
c004a2b8bd | ||
|
|
d0aee031e9 | ||
|
|
cec1de0147 | ||
|
|
b4976c1ddc | ||
|
|
1611c7a224 | ||
|
|
d01081dab4 | ||
|
|
e5d4d07d32 | ||
|
|
f086377307 | ||
|
|
687b9e659c | ||
|
|
da0d51311b | ||
|
|
e314d9cbd9 | ||
|
|
95baf8d2f1 | ||
|
|
f951e07cea | ||
|
|
023e466dba | ||
|
|
abd6823744 | ||
|
|
c4c0e52e64 | ||
|
|
295332dc46 | ||
|
|
5c498348b8 | ||
|
|
8133bd4b7b | ||
|
|
fd12591756 | ||
|
|
b3c939ff15 | ||
|
|
0801778f60 | ||
|
|
8ffe63f54e | ||
|
|
893409dfc8 | ||
|
|
df2fda6077 | ||
|
|
4f5bbe0605 | ||
|
|
a975e50f1b | ||
|
|
a17c74fa0c | ||
|
|
5e625a5002 | ||
|
|
002fac0232 | ||
|
|
7e115543fa | ||
|
|
80d75bb164 | ||
|
|
d59885839a | ||
|
|
cbb0f765b8 | ||
|
|
726a2fbbc9 | ||
|
|
553b5aa02b | ||
|
|
2ff0d951ed | ||
|
|
1f88925144 | ||
|
|
250433a91a |
@@ -67,9 +67,9 @@ This is critical for better file inspection:
|
||||
|
||||
Use git locally for much faster analysis:
|
||||
|
||||
1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt`
|
||||
2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt`
|
||||
3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt`
|
||||
1. Get list of changed files: `git diff --name-only "$BASE_SHA" > changed_files.txt`
|
||||
2. Get the full diff: `git diff "$BASE_SHA" > pr_diff.txt`
|
||||
3. Get detailed file changes with status: `git diff --name-status "$BASE_SHA" > file_changes.txt`
|
||||
|
||||
### Step 1.5: Create Analysis Cache
|
||||
|
||||
|
||||
2
.gitattributes
vendored
@@ -13,4 +13,4 @@
|
||||
|
||||
# Generated files
|
||||
src/types/comfyRegistryTypes.ts linguist-generated=true
|
||||
src/types/generatedManagerTypes.ts linguist-generated=true
|
||||
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
|
||||
|
||||
109
.github/workflows/backport.yaml
vendored
@@ -4,10 +4,25 @@ on:
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to backport'
|
||||
required: true
|
||||
type: string
|
||||
force_rerun:
|
||||
description: 'Force rerun even if backports exist'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport')
|
||||
if: >
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'needs-backport')) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -15,6 +30,35 @@ jobs:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Validate inputs for manual triggers
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
# Validate PR number format
|
||||
if ! [[ "${{ inputs.pr_number }}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Invalid PR number format. Must be a positive integer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate PR exists and is merged
|
||||
if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged')
|
||||
if [ "$MERGED" != "true" ]; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate PR has needs-backport label
|
||||
if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label."
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -29,7 +73,7 @@ jobs:
|
||||
id: check-existing
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
# Check for existing backport PRs for this PR number
|
||||
EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName')
|
||||
@@ -39,6 +83,13 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# For manual triggers with force_rerun, proceed anyway
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_rerun }}" = "true" ]; then
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "::warning::Force rerun requested - existing backports will be updated"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found existing backport PRs:"
|
||||
echo "$EXISTING_BACKPORTS"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
@@ -50,8 +101,17 @@ jobs:
|
||||
run: |
|
||||
# Extract version labels (e.g., "1.24", "1.22")
|
||||
VERSIONS=""
|
||||
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
|
||||
for label in $(echo "$LABELS" | jq -r '.[].name'); do
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# For manual triggers, get labels from the PR
|
||||
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
|
||||
else
|
||||
# For automatic triggers, extract from PR event
|
||||
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
|
||||
LABELS=$(echo "$LABELS" | jq -r '.[].name')
|
||||
fi
|
||||
|
||||
for label in $LABELS; do
|
||||
# Match version labels like "1.24" (major.minor only)
|
||||
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
|
||||
# Validate the branch exists before adding to list
|
||||
@@ -75,12 +135,20 @@ jobs:
|
||||
if: steps.check-existing.outputs.skip != 'true'
|
||||
id: backport
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
FAILED=""
|
||||
SUCCESS=""
|
||||
|
||||
# Get PR data for manual triggers
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
fi
|
||||
|
||||
for version in ${{ steps.versions.outputs.versions }}; do
|
||||
echo "::group::Backporting to core/${version}"
|
||||
@@ -133,10 +201,18 @@ jobs:
|
||||
if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
# Get PR data for manual triggers
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
fi
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
IFS=':' read -r version branch <<< "${backport}"
|
||||
|
||||
@@ -165,9 +241,16 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
|
||||
PR_NUMBER="${{ inputs.pr_number }}"
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
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
|
||||
IFS=':' read -r version reason conflicts <<< "${failure}"
|
||||
|
||||
9
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -32,11 +32,10 @@ jobs:
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_frontend
|
||||
path: ComfyUI_frontend
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_devtools
|
||||
path: ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
- name: Checkout custom node repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
10
.github/workflows/test-ui.yaml
vendored
@@ -27,12 +27,10 @@ jobs:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_devtools'
|
||||
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
||||
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
3
.gitignore
vendored
@@ -22,6 +22,8 @@ dist-ssr
|
||||
*.local
|
||||
# Claude configuration
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
@@ -44,6 +46,7 @@ components.d.ts
|
||||
tests-ui/data/*
|
||||
tests-ui/ComfyUI_examples
|
||||
tests-ui/workflows/examples
|
||||
coverage/
|
||||
|
||||
# Browser tests
|
||||
/test-results/
|
||||
|
||||
@@ -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'],
|
||||
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.
|
||||
|
||||
70
CODEOWNERS
@@ -1,17 +1,61 @@
|
||||
# Admins
|
||||
* @Comfy-Org/comfy_frontend_devs
|
||||
# Desktop/Electron
|
||||
/src/types/desktop/ @webfiltered
|
||||
/src/constants/desktopDialogs.ts @webfiltered
|
||||
/src/constants/desktopMaintenanceTasks.ts @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/src/views/DesktopDialogView.vue @webfiltered
|
||||
/src/components/install/ @webfiltered
|
||||
/src/components/maintenance/ @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
|
||||
# Maintainers
|
||||
*.md @Comfy-Org/comfy_maintainer
|
||||
/tests-ui/ @Comfy-Org/comfy_maintainer
|
||||
/browser_tests/ @Comfy-Org/comfy_maintainer
|
||||
/.env_example @Comfy-Org/comfy_maintainer
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
|
||||
# Translations (AIGODLIKE team + shinshin86)
|
||||
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
|
||||
# Load 3D extension
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
|
||||
# Mask Editor extension
|
||||
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88
|
||||
|
||||
# Assets
|
||||
/src/platform/assets/ @arjansingh
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
|
||||
@@ -16,9 +16,14 @@ Without this flag, parallel tests will conflict and fail randomly.
|
||||
|
||||
### ComfyUI devtools
|
||||
|
||||
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
|
||||
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
|
||||
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
|
||||
|
||||
For local development, copy the devtools files to your ComfyUI installation:
|
||||
```bash
|
||||
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
```
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
||||
@@ -51,14 +56,6 @@ TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
|
||||
### Common Setup Issues
|
||||
|
||||
**Most tests require the new menu system** - Add to your test:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
```
|
||||
|
||||
### Release API Mocking
|
||||
|
||||
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.
|
||||
|
||||
1
browser_tests/assets/vueNodes/simple-triple.json
Normal file
@@ -0,0 +1 @@
|
||||
{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":null}],"properties":{"Node name for S&R":"KSampler"},"widgets_values":[0,"randomize",20,8,"euler","simple",1]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"clip","name":"clip","type":"CLIP","link":null},{"localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4}
|
||||
@@ -1643,7 +1643,7 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
// Hide canvas menu/info/selection toolbox by default.
|
||||
'Comfy.Graph.CanvasInfo': false,
|
||||
'Comfy.Graph.CanvasMenu': false,
|
||||
|
||||
@@ -22,6 +22,13 @@ export class VueNodeHelpers {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a Vue node by the node's title (displayed name in the header)
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of Vue nodes in the DOM
|
||||
*/
|
||||
|
||||
104
browser_tests/helpers/fitToView.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
interface FitToViewOptions {
|
||||
selectionOnly?: boolean
|
||||
zoom?: number
|
||||
padding?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantly fits the canvas view to graph content without waiting for UI animation.
|
||||
*
|
||||
* Lives outside the shared fixture to keep the default ComfyPage interactions user-oriented.
|
||||
*/
|
||||
export async function fitToViewInstant(
|
||||
comfyPage: ComfyPage,
|
||||
options: FitToViewOptions = {}
|
||||
) {
|
||||
const { selectionOnly = false, zoom = 0.75, padding = 10 } = options
|
||||
|
||||
const rectangles = await comfyPage.page.evaluate<
|
||||
ReadOnlyRect[] | null,
|
||||
{ selectionOnly: boolean }
|
||||
>(
|
||||
({ selectionOnly }) => {
|
||||
const app = window['app']
|
||||
if (!app?.canvas) return null
|
||||
|
||||
const canvas = app.canvas
|
||||
const items = (() => {
|
||||
if (selectionOnly && canvas.selectedItems?.size) {
|
||||
return Array.from(canvas.selectedItems)
|
||||
}
|
||||
try {
|
||||
return Array.from(canvas.positionableItems ?? [])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
if (!items.length) return null
|
||||
|
||||
const rects: ReadOnlyRect[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const rect = item?.boundingRect
|
||||
if (!rect) continue
|
||||
|
||||
const x = Number(rect[0])
|
||||
const y = Number(rect[1])
|
||||
const width = Number(rect[2])
|
||||
const height = Number(rect[3])
|
||||
|
||||
rects.push([x, y, width, height] as const)
|
||||
}
|
||||
|
||||
return rects.length ? rects : null
|
||||
},
|
||||
{ selectionOnly }
|
||||
)
|
||||
|
||||
if (!rectangles || rectangles.length === 0) return
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const [x, y, width, height] of rectangles) {
|
||||
minX = Math.min(minX, Number(x))
|
||||
minY = Math.min(minY, Number(y))
|
||||
maxX = Math.max(maxX, Number(x) + Number(width))
|
||||
maxY = Math.max(maxY, Number(y) + Number(height))
|
||||
}
|
||||
|
||||
const hasFiniteBounds =
|
||||
Number.isFinite(minX) &&
|
||||
Number.isFinite(minY) &&
|
||||
Number.isFinite(maxX) &&
|
||||
Number.isFinite(maxY)
|
||||
|
||||
if (!hasFiniteBounds) return
|
||||
|
||||
const bounds: ReadOnlyRect = [
|
||||
minX - padding,
|
||||
minY - padding,
|
||||
maxX - minX + 2 * padding,
|
||||
maxY - minY + 2 * padding
|
||||
]
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds, zoom }) => {
|
||||
const app = window['app']
|
||||
if (!app?.canvas) return
|
||||
|
||||
const canvas = app.canvas
|
||||
canvas.ds.fitToBounds(bounds, { zoom })
|
||||
canvas.setDirty(true, true)
|
||||
},
|
||||
{ bounds, zoom }
|
||||
)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Background Image Upload', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Reset the background image setting before each test
|
||||
|
||||
@@ -15,6 +15,10 @@ async function afterChange(comfyPage: ComfyPage) {
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Change Tracker', () => {
|
||||
test.describe('Undo/Redo', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
interface ChatHistoryEntry {
|
||||
prompt: string
|
||||
response: string
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
import type { Palette } from '../../src/schemas/colorPaletteSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const customColorPalettes: Record<string, Palette> = {
|
||||
obsidian: {
|
||||
version: 102,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should execute command', async ({ comfyPage }) => {
|
||||
await comfyPage.registerCommand('TestCommand', () => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Copy Paste', () => {
|
||||
test('Can copy and paste node', async ({ comfyPage }) => {
|
||||
await comfyPage.clickEmptyLatentNode()
|
||||
|
||||
@@ -4,6 +4,10 @@ import { expect } from '@playwright/test'
|
||||
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load workflow warning', () => {
|
||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('DOM Widget', () => {
|
||||
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/collapsed_multiline')
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Execution', () => {
|
||||
test('Report error on unconnected slot', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Feature Flags', () => {
|
||||
test('Client and server exchange feature flags on connection', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph', () => {
|
||||
// Should be able to fix link input slot index after swap the input order
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph Canvas Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Set link render mode to spline to make sure it's not affected by other tests'
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -4,6 +4,10 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Group Node', () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('groups/mixed_graph_items')
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
function listenForEvent(): Promise<Event> {
|
||||
return new Promise<Event>((resolve) => {
|
||||
document.addEventListener('litegraph:canvas', (e) => resolve(e), {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load Workflow in Media', () => {
|
||||
const fileNames = [
|
||||
'workflow.webp',
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('LOD Threshold', () => {
|
||||
test('Should switch to low quality mode at correct zoom threshold', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { ComfyApp } from '../../src/scripts/app'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node Badge', () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
// If an input is optional by node definition, it should be shown as
|
||||
// a hollow circle no matter what shape it was defined in the workflow JSON.
|
||||
test.describe('Optional input', () => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node search box', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Note Node', () => {
|
||||
test('Can load node nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Primitive Node', () => {
|
||||
test('Can load with correct size', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('primitive/primitive_node')
|
||||
|
||||
@@ -40,6 +40,7 @@ test.describe('Reroute Node', () => {
|
||||
|
||||
test.describe('LiteGraph Native Reroute Node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Canvas Right Click Menu', () => {
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickCanvas()
|
||||
|
||||
@@ -4,6 +4,9 @@ import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Settings Search functionality', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Register test settings to verify hidden/deprecated filtering
|
||||
|
||||
@@ -106,6 +106,11 @@ test.describe('Version Mismatch Warnings', () => {
|
||||
const dismissButton = warningToast.getByRole('button', { name: 'Close' })
|
||||
await dismissButton.click()
|
||||
|
||||
// Wait for the dismissed state to be persisted
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => !!localStorage.getItem('comfy.versionMismatch.dismissals')
|
||||
)
|
||||
|
||||
// Reload the page, keeping local storage
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('NodeHeader', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled')
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
@@ -2,6 +2,10 @@ 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 - Delete Key Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Enable Vue nodes rendering
|
||||
|
||||
221
browser_tests/tests/vueNodes/linkInteraction.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../../helpers/fitToView'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('Slot bounding box not available')
|
||||
return {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
})
|
||||
|
||||
test('should show a link dragging out from a slot when dragging on a slot', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
|
||||
const samplerNode = samplerNodes[0]
|
||||
const outputSlot = await samplerNode.getOutput(0)
|
||||
await outputSlot.removeLinks()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const slotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
|
||||
await expect(slotLocator).toBeVisible()
|
||||
|
||||
const start = await getCenter(slotLocator)
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Arbitrary value
|
||||
const dragTarget = {
|
||||
x: start.x + 180,
|
||||
y: start.y - 140
|
||||
}
|
||||
|
||||
await comfyMouse.move(start)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
try {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-dragging-link.png'
|
||||
)
|
||||
} finally {
|
||||
await comfyMouse.drop()
|
||||
}
|
||||
})
|
||||
|
||||
test('should create a link when dropping on a compatible slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
expect(vaeNodes.length).toBeGreaterThan(0)
|
||||
const vaeNode = vaeNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
expect(await vaeInput.getLinkCount()).toBe(1)
|
||||
|
||||
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return null
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return null
|
||||
|
||||
const linkId = source.outputs[0]?.links?.[0]
|
||||
if (linkId == null) return null
|
||||
|
||||
const link = graph.links[linkId]
|
||||
if (!link) return null
|
||||
|
||||
return {
|
||||
originId: link.origin_id,
|
||||
originSlot: link.origin_slot,
|
||||
targetId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
}
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails).toMatchObject({
|
||||
originId: samplerNode.id,
|
||||
originSlot: 0,
|
||||
targetId: vaeNode.id,
|
||||
targetSlot: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('should not create a link when slot types are incompatible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(clipNodes.length).toBeGreaterThan(0)
|
||||
const clipNode = clipNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const clipInput = await clipNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await clipInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return 0
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
})
|
||||
|
||||
test('should not create a link when dropping onto a slot on the same node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const samplerInput = await samplerNode.getInput(3)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await samplerInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return 0
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 54 KiB |
48
browser_tests/tests/vueNodes/lod.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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')
|
||||
})
|
||||
|
||||
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 buttonsInNodes = vueNodesContainer.getByRole('button')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(buttonsInNodes.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(buttonsInNodes.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(buttonsInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Node Selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const modifiers = [
|
||||
{ key: 'Control', name: 'ctrl' },
|
||||
{ key: 'Shift', name: 'shift' }
|
||||
] as const
|
||||
|
||||
for (const { key: modifier, name } of modifiers) {
|
||||
test(`should allow selecting multiple nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Empty Latent Image').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.page.getByText('KSampler').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
|
||||
})
|
||||
|
||||
test(`should allow de-selecting nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Load Checkpoint').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
45
browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const BYPASS_HOTKEY = 'Control+b'
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
|
||||
test.describe('Vue Node Bypass', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
})
|
||||
32
browser_tests/tests/vueNodes/nodeStates/error.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const ERROR_CLASS = /border-error/
|
||||
|
||||
test.describe('Vue Node Error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Close missing nodes warning dialog
|
||||
await comfyPage.page.getByRole('button', { name: 'Close' }).click()
|
||||
await comfyPage.page.waitForSelector('.comfy-missing-nodes', {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'UNKNOWN NODE'
|
||||
})
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
45
browser_tests/tests/vueNodes/nodeStates/mute.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const MUTE_HOTKEY = 'Control+m'
|
||||
const MUTE_CLASS = /opacity-50/
|
||||
|
||||
test.describe('Vue Node Mute', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling mute on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
})
|
||||
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Combo text widget', () => {
|
||||
test('Truncates text when resized', async ({ comfyPage }) => {
|
||||
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
|
||||
@@ -318,6 +322,9 @@ test.describe('Animated image widget', () => {
|
||||
test.describe('Load audio widget', () => {
|
||||
test('Can load audio', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_audio_widget')
|
||||
// Wait for the audio widget to be rendered in the DOM
|
||||
await comfyPage.page.waitForSelector('.comfy-audio', { state: 'attached' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
/* Test files should not be compiled */
|
||||
"noEmit": true,
|
||||
// "strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import { Plugin } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
interface ShimResult {
|
||||
code: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
|
||||
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
|
||||
|
||||
interface ImportMapSource {
|
||||
name: string
|
||||
|
||||
17
build/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Build scripts configuration */
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,13 @@ export default defineConfig([
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
projectService: true,
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'vite.config.mts',
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts'
|
||||
]
|
||||
},
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
@@ -77,12 +83,25 @@ export default defineConfig([
|
||||
'@typescript-eslint/prefer-as-const': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/no-import-type-side-effects': 'error',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'always'
|
||||
}
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
/* Toggle on to do additional until we can clean up existing violations.
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-unused-properties': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/no-useless-v-bind': 'error',
|
||||
// */
|
||||
'vue/one-component-per-file': 'off', // TODO: fix
|
||||
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
|
||||
// Restrict deprecated PrimeVue components
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" type="text/css" href="user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
|
||||
|
||||
<!-- Fullscreen mode on iOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<!-- Fullscreen mode on mobile browsers -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- Status bar style (eg. black or transparent) -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const config: KnipConfig = {
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated manager types
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'src/types/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
|
||||
11
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.0",
|
||||
"version": "1.28.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -18,15 +18,18 @@
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"test:all": "nx run test",
|
||||
"test:browser": "npx nx e2e",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
"test:component": "nx run test src/components/",
|
||||
"test:litegraph": "vitest run --config vitest.litegraph.config.ts",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"lint": "eslint src --cache",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:no-cache": "eslint src",
|
||||
"lint:fix:no-cache": "eslint src --fix",
|
||||
"knip": "knip --cache",
|
||||
@@ -94,6 +97,7 @@
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-component-type-helpers": "^3.0.7",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.7",
|
||||
"zip-dir": "^2.0.0",
|
||||
@@ -103,6 +107,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@iconify/json": "^2.2.380",
|
||||
"@primeuix/forms": "0.0.2",
|
||||
"@primeuix/styled": "0.3.2",
|
||||
@@ -126,7 +131,6 @@
|
||||
"algoliasearch": "^5.21.0",
|
||||
"axios": "^1.8.2",
|
||||
"chart.js": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"es-toolkit": "^1.39.9",
|
||||
@@ -144,7 +148,6 @@
|
||||
"primevue": "^4.2.5",
|
||||
"reka-ui": "^2.5.0",
|
||||
"semver": "^7.7.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
31
packages/tailwind-utils/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @comfyorg/tailwind-utils
|
||||
|
||||
Shared Tailwind CSS utility functions for the ComfyUI Frontend monorepo.
|
||||
|
||||
## Usage
|
||||
|
||||
The `cn` function combines `clsx` and `tailwind-merge` to handle conditional classes and resolve Tailwind conflicts.
|
||||
|
||||
```typescript
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// Use with conditional classes (object)
|
||||
<div :class="cn('transition-opacity', { 'opacity-75': !isHovered })" />
|
||||
|
||||
// Use with conditional classes (ternary)
|
||||
<button
|
||||
:class="cn('px-4 py-2', isActive ? 'bg-blue-500' : 'bg-gray-500')"
|
||||
/>
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
This package is part of the ComfyUI Frontend monorepo and is automatically available to all workspace packages.
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@comfyorg/tailwind-utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
30
packages/tailwind-utils/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@comfyorg/tailwind-utils",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Shared Tailwind CSS utilities for ComfyUI Frontend",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:util"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
8
packages/tailwind-utils/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import clsx, { type ClassArray } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export type { ClassValue } from 'clsx'
|
||||
|
||||
export function cn(...inputs: ClassArray) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
8
packages/tailwind-utils/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
38
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.4.73-0
|
||||
version: 0.4.73-0
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
@@ -86,9 +89,6 @@ importers:
|
||||
chart.js:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dompurify:
|
||||
specifier: ^3.2.5
|
||||
version: 3.2.5
|
||||
@@ -140,9 +140,6 @@ importers:
|
||||
semver:
|
||||
specifier: ^7.7.2
|
||||
version: 7.7.2
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
three:
|
||||
specifier: ^0.170.0
|
||||
version: 0.170.0
|
||||
@@ -339,6 +336,9 @@ importers:
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
|
||||
vue-component-type-helpers:
|
||||
specifier: ^3.0.7
|
||||
version: 3.0.7
|
||||
vue-eslint-parser:
|
||||
specifier: ^10.2.0
|
||||
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
|
||||
@@ -352,6 +352,19 @@ importers:
|
||||
specifier: ^3.24.1
|
||||
version: 3.24.1(zod@3.24.1)
|
||||
|
||||
packages/tailwind-utils:
|
||||
dependencies:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
tailwind-merge:
|
||||
specifier: ^2.2.0
|
||||
version: 2.6.0
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
|
||||
packages:
|
||||
|
||||
'@adobe/css-tools@4.4.3':
|
||||
@@ -5885,8 +5898,8 @@ packages:
|
||||
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
tailwindcss-primeui@0.6.1:
|
||||
resolution: {integrity: sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==}
|
||||
@@ -6320,6 +6333,9 @@ packages:
|
||||
vue-component-type-helpers@3.0.7:
|
||||
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
|
||||
|
||||
vue-component-type-helpers@3.0.8:
|
||||
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -8876,7 +8892,7 @@ snapshots:
|
||||
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.7
|
||||
vue-component-type-helpers: 3.0.8
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13072,7 +13088,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.2.9
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss-primeui@0.6.1(tailwindcss@4.1.12):
|
||||
dependencies:
|
||||
@@ -13542,6 +13558,8 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.0.7: {}
|
||||
|
||||
vue-component-type-helpers@3.0.8: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
|
||||
3
public/assets/images/comfy-brand-mark.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/assets/images/nvidia-logo-square.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
@@ -9,9 +9,18 @@ import { normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
|
||||
interface WidgetInfo {
|
||||
name?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
comfyPage.page.route('**/view**', async (route) => {
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
await route.fulfill({
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
@@ -20,6 +29,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const nodeDefs: ComfyNodeDefImpl[] = (
|
||||
Object.values(
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
// @ts-expect-error - app is dynamically added to window
|
||||
const api = window['app'].api as ComfyApi
|
||||
return await api.getNodeDefs()
|
||||
})
|
||||
@@ -52,7 +62,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels = {}
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
|
||||
for (const nodeDef of nodeDefs) {
|
||||
const inputNames = Object.values(nodeDef.inputs).map(
|
||||
@@ -65,12 +75,15 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const widgetsMappings = await comfyPage.page.evaluate(
|
||||
(args) => {
|
||||
const [nodeName, displayName, inputNames] = args
|
||||
// @ts-expect-error - LiteGraph is dynamically added to window
|
||||
const node = window['LiteGraph'].createNode(nodeName, displayName)
|
||||
if (!node.widgets?.length) return {}
|
||||
return Object.fromEntries(
|
||||
node.widgets
|
||||
.filter((w) => w?.name && !inputNames.includes(w.name))
|
||||
.map((w) => [w.name, w.label])
|
||||
.filter(
|
||||
(w: WidgetInfo) => w?.name && !inputNames.includes(w.name)
|
||||
)
|
||||
.map((w: WidgetInfo) => [w.name, w.label])
|
||||
)
|
||||
},
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
|
||||
@@ -72,7 +72,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
|
||||
const relativePath = file.replace(srcLocaleDir, '')
|
||||
const targetPath = join(tempBaseDir, relativePath)
|
||||
ensureDir(dirname(targetPath))
|
||||
writeFileSync(targetPath, readFileSync(file))
|
||||
writeFileSync(targetPath, readFileSync(file, 'utf8'))
|
||||
}
|
||||
console.log('Captured current locale files to temp/base/')
|
||||
}
|
||||
|
||||
14
scripts/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Script files configuration */
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import { computed, onMounted } from 'vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { useConflictDetection } from './composables/useConflictDetection'
|
||||
import { electronAPI, isElectron } from './utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
--color-sand-200: #d6cfc2;
|
||||
--color-sand-300: #888682;
|
||||
|
||||
--color-pure-white: #ffffff;
|
||||
|
||||
--color-slate-100: #9c9eab;
|
||||
--color-slate-200: #9fa2bd;
|
||||
--color-slate-300: #5b5e7d;
|
||||
@@ -929,48 +931,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
}
|
||||
/* End of [Desktop] Electron window specific styles */
|
||||
|
||||
/* Vue Node LOD (Level of Detail) System */
|
||||
/* These classes control rendering detail based on zoom level */
|
||||
|
||||
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
|
||||
.lg-node--lod-minimal {
|
||||
min-height: 32px;
|
||||
transition: min-height 0.2s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-minimal .lg-node-body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
|
||||
.lg-node--lod-reduced {
|
||||
transition: opacity 0.1s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget-label,
|
||||
.lg-node--lod-reduced .lg-slot-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-slot {
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget {
|
||||
margin: 2px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Full LOD (zoom > 0.8) - Complete detail rendering */
|
||||
.lg-node--lod-full {
|
||||
/* Uses default styling - no overrides needed */
|
||||
}
|
||||
|
||||
.lg-node {
|
||||
/* Disable text selection on all nodes */
|
||||
@@ -996,23 +956,51 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Global performance optimizations for LOD */
|
||||
.lg-node--lod-minimal,
|
||||
.lg-node--lod-reduced {
|
||||
/* Remove ALL expensive paint effects */
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-mask-image: none !important;
|
||||
mask-image: none !important;
|
||||
clip-path: none !important;
|
||||
/* 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;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Reduce paint complexity for minimal LOD */
|
||||
.lg-node--lod-minimal {
|
||||
/* Skip complex borders */
|
||||
border-radius: 0 !important;
|
||||
/* Use solid colors only */
|
||||
background-image: none !important;
|
||||
.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 */
|
||||
|
||||
98
src/base/common/async.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Cross-browser async utilities for scheduling tasks during browser idle time
|
||||
* with proper fallbacks for browsers that don't support requestIdleCallback.
|
||||
*
|
||||
* Implementation based on:
|
||||
* https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
|
||||
*/
|
||||
|
||||
interface IdleDeadline {
|
||||
didTimeout: boolean
|
||||
timeRemaining(): number
|
||||
}
|
||||
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation function that handles the actual scheduling logic.
|
||||
* Uses feature detection to determine whether to use native requestIdleCallback
|
||||
* or fall back to setTimeout-based implementation.
|
||||
*/
|
||||
let _runWhenIdle: (
|
||||
targetWindow: any,
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
/**
|
||||
* Execute the callback during the next browser idle period.
|
||||
* Falls back to setTimeout-based scheduling in browsers without native support.
|
||||
*/
|
||||
export let runWhenGlobalIdle: (
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: any = globalThis
|
||||
|
||||
if (
|
||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||
typeof safeGlobal.cancelIdleCallback !== 'function'
|
||||
) {
|
||||
// Fallback implementation for browsers without native support (e.g., Safari)
|
||||
_runWhenIdle = (_targetWindow, runner, _timeout?) => {
|
||||
setTimeout(() => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
|
||||
const end = Date.now() + 15
|
||||
const deadline: IdleDeadline = {
|
||||
didTimeout: true,
|
||||
timeRemaining() {
|
||||
return Math.max(0, end - Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
runner(Object.freeze(deadline))
|
||||
})
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Native requestIdleCallback implementation
|
||||
_runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
|
||||
const handle: number = targetWindow.requestIdleCallback(
|
||||
runner,
|
||||
typeof timeout === 'number' ? { timeout } : undefined
|
||||
)
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
targetWindow.cancelIdleCallback(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runWhenGlobalIdle = (runner, timeout) =>
|
||||
_runWhenIdle(globalThis, runner, timeout)
|
||||
})()
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
</div>
|
||||
@@ -98,12 +101,13 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
overflow-x: auto;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
background-color: black;
|
||||
overflow-y: hidden;
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
71
src/components/common/StartupDisplay.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div class="grid grid-rows-2 gap-8">
|
||||
<!-- Top container: Logo -->
|
||||
<div class="flex items-end justify-center">
|
||||
<img
|
||||
src="/assets/images/comfy-brand-mark.svg"
|
||||
:alt="t('g.logoAlt')"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bottom container: Progress and text -->
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<ProgressBar
|
||||
v-if="!hideProgress"
|
||||
:mode="progressMode"
|
||||
:value="progressPercentage ?? 0"
|
||||
:show-value="false"
|
||||
class="w-90 h-2 mt-8"
|
||||
:pt="{ value: { class: 'bg-brand-yellow' } }"
|
||||
/>
|
||||
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="statusText" class="text-lg text-neutral-400">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Props for the StartupDisplay component */
|
||||
interface StartupDisplayProps {
|
||||
/** Progress: 0-100 for determinate, undefined for indeterminate */
|
||||
progressPercentage?: number
|
||||
/** Main title text */
|
||||
title?: string
|
||||
/** Status text shown below the title */
|
||||
statusText?: string
|
||||
/** Hide the progress bar */
|
||||
hideProgress?: boolean
|
||||
/** Use full screen wrapper (default: true) */
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
progressPercentage,
|
||||
title,
|
||||
statusText,
|
||||
hideProgress = false,
|
||||
fullScreen = true
|
||||
} = defineProps<StartupDisplayProps>()
|
||||
|
||||
const progressMode = computed(() =>
|
||||
progressPercentage === undefined ? 'indeterminate' : 'determinate'
|
||||
)
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
fullScreen
|
||||
? 'flex items-center justify-center min-h-screen'
|
||||
: 'flex items-center justify-center'
|
||||
)
|
||||
</script>
|
||||
@@ -58,15 +58,14 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/composables/useManagerState'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
import PackInstallButton from './manager/button/PackInstallButton.vue'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
@@ -138,7 +137,7 @@ const allMissingNodesInstalled = computed(() => {
|
||||
})
|
||||
// Watch for completion and close dialog
|
||||
watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
if (allInstalled) {
|
||||
if (allInstalled && showInstallAllButton.value) {
|
||||
// Use nextTick to ensure state updates are complete
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { compare } from 'semver'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
@@ -68,7 +68,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compareVersions(b, a) // Reversed for descending order
|
||||
return compare(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tag from 'primevue/tag'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import ManagerHeader from './ManagerHeader.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: enMessages
|
||||
}
|
||||
})
|
||||
|
||||
describe('ManagerHeader', () => {
|
||||
const createWrapper = () => {
|
||||
return mount(ManagerHeader, {
|
||||
global: {
|
||||
plugins: [createPinia(), PrimeVue, i18n],
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
},
|
||||
components: {
|
||||
Tag
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the component title', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe(
|
||||
enMessages.manager.discoverCommunityContent
|
||||
)
|
||||
})
|
||||
|
||||
it('displays the legacy manager UI tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.exists()).toBe(true)
|
||||
expect(tag.text()).toContain(enMessages.manager.legacyManagerUI)
|
||||
})
|
||||
|
||||
it('applies info severity to the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.classes()).toContain('p-tag-info')
|
||||
})
|
||||
|
||||
it('displays info icon in the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const icon = wrapper.find('.pi-info-circle')
|
||||
expect(icon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has cursor-help class on the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.classes()).toContain('cursor-help')
|
||||
})
|
||||
|
||||
it('has proper structure with flex container', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4')
|
||||
expect(flexContainer.exists()).toBe(true)
|
||||
|
||||
const tag = flexContainer.find('[data-pc-name="tag"]')
|
||||
expect(tag.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-normal text-left">
|
||||
{{ $t('manager.discoverCommunityContent') }}
|
||||
</h2>
|
||||
<div class="flex justify-end ml-auto pr-4 pl-2">
|
||||
<Tag
|
||||
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
:value="$t('manager.legacyManagerUI')"
|
||||
class="cursor-help ml-2"
|
||||
:pt="{
|
||||
root: { class: 'text-xs' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
</script>
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- TransformPane for Vue node rendering -->
|
||||
<TransformPane
|
||||
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
@@ -43,8 +43,6 @@
|
||||
v-for="nodeData in allNodes"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:readonly="false"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
@@ -53,9 +51,6 @@
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
@node-click="handleNodeSelect"
|
||||
@update:collapsed="handleNodeCollapse"
|
||||
@update:title="handleNodeTitleUpdate"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
@@ -76,9 +71,9 @@
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@@ -116,13 +111,11 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -169,44 +162,34 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
|
||||
// Feature flags
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
|
||||
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
|
||||
const viewportCulling = useViewportCulling(
|
||||
isVueNodesEnabled,
|
||||
vueNodeLifecycle.vueNodeData,
|
||||
vueNodeLifecycle.nodeDataTrigger,
|
||||
vueNodeLifecycle.nodeManager
|
||||
)
|
||||
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
const { handleTransformUpdate } = useViewportCulling()
|
||||
|
||||
const nodePositions = vueNodeLifecycle.nodePositions
|
||||
const nodeSizes = vueNodeLifecycle.nodeSizes
|
||||
const allNodes = viewportCulling.allNodes
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
viewportCulling.handleTransformUpdate()
|
||||
// TODO: Fix paste position sync in separate PR
|
||||
vueNodeLifecycle.detectChangesInRAF.value()
|
||||
const handleVueNodeLifecycleReset = async () => {
|
||||
if (shouldRenderVueNodes.value) {
|
||||
vueNodeLifecycle.disposeNodeManagerAndSyncs()
|
||||
await nextTick()
|
||||
vueNodeLifecycle.initializeNodeManager()
|
||||
}
|
||||
}
|
||||
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
|
||||
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
|
||||
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
|
||||
|
||||
// Provide selection state to all Vue nodes
|
||||
const selectedNodeIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
canvasStore.selectedItems
|
||||
.filter((item) => item.id !== undefined)
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset)
|
||||
|
||||
watch(
|
||||
() => canvasStore.isInSubgraph,
|
||||
async (newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
}
|
||||
await handleVueNodeLifecycleReset()
|
||||
}
|
||||
)
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
|
||||
// Provide execution state to all Vue nodes
|
||||
useExecutionStateProvider()
|
||||
const allNodes = computed(() =>
|
||||
Array.from(vueNodeLifecycle.vueNodeData.value.values())
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
@@ -325,13 +308,27 @@ watch(
|
||||
removeSlotError(node)
|
||||
const nodeErrors = lastNodeErrors?.[node.id]
|
||||
if (!nodeErrors) continue
|
||||
for (const error of nodeErrors.errors) {
|
||||
if (error.extra_info && error.extra_info.input_name) {
|
||||
const inputIndex = node.findInputSlot(error.extra_info.input_name)
|
||||
|
||||
const validErrors = nodeErrors.errors.filter(
|
||||
(error) => error.extra_info?.input_name !== undefined
|
||||
)
|
||||
const slotErrorsChanged =
|
||||
validErrors.length > 0 &&
|
||||
validErrors.some((error) => {
|
||||
const inputName = error.extra_info!.input_name!
|
||||
const inputIndex = node.findInputSlot(inputName)
|
||||
if (inputIndex !== -1) {
|
||||
node.inputs[inputIndex].hasErrors = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Trigger Vue node data update if slot errors changed
|
||||
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
|
||||
comfyApp.graph.onTrigger('node:slot-errors:changed', {
|
||||
nodeId: node.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +401,7 @@ onMounted(async () => {
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
attachSlotLinkPreviewRenderer(comfyApp.canvas)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
|
||||
@@ -33,9 +33,11 @@ const tooltipText = ref('')
|
||||
const left = ref<string>()
|
||||
const top = ref<string>()
|
||||
|
||||
const hideTooltip = () => (tooltipText.value = '')
|
||||
function hideTooltip() {
|
||||
return (tooltipText.value = '')
|
||||
}
|
||||
|
||||
const showTooltip = async (tooltip: string | null | undefined) => {
|
||||
async function showTooltip(tooltip: string | null | undefined) {
|
||||
if (!tooltip) return
|
||||
|
||||
left.value = comfyApp.canvas.mouse[0] + 'px'
|
||||
@@ -56,9 +58,9 @@ const showTooltip = async (tooltip: string | null | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onIdle = () => {
|
||||
function onIdle() {
|
||||
const { canvas } = comfyApp
|
||||
const node = canvas.node_over
|
||||
const node = canvas?.node_over
|
||||
if (!node) return
|
||||
|
||||
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
|
||||
@@ -68,7 +70,7 @@ const onIdle = () => {
|
||||
ctor.title_mode !== LiteGraph.NO_TITLE &&
|
||||
canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title
|
||||
) {
|
||||
return showTooltip(nodeDef.description)
|
||||
return showTooltip(nodeDef?.description)
|
||||
}
|
||||
|
||||
if (node.flags?.collapsed) return
|
||||
@@ -83,7 +85,7 @@ const onIdle = () => {
|
||||
const inputName = node.inputs[inputSlot].name
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
|
||||
nodeDef.inputs[inputName]?.tooltip ?? ''
|
||||
nodeDef?.inputs[inputName]?.tooltip ?? ''
|
||||
)
|
||||
return showTooltip(translatedTooltip)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ const onIdle = () => {
|
||||
if (outputSlot !== -1) {
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
|
||||
nodeDef.outputs[outputSlot]?.tooltip ?? ''
|
||||
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
|
||||
)
|
||||
return showTooltip(translatedTooltip)
|
||||
}
|
||||
@@ -107,7 +109,7 @@ const onIdle = () => {
|
||||
if (widget && !isDOMWidget(widget)) {
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
|
||||
nodeDef.inputs[widget.name]?.tooltip ?? ''
|
||||
nodeDef?.inputs[widget.name]?.tooltip ?? ''
|
||||
)
|
||||
// Widget tooltip can be set dynamically, current translation collection does not support this.
|
||||
return showTooltip(widget.tooltip ?? translatedTooltip)
|
||||
|
||||
@@ -450,9 +450,9 @@ describe('SelectionToolbox', () => {
|
||||
describe('Event Handling', () => {
|
||||
it('should handle wheel events', async () => {
|
||||
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
|
||||
const handleWheelSpy = vi.fn()
|
||||
const forwardEventToCanvasSpy = vi.fn()
|
||||
mockCanvasInteractions.mockReturnValue({
|
||||
handleWheel: handleWheelSpy
|
||||
forwardEventToCanvas: forwardEventToCanvasSpy
|
||||
} as any)
|
||||
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
@@ -467,7 +467,7 @@ describe('SelectionToolbox', () => {
|
||||
const panel = wrapper.find('.panel')
|
||||
await panel.trigger('wheel')
|
||||
|
||||
expect(handleWheelSpy).toHaveBeenCalled()
|
||||
expect(forwardEventToCanvasSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
|
||||
content: 'p-1 h-10 flex flex-row gap-1'
|
||||
}"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<DeleteButton v-if="showDelete" />
|
||||
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
:pt="pt"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<div class="flex flex-col p-2 min-w-48">
|
||||
<MenuOptionItem
|
||||
@@ -49,11 +50,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import {
|
||||
forceCloseMoreOptionsSignal,
|
||||
moreOptionsOpen,
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
useMoreOptionsMenu
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
|
||||
import MenuOptionItem from './MenuOptionItem.vue'
|
||||
@@ -84,7 +86,7 @@ const currentSubmenu = ref<string | null>(null)
|
||||
|
||||
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
|
||||
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
|
||||
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const minimap = useMinimap()
|
||||
const containerStyles = minimap.containerStyles
|
||||
|
||||
@@ -152,9 +154,7 @@ const repositionPopover = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, {
|
||||
autoStart: false
|
||||
})
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
|
||||
|
||||
function openPopover(triggerEvent?: Event): boolean {
|
||||
const el = getButtonEl()
|
||||
|
||||
@@ -141,15 +141,15 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/composables/useManagerState'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
// Types
|
||||
interface MenuItem {
|
||||
@@ -168,7 +168,8 @@ const EXTERNAL_LINKS = {
|
||||
DOCS: 'https://docs.comfy.org/',
|
||||
DISCORD: 'https://www.comfy.org/discord',
|
||||
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
|
||||
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
|
||||
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
|
||||
} as const
|
||||
|
||||
@@ -222,7 +223,11 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
|
||||
const docsUrl =
|
||||
electronAPI().getPlatform() === 'darwin'
|
||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
|
||||
openExternalLink(docsUrl)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg">
|
||||
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
|
||||
<!-- Auto Update Setting -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.autoUpdate') }}
|
||||
</h3>
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
<p class="text-neutral-400 mt-1">
|
||||
{{ $t('install.settings.autoUpdateDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -32,14 +32,10 @@
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.allowMetrics') }}
|
||||
</h3>
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
<p class="text-neutral-400">
|
||||
{{ $t('install.settings.allowMetricsDescription') }}
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
|
||||
@click.prevent="showMetricsInfo"
|
||||
>
|
||||
<a href="#" @click.prevent="showMetricsInfo">
|
||||
{{ $t('install.settings.learnMoreAboutData') }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -51,7 +47,9 @@
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
modal
|
||||
dismissable-mask
|
||||
:header="$t('install.settings.dataCollectionDialog.title')"
|
||||
class="select-none"
|
||||
>
|
||||
<div class="text-neutral-300">
|
||||
<h4 class="font-medium mb-2">
|
||||
@@ -110,11 +108,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
<a href="https://comfy.org/privacy" target="_blank">
|
||||
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,130 +1,66 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 w-[600px] h-[30rem] select-none">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="grow flex flex-col gap-4 text-neutral-300">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.gpuSelection.selectGpu') }}
|
||||
</h2>
|
||||
<div
|
||||
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
|
||||
>
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.gpuPicker.title') }}
|
||||
</h2>
|
||||
|
||||
<p class="m-1 text-neutral-400">
|
||||
{{ $t('install.gpuSelection.selectGpuDescription') }}:
|
||||
</p>
|
||||
<!-- GPU Selection buttons - takes up remaining space and centers content -->
|
||||
<div class="flex-1 flex gap-8 justify-center items-center">
|
||||
<!-- Apple Metal / NVIDIA -->
|
||||
<HardwareOption
|
||||
v-if="platform === 'darwin'"
|
||||
:image-path="'/assets/images/apple-mps-logo.png'"
|
||||
placeholder-text="Apple Metal"
|
||||
subtitle="Apple Metal"
|
||||
:value="'mps'"
|
||||
:selected="selected === 'mps'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('mps')"
|
||||
/>
|
||||
<HardwareOption
|
||||
v-else
|
||||
:image-path="'/assets/images/nvidia-logo-square.jpg'"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:value="'nvidia'"
|
||||
:selected="selected === 'nvidia'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<!-- CPU -->
|
||||
<HardwareOption
|
||||
placeholder-text="CPU"
|
||||
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
|
||||
:value="'cpu'"
|
||||
:selected="selected === 'cpu'"
|
||||
@click="pickGpu('cpu')"
|
||||
/>
|
||||
<!-- Manual Install -->
|
||||
<HardwareOption
|
||||
placeholder-text="Manual Install"
|
||||
:subtitle="$t('install.gpuPicker.manualSubtitle')"
|
||||
:value="'unsupported'"
|
||||
:selected="selected === 'unsupported'"
|
||||
@click="pickGpu('unsupported')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- GPU Selection buttons -->
|
||||
<div
|
||||
class="flex gap-2 text-center transition-opacity"
|
||||
:class="{ selected: selected }"
|
||||
>
|
||||
<!-- NVIDIA -->
|
||||
<div
|
||||
v-if="platform !== 'darwin'"
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'nvidia' }"
|
||||
role="button"
|
||||
@click="pickGpu('nvidia')"
|
||||
>
|
||||
<img
|
||||
class="m-12"
|
||||
alt="NVIDIA logo"
|
||||
width="196"
|
||||
height="32"
|
||||
src="/assets/images/nvidia-logo.svg"
|
||||
/>
|
||||
</div>
|
||||
<!-- MPS -->
|
||||
<div
|
||||
v-if="platform === 'darwin'"
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'mps' }"
|
||||
role="button"
|
||||
@click="pickGpu('mps')"
|
||||
>
|
||||
<img
|
||||
class="rounded-lg hover-brighten"
|
||||
alt="Apple Metal Performance Shaders Logo"
|
||||
width="292"
|
||||
ratio
|
||||
src="/assets/images/apple-mps-logo.png"
|
||||
/>
|
||||
</div>
|
||||
<!-- Manual configuration -->
|
||||
<div
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'unsupported' }"
|
||||
role="button"
|
||||
@click="pickGpu('unsupported')"
|
||||
>
|
||||
<img
|
||||
class="m-12"
|
||||
alt="Manual configuration"
|
||||
width="196"
|
||||
src="/assets/images/manual-configuration.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details on selected GPU -->
|
||||
<p v-if="selected === 'nvidia'" class="m-1">
|
||||
<Tag icon="pi pi-check" severity="success" :value="'CUDA'" />
|
||||
{{ $t('install.gpuSelection.nvidiaDescription') }}
|
||||
</p>
|
||||
|
||||
<p v-if="selected === 'mps'" class="m-1">
|
||||
<Tag icon="pi pi-check" severity="success" :value="'MPS'" />
|
||||
{{ $t('install.gpuSelection.mpsDescription') }}
|
||||
</p>
|
||||
|
||||
<div v-if="selected === 'unsupported'" class="text-neutral-300">
|
||||
<p class="m-1">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
{{ $t('install.gpuSelection.customSkipsPython') }}
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>
|
||||
{{ $t('install.gpuSelection.customComfyNeedsPython') }}
|
||||
</strong>
|
||||
</li>
|
||||
<li>{{ $t('install.gpuSelection.customManualVenv') }}</li>
|
||||
<li>{{ $t('install.gpuSelection.customInstallRequirements') }}</li>
|
||||
<li>{{ $t('install.gpuSelection.customMayNotWork') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="selected === 'cpu'">
|
||||
<p class="m-1">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
{{ $t('install.gpuSelection.cpuModeDescription') }}
|
||||
</p>
|
||||
<p class="m-1">
|
||||
{{ $t('install.gpuSelection.cpuModeDescription2') }}
|
||||
</p>
|
||||
<div class="pt-12 px-24 h-16">
|
||||
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
|
||||
<Tag
|
||||
:value="$t('install.gpuPicker.recommended')"
|
||||
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
|
||||
/>
|
||||
<i-lucide:badge-check class="text-neutral-300 text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="transition-opacity flex gap-3 h-0"
|
||||
:class="{
|
||||
'opacity-40': selected && selected !== 'cpu'
|
||||
}"
|
||||
>
|
||||
<ToggleSwitch
|
||||
v-model="cpuMode"
|
||||
input-id="cpu-mode"
|
||||
class="-translate-y-40"
|
||||
/>
|
||||
<label for="cpu-mode" class="select-none">
|
||||
{{ $t('install.gpuSelection.enableCpuMode') }}
|
||||
</label>
|
||||
<div class="text-neutral-300 px-24">
|
||||
<p v-show="descriptionText" class="leading-relaxed">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -132,20 +68,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import Tag from 'primevue/tag'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HardwareOption from '@/components/install/HardwareOption.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cpuMode = computed({
|
||||
get: () => selected.value === 'cpu',
|
||||
set: (value) => {
|
||||
selected.value = value ? 'cpu' : null
|
||||
}
|
||||
})
|
||||
const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
required: true
|
||||
})
|
||||
@@ -153,55 +81,23 @@ const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
const electron = electronAPI()
|
||||
const platform = electron.getPlatform()
|
||||
|
||||
const pickGpu = (value: typeof selected.value) => {
|
||||
const newValue = selected.value === value ? null : value
|
||||
selected.value = newValue
|
||||
const showRecommendedBadge = computed(
|
||||
() => selected.value === 'mps' || selected.value === 'nvidia'
|
||||
)
|
||||
|
||||
const descriptionKeys = {
|
||||
mps: 'appleMetal',
|
||||
nvidia: 'nvidia',
|
||||
cpu: 'cpu',
|
||||
unsupported: 'manual'
|
||||
} as const
|
||||
|
||||
const descriptionText = computed(() => {
|
||||
const key = selected.value ? descriptionKeys[selected.value] : undefined
|
||||
return st(`install.gpuPicker.${key}Description`, '')
|
||||
})
|
||||
|
||||
const pickGpu = (value: TorchDeviceType) => {
|
||||
selected.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-tag {
|
||||
--p-tag-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hover-brighten {
|
||||
@apply transition-colors;
|
||||
transition-property: filter, box-shadow;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(107%) contrast(105%);
|
||||
box-shadow: 0 0 0.25rem #ffffff79;
|
||||
}
|
||||
}
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-neutral-900 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
div.selected {
|
||||
.gpu-button:not(.selected) {
|
||||
@apply opacity-50 hover:opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
.gpu-button {
|
||||
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
|
||||
|
||||
&.selected {
|
||||
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
@apply pointer-events-none opacity-40;
|
||||
}
|
||||
|
||||
.p-card-header {
|
||||
@apply text-center grow;
|
||||
}
|
||||
|
||||
.p-card-body {
|
||||
@apply text-center pt-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
73
src/components/install/HardwareOption.stories.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
|
||||
import HardwareOption from './HardwareOption.vue'
|
||||
|
||||
const meta: Meta<typeof HardwareOption> = {
|
||||
title: 'Desktop/Components/HardwareOption',
|
||||
component: HardwareOption,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [{ name: 'dark', value: '#1a1a1a' }]
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
selected: { control: 'boolean' },
|
||||
imagePath: { control: 'text' },
|
||||
placeholderText: { control: 'text' },
|
||||
subtitle: { control: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AppleMetalSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AppleMetalUnselected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CPUOption: Story = {
|
||||
args: {
|
||||
placeholderText: 'CPU',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'cpu',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const ManualInstall: Story = {
|
||||
args: {
|
||||
placeholderText: 'Manual Install',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'unsupported',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const NvidiaSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/nvidia-logo-square.jpg',
|
||||
placeholderText: 'NVIDIA',
|
||||
subtitle: 'NVIDIA',
|
||||
value: 'nvidia',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
55
src/components/install/HardwareOption.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Recommended Badge -->
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
|
||||
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
|
||||
)
|
||||
"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- Icon/Logo Area - Rounded square container -->
|
||||
<div
|
||||
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="imagePath"
|
||||
:src="imagePath"
|
||||
:alt="placeholderText"
|
||||
class="w-full h-full object-cover"
|
||||
style="object-position: 57% center"
|
||||
draggable="false"
|
||||
/>
|
||||
<span v-else class="text-xl font-medium text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div v-if="subtitle" class="text-center mt-4">
|
||||
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface Props {
|
||||
imagePath?: string
|
||||
placeholderText: string
|
||||
subtitle?: string
|
||||
value: TorchDeviceType
|
||||
selected?: boolean
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
79
src/components/install/InstallFooter.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
|
||||
<!-- Back button -->
|
||||
<Button
|
||||
v-if="currentStep !== '1'"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
icon="pi pi-arrow-left"
|
||||
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
|
||||
@click="$emit('previous')"
|
||||
/>
|
||||
<div v-else></div>
|
||||
|
||||
<!-- Step indicators in center -->
|
||||
<StepList class="flex justify-center items-center gap-3 select-none">
|
||||
<Step value="1" :pt="stepPassthrough">
|
||||
{{ $t('install.gpu') }}
|
||||
</Step>
|
||||
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
|
||||
{{ $t('install.installLocation') }}
|
||||
</Step>
|
||||
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
|
||||
{{ $t('install.desktopSettings') }}
|
||||
</Step>
|
||||
</StepList>
|
||||
|
||||
<!-- Next/Install button -->
|
||||
<Button
|
||||
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
|
||||
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
|
||||
:pt="{
|
||||
label: { class: 'text-neutral-900 font-inter font-black' }
|
||||
}"
|
||||
:disabled="!canProceed"
|
||||
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PassThrough } from '@primevue/core'
|
||||
import Button from 'primevue/button'
|
||||
import Step, { type StepPassThroughOptions } from 'primevue/step'
|
||||
import StepList from 'primevue/steplist'
|
||||
|
||||
defineProps<{
|
||||
/** Current step index as string ('1', '2', '3', '4') */
|
||||
currentStep: string
|
||||
/** Whether the user can proceed to the next step */
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
previous: []
|
||||
next: []
|
||||
install: []
|
||||
}>()
|
||||
|
||||
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
|
||||
root: { class: 'flex-none p-0 m-0' },
|
||||
header: ({ context }) => ({
|
||||
class: [
|
||||
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
|
||||
context.active
|
||||
? 'bg-brand-yellow w-8 rounded-sm'
|
||||
: 'bg-neutral-700 w-2.5',
|
||||
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
].join(' ')
|
||||
}),
|
||||
number: { class: 'hidden' },
|
||||
title: { class: 'hidden' }
|
||||
}
|
||||
</script>
|
||||
148
src/components/install/InstallLocationPicker.stories.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import InstallLocationPicker from './InstallLocationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof InstallLocationPicker> = {
|
||||
title: 'Desktop/Components/InstallLocationPicker',
|
||||
component: InstallLocationPicker,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
// Mock electron API
|
||||
;(window as any).electronAPI = {
|
||||
getSystemPaths: () =>
|
||||
Promise.resolve({
|
||||
defaultInstallPath: '/Users/username/ComfyUI'
|
||||
}),
|
||||
validateInstallPath: () =>
|
||||
Promise.resolve({
|
||||
isValid: true,
|
||||
exists: false,
|
||||
canWrite: true,
|
||||
freeSpace: 100000000000,
|
||||
requiredSpace: 10000000000,
|
||||
isNonDefaultDrive: false
|
||||
}),
|
||||
validateComfyUISource: () =>
|
||||
Promise.resolve({
|
||||
isValid: true
|
||||
}),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default story with accordion expanded
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with different background to test transparency
|
||||
export const OnNeutral900: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-900 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with debug overlay showing background colors
|
||||
export const DebugBackgrounds: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8 relative">
|
||||
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
|
||||
<div>Parent bg: neutral-950 (#0a0a0a)</div>
|
||||
<div>Accordion content: bg-transparent</div>
|
||||
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
|
||||
</div>
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,103 +1,215 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 w-[600px]">
|
||||
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.chooseInstallationLocation') }}
|
||||
<div class="grow flex flex-col gap-6 text-neutral-300">
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.locationPicker.title') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-neutral-400 my-0">
|
||||
{{ $t('install.installLocationDescription') }}
|
||||
<p class="text-center text-neutral-400 px-12">
|
||||
{{ $t('install.locationPicker.subtitle') }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<IconField class="flex-1">
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
class="w-full"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<InputIcon
|
||||
v-tooltip.top="$t('install.installLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</IconField>
|
||||
<Button icon="pi pi-folder" class="w-12" @click="browsePath" />
|
||||
<!-- Path Input -->
|
||||
<div class="flex gap-2 px-12">
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
:placeholder="$t('install.locationPicker.pathPlaceholder')"
|
||||
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder-open"
|
||||
severity="secondary"
|
||||
class="bg-neutral-700 hover:bg-neutral-600 border-0"
|
||||
@click="browsePath"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Message v-if="pathError" severity="error" class="whitespace-pre-line">
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
<Message v-if="pathExists" severity="warn">
|
||||
{{ $t('install.pathExists') }}
|
||||
</Message>
|
||||
<Message v-if="nonDefaultDrive" severity="warn">
|
||||
{{ $t('install.nonDefaultDrive') }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- System Paths Info -->
|
||||
<div class="bg-neutral-800 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100">
|
||||
{{ $t('install.systemLocations') }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-folder text-neutral-400" />
|
||||
<span class="text-neutral-400">App Data:</span>
|
||||
<span class="text-neutral-200">{{ appData }}</span>
|
||||
<span
|
||||
v-tooltip="$t('install.appDataLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-desktop text-neutral-400" />
|
||||
<span class="text-neutral-400">App Path:</span>
|
||||
<span class="text-neutral-200">{{ appPath }}</span>
|
||||
<span
|
||||
v-tooltip="$t('install.appPathLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</div>
|
||||
<!-- Error Messages -->
|
||||
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
|
||||
<Message
|
||||
v-if="pathError"
|
||||
severity="error"
|
||||
class="whitespace-pre-line w-full"
|
||||
>
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
<Message v-if="pathExists" severity="warn" class="w-full">
|
||||
{{ $t('install.pathExists') }}
|
||||
</Message>
|
||||
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
|
||||
{{ $t('install.nonDefaultDrive') }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Sections using PrimeVue Accordion -->
|
||||
<Accordion
|
||||
v-model:value="activeAccordionIndex"
|
||||
:multiple="true"
|
||||
class="location-picker-accordion"
|
||||
:pt="{
|
||||
root: 'bg-transparent border-0',
|
||||
panel: {
|
||||
root: 'border-0 mb-0'
|
||||
},
|
||||
header: {
|
||||
root: 'border-0',
|
||||
content:
|
||||
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
|
||||
toggleicon: 'text-xs order-first mr-0'
|
||||
},
|
||||
content: {
|
||||
root: 'bg-transparent border-0',
|
||||
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.migrateFromExisting') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<MigrationPicker
|
||||
v-model:source-path="migrationSourcePath"
|
||||
v-model:migration-item-ids="migrationItemIds"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<AccordionPanel value="1">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.chooseDownloadServers') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<template
|
||||
v-for="([item, modelValue], index) in mirrors"
|
||||
:key="item.settingId + item.mirror"
|
||||
>
|
||||
<Divider v-if="index > 0" class="my-8" />
|
||||
|
||||
<MirrorItem
|
||||
v-model="modelValue.value"
|
||||
:item="item"
|
||||
@state-change="validationStates[index] = $event"
|
||||
/>
|
||||
</template>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import Button from 'primevue/button'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import Divider from 'primevue/divider'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { type ModelRef, computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import {
|
||||
PYPI_MIRROR,
|
||||
PYTHON_MIRROR,
|
||||
type UVMirror
|
||||
} from '@/constants/uvMirrors'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const installPath = defineModel<string>('installPath', { required: true })
|
||||
const pathError = defineModel<string>('pathError', { required: true })
|
||||
const migrationSourcePath = defineModel<string>('migrationSourcePath')
|
||||
const migrationItemIds = defineModel<string[]>('migrationItemIds')
|
||||
const pythonMirror = defineModel<string>('pythonMirror', {
|
||||
default: ''
|
||||
})
|
||||
const pypiMirror = defineModel<string>('pypiMirror', {
|
||||
default: ''
|
||||
})
|
||||
const torchMirror = defineModel<string>('torchMirror', {
|
||||
default: ''
|
||||
})
|
||||
|
||||
const { device } = defineProps<{ device: TorchDeviceType | null }>()
|
||||
|
||||
const pathExists = ref(false)
|
||||
const nonDefaultDrive = ref(false)
|
||||
const appData = ref('')
|
||||
const appPath = ref('')
|
||||
const inputTouched = ref(false)
|
||||
|
||||
// Accordion state - array of active panel values
|
||||
const activeAccordionIndex = ref<string[] | undefined>(undefined)
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
// Get system paths on component mount
|
||||
// Mirror configuration logic
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.NightlyCpu,
|
||||
fallbackMirror: TorchMirrorUrl.NightlyCpu
|
||||
}
|
||||
case 'nvidia':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
settingId,
|
||||
mirror: PYPI_MIRROR.mirror,
|
||||
fallbackMirror: PYPI_MIRROR.fallbackMirror
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
[
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
|
||||
] as [UVMirror, ModelRef<string>][]
|
||||
).map(([item, modelValue]) => [
|
||||
userIsInChina.value ? useFallbackMirror(item) : item,
|
||||
modelValue
|
||||
])
|
||||
)
|
||||
|
||||
const validationStates = ref<ValidationState[]>(
|
||||
mirrors.value.map(() => ValidationState.IDLE)
|
||||
)
|
||||
|
||||
// Get default install path on component mount
|
||||
onMounted(async () => {
|
||||
const paths = await electron.getSystemPaths()
|
||||
appData.value = paths.appData
|
||||
appPath.value = paths.appPath
|
||||
installPath.value = paths.defaultInstallPath
|
||||
|
||||
await validatePath(paths.defaultInstallPath)
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
const validatePath = async (path: string | undefined) => {
|
||||
@@ -151,3 +263,52 @@ const onFocus = async () => {
|
||||
await validatePath(installPath.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
@apply px-12;
|
||||
|
||||
.p-accordionpanel {
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e933';
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e902';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
45
src/components/install/MigrationPicker.stories.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MigrationPicker from './MigrationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof MigrationPicker> = {
|
||||
title: 'Desktop/Components/MigrationPicker',
|
||||
component: MigrationPicker,
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
;(window as any).electronAPI = {
|
||||
validateComfyUISource: () => Promise.resolve({ isValid: true }),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { MigrationPicker },
|
||||
setup() {
|
||||
const sourcePath = ref('')
|
||||
const migrationItemIds = ref<string[]>([])
|
||||
return { sourcePath, migrationItemIds }
|
||||
},
|
||||
template:
|
||||
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
|
||||
})
|
||||
}
|
||||