Compare commits
78 Commits
queue_text
...
vue-node-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3166acb825 | ||
|
|
7e153cf10b | ||
|
|
e488b2abce | ||
|
|
20e4427602 | ||
|
|
33e99da325 | ||
|
|
a7461c49c7 | ||
|
|
102590c2c2 | ||
|
|
928dfc6b8e | ||
|
|
593ac576da | ||
|
|
0858356dcf | ||
|
|
471018a962 | ||
|
|
344c6f6244 | ||
|
|
b2918a4cf6 | ||
|
|
6d4eafb07a | ||
|
|
97edaade63 | ||
|
|
83af274339 | ||
|
|
f251af25cc | ||
|
|
e2024c1e79 | ||
|
|
e8236e1a85 | ||
|
|
79a63de70e | ||
|
|
3eee7cde0b | ||
|
|
6bbe46009b | ||
|
|
1ca71caf45 | ||
|
|
65289b1927 | ||
|
|
9e2180dcd8 | ||
|
|
defea56ba5 | ||
|
|
e6bca95a5f | ||
|
|
841e3f743a | ||
|
|
73be826956 | ||
|
|
398dc6d8a6 | ||
|
|
d1f4341319 | ||
|
|
8c8bb1a3b7 | ||
|
|
05ef25a7a3 | ||
|
|
86aeeb87bb | ||
|
|
f7093f6ce0 | ||
|
|
88817e5bc0 | ||
|
|
3ac8aa248c | ||
|
|
75ab54ee04 | ||
|
|
a5729c9e06 | ||
|
|
d1da3476da | ||
|
|
ac01bff67e | ||
|
|
ec4ced26e7 | ||
|
|
40cfc43c54 | ||
|
|
35a811c5cf | ||
|
|
3d4ac07957 | ||
|
|
54055e7707 | ||
|
|
69f33f322f | ||
|
|
b81c2f7cd2 | ||
|
|
6289ac9182 | ||
|
|
86a7dd05a3 | ||
|
|
dee00edc5f | ||
|
|
afac449f41 | ||
|
|
aca1a2a194 | ||
|
|
4dfe75d68b | ||
|
|
2c37dba143 | ||
|
|
3936454ffd | ||
|
|
30ee669f5c | ||
|
|
811ddd6165 | ||
|
|
0cdaa512c8 | ||
|
|
3a514ca63b | ||
|
|
405b5fc5b7 | ||
|
|
0eaf7d11b6 | ||
|
|
fa58c04b3a | ||
|
|
9c84c9e250 | ||
|
|
6f9f048b4a | ||
|
|
768faeee7e | ||
|
|
eba81efb4b | ||
|
|
f9d92b8198 | ||
|
|
c4bbe7fee1 | ||
|
|
8f4f5f8e5f | ||
|
|
9e137d9924 | ||
|
|
a084b55db7 | ||
|
|
835f318999 | ||
|
|
c35d44c491 | ||
|
|
38d3e15103 | ||
|
|
674d04c9cf | ||
|
|
8209765eec | ||
|
|
9d48638464 |
43
.claude/commands/add-missing-i18n.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Add Missing i18n Translations
|
||||
|
||||
## Task: Add English translations for all new localized strings
|
||||
|
||||
### Step 1: Identify new translation keys
|
||||
Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions.
|
||||
|
||||
### Step 2: Add translations to English locale file
|
||||
For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`.
|
||||
|
||||
### Key-to-JSON mapping rules:
|
||||
- Translation keys use dot notation to represent nested JSON structure
|
||||
- Convert dot notation to nested JSON objects when adding to the locale file
|
||||
- Example: The key `g.user.name` maps to:
|
||||
```json
|
||||
{
|
||||
"g": {
|
||||
"user": {
|
||||
"name": "User Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important notes:
|
||||
1. **Only modify the English locale file** (`src/locales/en/main.json`)
|
||||
2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow
|
||||
3. **Exception for manual translations**: Only add translations to non-English locale files if:
|
||||
- You have specific domain knowledge that would produce a more accurate translation than the automated system
|
||||
- The automated translation would likely be incorrect due to technical terminology or context-specific meaning
|
||||
|
||||
### Example workflow:
|
||||
1. If you added `t('settings.advanced.enable')` in a Vue component
|
||||
2. Add to `src/locales/en/main.json`:
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"advanced": {
|
||||
"enable": "Enable advanced settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
53
.claude/commands/create-widget.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Create a Vue Widget for ComfyUI
|
||||
|
||||
Your task is to create a new Vue widget for ComfyUI based on the widget specification: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Follow the comprehensive guide in `vue-widget-conversion/vue-widget-guide.md` to create the widget. This guide contains step-by-step instructions, examples from actual PRs, and best practices.
|
||||
|
||||
### Key Steps to Follow:
|
||||
|
||||
1. **Understand the Widget Type**
|
||||
- Analyze what type of widget is needed: $ARGUMENTS
|
||||
- Identify the data type (string, number, array, object, etc.)
|
||||
- Determine if it needs special behaviors (execution state awareness, dynamic management, etc.)
|
||||
|
||||
2. **Component Creation**
|
||||
- Create Vue component in `src/components/graph/widgets/`
|
||||
- REQUIRED: Use PrimeVue components (reference `vue-widget-conversion/primevue-components.md`)
|
||||
- Use Composition API with `<script setup>`
|
||||
- Implement proper v-model binding with `defineModel`
|
||||
|
||||
3. **Composable Pattern**
|
||||
- Always create widget constructor composable in `src/composables/widgets/`
|
||||
- Only create node-level composable in `src/composables/node/` if the widget needs dynamic management
|
||||
- Follow the dual composable pattern explained in the guide
|
||||
|
||||
4. **Registration**
|
||||
- Register in `src/scripts/widgets.ts`
|
||||
- Use appropriate widget type name
|
||||
|
||||
5. **Testing**
|
||||
- Create unit tests for composables
|
||||
- Test with actual nodes that use the widget
|
||||
|
||||
### Important Requirements:
|
||||
|
||||
- **Always use PrimeVue components** - Check `vue-widget-conversion/primevue-components.md` for available components
|
||||
- Use TypeScript with proper types
|
||||
- Follow Vue 3 Composition API patterns
|
||||
- Use Tailwind CSS for styling (no custom CSS unless absolutely necessary)
|
||||
- Implement proper error handling and validation
|
||||
- Consider performance (use v-show vs v-if appropriately)
|
||||
|
||||
### Before Starting:
|
||||
|
||||
1. First read through the entire guide at `vue-widget-conversion/vue-widget-guide.md`
|
||||
2. Check existing widget implementations for similar patterns
|
||||
3. Identify which PrimeVue component(s) best fit the widget requirements
|
||||
|
||||
### Widget Specification to Implement:
|
||||
$ARGUMENTS
|
||||
|
||||
Begin by analyzing the widget requirements and proposing an implementation plan based on the guide.
|
||||
57
.claude/commands/verify-visually.md
Normal file
@@ -0,0 +1,57 @@
|
||||
Your task is to perform visual verification of our recent changes to ensure they display correctly in the browser. This verification is critical for catching visual regressions, layout issues, and ensuring our UI changes render properly for end users.
|
||||
|
||||
<instructions>
|
||||
Follow these steps systematically to verify our changes:
|
||||
|
||||
1. **Server Setup**
|
||||
- Check if the dev server is running on port 5173 using browser navigation or port checking
|
||||
- If not running, start it with `npm run dev` from the root directory
|
||||
- If the server fails to start, provide detailed troubleshooting steps by reading package.json and README.md for accurate instructions
|
||||
- Wait for the server to be fully ready before proceeding
|
||||
|
||||
2. **Visual Testing Process**
|
||||
- Navigate to http://localhost:5173/
|
||||
- For each target page (specified in arguments or recently changed files):
|
||||
* Navigate to the page using direct URL or site navigation
|
||||
* Take a high-quality screenshot
|
||||
* Analyze the screenshot for the specific changes we implemented
|
||||
* Document any visual issues or improvements needed
|
||||
|
||||
3. **Quality Verification**
|
||||
Check each page for:
|
||||
- Content accuracy and completeness
|
||||
- Proper styling and layout alignment
|
||||
- Responsive design elements
|
||||
- Navigation functionality
|
||||
- Image loading and display
|
||||
- Typography and readability
|
||||
- Color scheme consistency
|
||||
- Interactive elements (buttons, links, forms)
|
||||
</instructions>
|
||||
|
||||
<examples>
|
||||
Common issues to watch for:
|
||||
- Broken layouts or overlapping elements
|
||||
- Missing images or broken image links
|
||||
- Inconsistent styling or spacing
|
||||
- Navigation menu problems
|
||||
- Mobile responsiveness issues
|
||||
- Text overflow or truncation
|
||||
- Color contrast problems
|
||||
</examples>
|
||||
|
||||
<reporting>
|
||||
For each page tested, provide:
|
||||
1. Page URL and screenshot
|
||||
2. Confirmation that changes display correctly OR detailed description of issues found
|
||||
3. Any design improvement suggestions
|
||||
4. Overall assessment of visual quality
|
||||
|
||||
If you find issues, be specific about:
|
||||
- Exact location of the problem
|
||||
- Expected vs actual behavior
|
||||
- Severity level (critical, important, minor)
|
||||
- Suggested fix if obvious
|
||||
</reporting>
|
||||
|
||||
Remember: Take your time with each screenshot and analysis. Visual quality directly impacts user experience and our project's professional appearance.
|
||||
59
.cursorrules
@@ -1,26 +1,25 @@
|
||||
// Vue 3 Composition API .cursorrules
|
||||
# Vue 3 Composition API Project Rules
|
||||
|
||||
// Vue 3 Composition API best practices
|
||||
const vue3CompositionApiBestPractices = [
|
||||
"Use setup() function for component logic",
|
||||
"Utilize ref and reactive for reactive state",
|
||||
"Implement computed properties with computed()",
|
||||
"Use watch and watchEffect for side effects",
|
||||
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
|
||||
"Utilize provide/inject for dependency injection",
|
||||
"Use vue 3.5 style of default prop declaration. Example:
|
||||
## Vue 3 Composition API Best Practices
|
||||
- Use setup() function for component logic
|
||||
- Utilize ref and reactive for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Use vue 3.5 style of default prop declaration. Example:
|
||||
|
||||
```typescript
|
||||
const { nodes, showTotal = true } = defineProps<{
|
||||
nodes: ApiNodeCost[]
|
||||
showTotal?: boolean
|
||||
}>()
|
||||
```
|
||||
|
||||
",
|
||||
"Organize vue component in <template> <script> <style> order",
|
||||
]
|
||||
- Organize vue component in <template> <script> <style> order
|
||||
|
||||
// Folder structure
|
||||
const folderStructure = `
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
components/
|
||||
constants/
|
||||
@@ -30,16 +29,25 @@ src/
|
||||
services/
|
||||
App.vue
|
||||
main.ts
|
||||
`;
|
||||
```
|
||||
|
||||
// Tailwind CSS best practices
|
||||
const tailwindCssBestPractices = [
|
||||
"Use Tailwind CSS for styling",
|
||||
"Implement responsive design with Tailwind CSS",
|
||||
]
|
||||
## Styling Guidelines
|
||||
- Use Tailwind CSS for styling
|
||||
- Implement responsive design with Tailwind CSS
|
||||
|
||||
// Additional instructions
|
||||
const additionalInstructions = `
|
||||
## PrimeVue Component Guidelines
|
||||
DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
- Dropdown → Use Select (import from 'primevue/select')
|
||||
- OverlayPanel → Use Popover (import from 'primevue/popover')
|
||||
- Calendar → Use DatePicker (import from 'primevue/datepicker')
|
||||
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
|
||||
- Sidebar → Use Drawer (import from 'primevue/drawer')
|
||||
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
|
||||
- TabMenu → Use Tabs without panels
|
||||
- Steps → Use Stepper without panels
|
||||
- InlineMessage → Use Message component
|
||||
|
||||
## Development Guidelines
|
||||
1. Leverage VueUse functions for performance-enhancing styles
|
||||
2. Use lodash for utility functions
|
||||
3. Use TypeScript for type safety
|
||||
@@ -49,6 +57,5 @@ const additionalInstructions = `
|
||||
7. Implement proper error handling
|
||||
8. Follow Vue 3 style guide and naming conventions
|
||||
9. Use Vite for fast development and building
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation
|
||||
entries in src/locales/en/main.json.
|
||||
`;
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Never use deprecated PrimeVue components listed above
|
||||
|
||||
@@ -29,3 +29,7 @@ DISABLE_TEMPLATES_PROXY=false
|
||||
# If playwright tests are being run via vite dev server, Vue plugins will
|
||||
# invalidate screenshots. When `true`, vite plugins will not be loaded.
|
||||
DISABLE_VUE_PLUGINS=false
|
||||
|
||||
# Algolia credentials required for developing with the new custom node manager.
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,7 +1,9 @@
|
||||
name: Bug Report
|
||||
description: "Something is not behaving as expected."
|
||||
title: "[Bug]: "
|
||||
description: 'Something is not behaving as expected.'
|
||||
title: '[Bug]: '
|
||||
labels: ['Potential Bug']
|
||||
type: Bug
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -10,8 +12,15 @@ body:
|
||||
|
||||
- **1:** You are running the latest version of ComfyUI.
|
||||
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
|
||||
- **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
|
||||
`--disable-all-custom-nodes` command line argument.
|
||||
|
||||
- type: checkboxes
|
||||
id: custom-nodes-test
|
||||
attributes:
|
||||
label: Custom Node Testing
|
||||
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
|
||||
options:
|
||||
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature Request]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Feature Request]: '
|
||||
labels: ['enhancement']
|
||||
type: Feature
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
2
.github/workflows/dev-release.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
2
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -136,7 +136,7 @@ jobs:
|
||||
git commit -m "Update locales"
|
||||
|
||||
- name: Install SSH key For PUSH
|
||||
uses: shimataro/ssh-key-action@v2
|
||||
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
|
||||
with:
|
||||
# PR private key from action server
|
||||
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
|
||||
|
||||
2
.github/workflows/i18n-node-defs.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: "Update locales for node definitions"
|
||||
|
||||
4
.github/workflows/release.yaml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
name: dist-files
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
2
.github/workflows/update-electron-types.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'
|
||||
|
||||
2
.github/workflows/update-litegraph.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'
|
||||
|
||||
92
.github/workflows/update-manager-types.yaml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
name: Update ComfyUI-Manager API Types
|
||||
|
||||
on:
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-manager-types:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Checkout ComfyUI-Manager repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI-Manager
|
||||
path: ComfyUI-Manager
|
||||
clean: true
|
||||
|
||||
- name: Get Manager commit information
|
||||
id: manager-info
|
||||
run: |
|
||||
cd ComfyUI-Manager
|
||||
MANAGER_COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "commit=${MANAGER_COMMIT}" >> $GITHUB_OUTPUT
|
||||
cd ..
|
||||
|
||||
- name: Generate Manager API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
|
||||
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
if [ ! -f ./src/types/generatedManagerTypes.ts ]; then
|
||||
echo "Error: Types file was not generated."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if file is not empty
|
||||
if [ ! -s ./src/types/generatedManagerTypes.ts ]; then
|
||||
echo "Error: Generated types file is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if [[ -z $(git status --porcelain ./src/types/generatedManagerTypes.ts) ]]; then
|
||||
echo "No changes to ComfyUI-Manager API types detected."
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected in ComfyUI-Manager API types."
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
title: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
body: |
|
||||
## Automated API Type Update
|
||||
|
||||
This PR updates the ComfyUI-Manager API types from the latest ComfyUI-Manager OpenAPI specification.
|
||||
|
||||
- Manager commit: ${{ steps.manager-info.outputs.commit }}
|
||||
- Generated on: ${{ github.event.repository.updated_at }}
|
||||
|
||||
These types are automatically generated using openapi-typescript.
|
||||
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
|
||||
base: main
|
||||
labels: Manager
|
||||
delete-branch: true
|
||||
add-paths: |
|
||||
src/types/generatedManagerTypes.ts
|
||||
2
.github/workflows/update-registry-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
|
||||
|
||||
2
.github/workflows/version-bump.yaml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'
|
||||
|
||||
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@executeautomation/playwright-mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
12
CLAUDE.md
@@ -1,4 +1,3 @@
|
||||
|
||||
- use npm run to see what commands are available
|
||||
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
|
||||
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
|
||||
@@ -36,3 +35,14 @@
|
||||
- Follow Vue 3 style guide and naming conventions
|
||||
- Use Vite for fast development and building
|
||||
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
|
||||
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
|
||||
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
* `Dropdown` → Use `Select` (import from 'primevue/select')
|
||||
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
|
||||
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
|
||||
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
|
||||
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
|
||||
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
||||
* `TabMenu` → Use `Tabs` without panels
|
||||
* `Steps` → Use `Stepper` without panels
|
||||
* `InlineMessage` → Use `Message` component
|
||||
|
||||
162
README.md
@@ -609,6 +609,68 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d
|
||||
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Recommended MCP Integrations
|
||||
|
||||
#### Playwright Browser Automation
|
||||
|
||||
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
|
||||
|
||||
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
|
||||
|
||||
##### Setup for Claude Code
|
||||
|
||||
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
|
||||
|
||||
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
|
||||
|
||||
|
||||
```
|
||||
> navigate to localhost:5173 and take a screenshot.
|
||||
tell me the items in the menubar
|
||||
|
||||
● I'll navigate to localhost:5173 and take a
|
||||
screenshot to see the menubar items.
|
||||
|
||||
● playwright:playwright_navigate (MCP)(url: …
|
||||
"http://local
|
||||
host:5173")
|
||||
⎿ Navigated to http://localhost:5173
|
||||
|
||||
● playwright:playwright_screenshot (MCP)(name: "lo…
|
||||
alhost_5173
|
||||
_menubar")
|
||||
|
||||
⎿ Screenshot saved to: ../../../Downloads/localho
|
||||
st_5173_menubar-2025-05-30T17-23-00-942Z.png
|
||||
⎿ Screenshot also stored in memory with name:
|
||||
'localhost_5173_menubar'
|
||||
|
||||
● Read(../../../Downloads/localhost_5173_menubar-2…
|
||||
25-05-30T17-23-00-942Z.png)
|
||||
⎿ Read image (94.3KB)
|
||||
|
||||
● Based on the screenshot, I can see the menubar at
|
||||
the top of the ComfyUI interface contains the
|
||||
following items:
|
||||
|
||||
Main Menubar Items:
|
||||
- Workflow - Menu for workflow-related actions
|
||||
- Edit - Menu for editing operations
|
||||
- Help - Menu for help and documentation
|
||||
|
||||
Additional UI Elements:
|
||||
- ComfyUI Logo - On the far left
|
||||
- Workflow Tab - Shows "Unsaved Workflow" with a
|
||||
dropdown and close button
|
||||
- Layout Controls - On the far right (grid view
|
||||
and hamburger menu icons)
|
||||
|
||||
The interface shows a typical ComfyUI workflow
|
||||
graph with nodes like "Load Checkpoint", "CLIP
|
||||
Text Encode (Prompt)", "KSampler", and "Empty
|
||||
Latent Image" connected with colored cables.
|
||||
```
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `npm i` to install all dependencies
|
||||
@@ -641,100 +703,8 @@ See [locales/README.md](src/locales/README.md) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
|
||||
For comprehensive troubleshooting and technical support, please refer to our official documentation:
|
||||
|
||||
> **Desktop Users**: For issues specific to the desktop application, please refer to the [ComfyUI desktop repository](https://github.com/Comfy-Org/desktop).
|
||||
|
||||
### Debugging Custom Node (Extension) Issues
|
||||
|
||||
If you're experiencing crashes, errors, or unexpected behavior with ComfyUI, it's often caused by custom nodes (extensions). Follow these steps to identify and resolve the issues:
|
||||
|
||||
#### Step 1: Verify if custom nodes are causing the problem
|
||||
|
||||
Run ComfyUI with the `--disable-all-custom-nodes` flag:
|
||||
|
||||
```bash
|
||||
python main.py --disable-all-custom-nodes
|
||||
```
|
||||
|
||||
If the issue disappears, a custom node is the culprit. Proceed to the next step.
|
||||
|
||||
#### Step 2: Identify the problematic custom node using binary search
|
||||
|
||||
Rather than disabling nodes one by one, use this more efficient approach:
|
||||
|
||||
1. Temporarily move half of your custom nodes out of the `custom_nodes` directory
|
||||
```bash
|
||||
# Create a temporary directory
|
||||
# Linux/Mac
|
||||
mkdir ~/custom_nodes_disabled
|
||||
|
||||
# Windows
|
||||
mkdir %USERPROFILE%\custom_nodes_disabled
|
||||
|
||||
# Move half of your custom nodes (assuming you have node1 through node8)
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node1 custom_nodes/node2 custom_nodes/node3 custom_nodes/node4 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node1 custom_nodes\node2 custom_nodes\node3 custom_nodes\node4 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
2. Run ComfyUI again
|
||||
- If the issue persists: The problem is in nodes 5-8 (the remaining half)
|
||||
- If the issue disappears: The problem is in nodes 1-4 (the moved half)
|
||||
|
||||
3. Let's assume the issue disappeared, so the problem is in nodes 1-4. Move half of these for the next test:
|
||||
```bash
|
||||
# Move nodes 3-4 back to custom_nodes
|
||||
# Linux/Mac
|
||||
mv ~/custom_nodes_disabled/node3 ~/custom_nodes_disabled/node4 custom_nodes/
|
||||
|
||||
# Windows
|
||||
move %USERPROFILE%\custom_nodes_disabled\node3 %USERPROFILE%\custom_nodes_disabled\node4 custom_nodes\
|
||||
```
|
||||
|
||||
4. Run ComfyUI again
|
||||
- If the issue reappears: The problem is in nodes 3-4
|
||||
- If issue still gone: The problem is in nodes 1-2
|
||||
|
||||
5. Let's assume the issue reappeared, so the problem is in nodes 3-4. Test each one:
|
||||
```bash
|
||||
# Move node 3 back to disabled
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node3 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node3 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
6. Run ComfyUI again
|
||||
- If the issue disappears: node3 is the problem
|
||||
- If issue persists: node4 is the problem
|
||||
|
||||
7. Repeat until you identify the specific problematic node
|
||||
|
||||
#### Step 3: Update or replace the problematic node
|
||||
|
||||
Once identified:
|
||||
1. Check for updates to the problematic custom node
|
||||
2. Consider alternatives with similar functionality
|
||||
3. Report the issue to the custom node developer with specific details
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
- **"Module not found" errors**: Usually indicates missing Python dependencies. Check the custom node's `requirements.txt` file for required packages and install them:
|
||||
```bash
|
||||
pip install -r custom_nodes/problematic_node/requirements.txt
|
||||
```
|
||||
|
||||
- **Frontend or Templates Package Not Updated**: After updating ComfyUI via Git, ensure you update the frontend dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- **Can't Find Custom Node**: Make sure to disable node validation in ComfyUI settings.
|
||||
|
||||
- **Error Toast About Workflow Failing Validation**: Report the issue to the ComfyUI team. As a temporary workaround, disable workflow validation in settings.
|
||||
|
||||
- **Login Issues When Not on Localhost**: Normal login is only available when accessing from localhost. If you're running ComfyUI via LAN, another domain, or headless, you can use our API key feature to authenticate. The API key lets you log in normally through the UI. Generate an API key at [platform.comfy.org/login](https://platform.comfy.org/login) and use it in the API Key field in the login dialog or with the `--api-key` command line argument. Refer to our [API Key Integration Guide](https://docs.comfy.org/essentials/comfyui-server/api-key-integration#integration-of-api-key-to-use-comfyui-api-nodes) for complete setup instructions.
|
||||
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
|
||||
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
|
||||
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting
|
||||
59
browser_tests/assets/model_metadata_widget_mismatch.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"models": [
|
||||
{
|
||||
"name": "outdated_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
},
|
||||
{
|
||||
"name": "another_outdated_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["current_selected_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -103,7 +103,7 @@ test.describe('Missing models warning', () => {
|
||||
}
|
||||
])
|
||||
}
|
||||
comfyPage.page.route(
|
||||
await comfyPage.page.route(
|
||||
'**/api/experiment/models',
|
||||
(route) => route.fulfill(modelFoldersRes),
|
||||
{ times: 1 }
|
||||
@@ -121,7 +121,7 @@ test.describe('Missing models warning', () => {
|
||||
}
|
||||
])
|
||||
}
|
||||
comfyPage.page.route(
|
||||
await comfyPage.page.route(
|
||||
'**/api/experiment/models/text_encoders',
|
||||
(route) => route.fulfill(clipModelsRes),
|
||||
{ times: 1 }
|
||||
@@ -133,6 +133,18 @@ test.describe('Missing models warning', () => {
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display warning when model metadata exists but widget values have changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// This tests the scenario where outdated model metadata exists in the workflow
|
||||
// but the actual selected models (widget values) have changed
|
||||
await comfyPage.loadWorkflow('model_metadata_widget_mismatch')
|
||||
|
||||
// The missing models warning should NOT appear
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
|
||||
test.skip('Should download missing model when clicking download button', async ({
|
||||
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
556
browser_tests/tests/nodeHelp.spec.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const app = window['app']
|
||||
const canvas = app.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||
canvas.setDirty(true, true)
|
||||
}, nodePos)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await nodeRef.click('title')
|
||||
}
|
||||
|
||||
test.describe('Node Help', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', () => {
|
||||
test('Should open help menu for selected node', async ({ comfyPage }) => {
|
||||
// Load a workflow with a node
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
// Select a single node (KSampler) using node references
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found in the workflow')
|
||||
}
|
||||
|
||||
// Select the node with panning to ensure toolbox is visible
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
// Wait for selection overlay container and toolbox to appear
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
// Click the help button in the selection toolbox
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
|
||||
// Verify that the node library sidebar is opened
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.selectedTabButton
|
||||
).toBeVisible()
|
||||
|
||||
// Verify that the help page is shown for the correct node
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler')
|
||||
await expect(helpPage.locator('.node-help-content')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Library Sidebar', () => {
|
||||
test('Should open help menu from node library', async ({ comfyPage }) => {
|
||||
// Open the node library sidebar
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
|
||||
// Wait for node library to load
|
||||
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
|
||||
|
||||
// Search for KSampler to make it easier to find
|
||||
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
|
||||
'KSampler'
|
||||
)
|
||||
|
||||
// Find the KSampler node in search results
|
||||
const ksamplerNode = comfyPage.page
|
||||
.locator('.tree-explorer-node-label')
|
||||
.filter({ hasText: 'KSampler' })
|
||||
.first()
|
||||
await expect(ksamplerNode).toBeVisible()
|
||||
|
||||
// Hover over the node to show action buttons
|
||||
await ksamplerNode.hover()
|
||||
|
||||
// Click the help button
|
||||
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
|
||||
// Verify that the help page is shown
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler')
|
||||
await expect(helpPage.locator('.node-help-content')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show node library tab when clicking back from help page', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open the node library sidebar
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
|
||||
// Wait for node library to load
|
||||
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
|
||||
|
||||
// Search for KSampler
|
||||
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
|
||||
'KSampler'
|
||||
)
|
||||
|
||||
// Find and interact with the node
|
||||
const ksamplerNode = comfyPage.page
|
||||
.locator('.tree-explorer-node-label')
|
||||
.filter({ hasText: 'KSampler' })
|
||||
.first()
|
||||
await ksamplerNode.hover()
|
||||
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
|
||||
await helpButton.click()
|
||||
|
||||
// Verify help page is shown
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler')
|
||||
|
||||
// Click the back button - use a more specific selector
|
||||
const backButton = comfyPage.page.locator('button:has(.pi-arrow-left)')
|
||||
await expect(backButton).toBeVisible()
|
||||
await backButton.click()
|
||||
|
||||
// Verify that we're back to the node library view
|
||||
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput
|
||||
).toBeVisible()
|
||||
|
||||
// Verify help page is no longer visible
|
||||
await expect(helpPage.locator('.node-help-content')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Help Content', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
})
|
||||
|
||||
test('Should display loading state while fetching help', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock slow network response
|
||||
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '# Test Help Content\nThis is test help content.'
|
||||
})
|
||||
})
|
||||
|
||||
// Load workflow and select a node
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
// Click help button
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
// Verify loading spinner is shown
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
|
||||
|
||||
// Wait for content to load
|
||||
await expect(helpPage).toContainText('Test Help Content')
|
||||
})
|
||||
|
||||
test('Should display fallback content when help file not found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock 404 response for help files
|
||||
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// Load workflow and select a node
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
// Click help button
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
// Verify fallback content is shown (description, inputs, outputs)
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('Description')
|
||||
await expect(helpPage).toContainText('Inputs')
|
||||
await expect(helpPage).toContainText('Outputs')
|
||||
})
|
||||
|
||||
test('Should render markdown with images correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock response with markdown containing images
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Documentation
|
||||
|
||||

|
||||

|
||||
|
||||
## Parameters
|
||||
- **steps**: Number of steps
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler Documentation')
|
||||
|
||||
// Check that relative image paths are prefixed correctly
|
||||
const relativeImage = helpPage.locator('img[alt="Example Image"]')
|
||||
await expect(relativeImage).toBeVisible()
|
||||
await expect(relativeImage).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/docs\/KSampler\/example\.jpg/
|
||||
)
|
||||
|
||||
// Check that absolute URLs are not modified
|
||||
const externalImage = helpPage.locator('img[alt="External Image"]')
|
||||
await expect(externalImage).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/image.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should render video elements with source tags in markdown', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock response with video elements
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Demo
|
||||
|
||||
<video src="demo.mp4" controls autoplay></video>
|
||||
<video src="/absolute/video.mp4" controls></video>
|
||||
|
||||
<video controls>
|
||||
<source src="video.mp4" type="video/mp4">
|
||||
<source src="https://example.com/video.webm" type="video/webm">
|
||||
</video>
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
|
||||
// Check relative video paths are prefixed
|
||||
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
|
||||
await expect(relativeVideo).toBeVisible()
|
||||
await expect(relativeVideo).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/docs\/KSampler\/demo\.mp4/
|
||||
)
|
||||
await expect(relativeVideo).toHaveAttribute('controls', '')
|
||||
await expect(relativeVideo).toHaveAttribute('autoplay', '')
|
||||
|
||||
// Check absolute paths are not modified
|
||||
const absoluteVideo = helpPage.locator('video[src="/absolute/video.mp4"]')
|
||||
await expect(absoluteVideo).toHaveAttribute('src', '/absolute/video.mp4')
|
||||
|
||||
// Check video source elements
|
||||
const relativeVideoSource = helpPage.locator('source[src*="video.mp4"]')
|
||||
await expect(relativeVideoSource).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/docs\/KSampler\/video\.mp4/
|
||||
)
|
||||
|
||||
const externalVideoSource = helpPage.locator(
|
||||
'source[src="https://example.com/video.webm"]'
|
||||
)
|
||||
await expect(externalVideoSource).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/video.webm'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should handle custom node documentation paths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// First load workflow with custom node
|
||||
await comfyPage.loadWorkflow('group_node_v1.3.3')
|
||||
|
||||
// Mock custom node documentation with fallback
|
||||
await comfyPage.page.route(
|
||||
'**/extensions/*/docs/*/en.md',
|
||||
async (route) => {
|
||||
await route.fulfill({ status: 404 })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# Custom Node Documentation
|
||||
|
||||
This is documentation for a custom node.
|
||||
|
||||

|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// Find and select a custom/group node
|
||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.nodes.map((n: any) => n.id)
|
||||
})
|
||||
if (nodeRefs.length > 0) {
|
||||
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
||||
await selectNodeWithPan(comfyPage, firstNode)
|
||||
}
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
if (await helpButton.isVisible()) {
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('Custom Node Documentation')
|
||||
|
||||
// Check image path for custom nodes
|
||||
const image = helpPage.locator('img[alt="Custom Image"]')
|
||||
await expect(image).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
|
||||
// Mock response with potentially dangerous content
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# Safe Content
|
||||
|
||||
<script>alert('XSS')</script>
|
||||
<img src="x" onerror="alert('XSS')">
|
||||
<a href="javascript:alert('XSS')">Dangerous Link</a>
|
||||
<iframe src="evil.com"></iframe>
|
||||
|
||||
<!-- Safe content -->
|
||||
<video src="safe.mp4" controls></video>
|
||||
<img src="safe.jpg" alt="Safe Image">
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
|
||||
// Dangerous elements should be removed
|
||||
await expect(helpPage.locator('script')).toHaveCount(0)
|
||||
await expect(helpPage.locator('iframe')).toHaveCount(0)
|
||||
|
||||
// Check that onerror attribute is removed
|
||||
const images = helpPage.locator('img')
|
||||
const imageCount = await images.count()
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
const img = images.nth(i)
|
||||
const onError = await img.getAttribute('onerror')
|
||||
expect(onError).toBeNull()
|
||||
}
|
||||
|
||||
// Check that javascript: links are sanitized
|
||||
const links = helpPage.locator('a')
|
||||
const linkCount = await links.count()
|
||||
for (let i = 0; i < linkCount; i++) {
|
||||
const link = links.nth(i)
|
||||
const href = await link.getAttribute('href')
|
||||
if (href !== null) {
|
||||
expect(href).not.toContain('javascript:')
|
||||
}
|
||||
}
|
||||
|
||||
// Safe content should remain
|
||||
await expect(helpPage.locator('video[src*="safe.mp4"]')).toBeVisible()
|
||||
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
|
||||
これは日本語のドキュメントです。
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
|
||||
This is English documentation.
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// Set locale to Japanese
|
||||
await comfyPage.setSetting('Comfy.Locale', 'ja')
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSamplerノード')
|
||||
await expect(helpPage).toContainText('これは日本語のドキュメントです')
|
||||
|
||||
// Reset locale
|
||||
await comfyPage.setSetting('Comfy.Locale', 'en')
|
||||
})
|
||||
|
||||
test('Should handle network errors gracefully', async ({ comfyPage }) => {
|
||||
// Mock network error
|
||||
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
|
||||
await route.abort('failed')
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
|
||||
// Should show fallback content (node description)
|
||||
await expect(helpPage).toBeVisible()
|
||||
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
|
||||
|
||||
// Should show some content even on error
|
||||
const content = await helpPage.textContent()
|
||||
expect(content).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Should update help content when switching between nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different help content for different nodes
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '# KSampler Help\n\nThis is KSampler documentation.'
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/docs/CheckpointLoaderSimple/en.md',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '# Checkpoint Loader Help\n\nThis is Checkpoint Loader documentation.'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
// Select KSampler first
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler Help')
|
||||
await expect(helpPage).toContainText('This is KSampler documentation')
|
||||
|
||||
// Now select Checkpoint Loader
|
||||
const checkpointNodes = await comfyPage.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
await selectNodeWithPan(comfyPage, checkpointNodes[0])
|
||||
|
||||
// Click help button again
|
||||
const helpButton2 = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton2.click()
|
||||
|
||||
// Content should update
|
||||
await expect(helpPage).toContainText('Checkpoint Loader Help')
|
||||
await expect(helpPage).toContainText(
|
||||
'This is Checkpoint Loader documentation'
|
||||
)
|
||||
await expect(helpPage).not.toContainText('KSampler documentation')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 98 KiB |
@@ -32,7 +32,9 @@ test.describe('Templates', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('should have all required thumbnail media for each template', async ({
|
||||
// TODO: Re-enable this test once issue resolved
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
|
||||
test.skip('should have all required thumbnail media for each template', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 78 KiB |
89
copy-widget-resources.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to copy vue-widget-conversion folder and .claude/commands/create-widget.md
|
||||
# to another local copy of the same repository
|
||||
|
||||
# Check if destination directory was provided
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <destination-repo-path>"
|
||||
echo "Example: $0 /home/c_byrne/projects/comfyui-frontend-testing/ComfyUI_frontend-clone-8"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the destination directory from first argument
|
||||
DEST_DIR="$1"
|
||||
|
||||
# Source files/directories (relative to script location)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_WIDGET_DIR="$SCRIPT_DIR/vue-widget-conversion"
|
||||
SOURCE_COMMAND_FILE="$SCRIPT_DIR/.claude/commands/create-widget.md"
|
||||
|
||||
# Destination paths
|
||||
DEST_WIDGET_DIR="$DEST_DIR/vue-widget-conversion"
|
||||
DEST_COMMAND_DIR="$DEST_DIR/.claude/commands"
|
||||
DEST_COMMAND_FILE="$DEST_COMMAND_DIR/create-widget.md"
|
||||
|
||||
# Check if destination directory exists
|
||||
if [ ! -d "$DEST_DIR" ]; then
|
||||
echo "Error: Destination directory does not exist: $DEST_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source vue-widget-conversion directory exists
|
||||
if [ ! -d "$SOURCE_WIDGET_DIR" ]; then
|
||||
echo "Error: Source vue-widget-conversion directory not found: $SOURCE_WIDGET_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source command file exists
|
||||
if [ ! -f "$SOURCE_COMMAND_FILE" ]; then
|
||||
echo "Error: Source command file not found: $SOURCE_COMMAND_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Copying widget resources to: $DEST_DIR"
|
||||
|
||||
# Copy vue-widget-conversion directory
|
||||
echo "Copying vue-widget-conversion directory..."
|
||||
if [ -d "$DEST_WIDGET_DIR" ]; then
|
||||
echo " Warning: Destination vue-widget-conversion already exists. Overwriting..."
|
||||
rm -rf "$DEST_WIDGET_DIR"
|
||||
fi
|
||||
cp -r "$SOURCE_WIDGET_DIR" "$DEST_WIDGET_DIR"
|
||||
echo " ✓ Copied vue-widget-conversion directory"
|
||||
|
||||
# Create .claude/commands directory if it doesn't exist
|
||||
echo "Creating .claude/commands directory structure..."
|
||||
mkdir -p "$DEST_COMMAND_DIR"
|
||||
echo " ✓ Created .claude/commands directory"
|
||||
|
||||
# Copy create-widget.md command
|
||||
echo "Copying create-widget.md command..."
|
||||
cp "$SOURCE_COMMAND_FILE" "$DEST_COMMAND_FILE"
|
||||
echo " ✓ Copied create-widget.md command"
|
||||
|
||||
# Verify the copy was successful
|
||||
echo ""
|
||||
echo "Verification:"
|
||||
if [ -d "$DEST_WIDGET_DIR" ] && [ -f "$DEST_WIDGET_DIR/vue-widget-guide.md" ] && [ -f "$DEST_WIDGET_DIR/primevue-components.md" ]; then
|
||||
echo " ✓ vue-widget-conversion directory copied successfully"
|
||||
echo " - vue-widget-guide.md exists"
|
||||
echo " - primevue-components.md exists"
|
||||
if [ -f "$DEST_WIDGET_DIR/primevue-components.json" ]; then
|
||||
echo " - primevue-components.json exists"
|
||||
fi
|
||||
else
|
||||
echo " ✗ Error: vue-widget-conversion directory copy may have failed"
|
||||
fi
|
||||
|
||||
if [ -f "$DEST_COMMAND_FILE" ]; then
|
||||
echo " ✓ create-widget.md command copied successfully"
|
||||
else
|
||||
echo " ✗ Error: create-widget.md command copy may have failed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Copy complete! Widget resources are now available in: $DEST_DIR"
|
||||
echo ""
|
||||
echo "You can now use the widget creation command in the destination repo:"
|
||||
echo " /project:create-widget <widget specification>"
|
||||
@@ -24,7 +24,7 @@ export default [
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
project: ['./tsconfig.json', './tsconfig.eslint.json'],
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions: ['.vue']
|
||||
|
||||
1897
package-lock.json
generated
10
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,11 +29,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.5",
|
||||
"@iconify/json": "^2.2.245",
|
||||
"@lobehub/i18n-cli": "^1.20.0",
|
||||
"@pinia/testing": "^0.1.5",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.8",
|
||||
@@ -74,7 +76,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.15.11",
|
||||
"@comfyorg/litegraph": "^0.15.15",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -91,12 +93,14 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"algoliasearch": "^5.21.0",
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.2.5",
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
@click="download.triggerBrowserDownload"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:label="$t('g.copyURL')"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="copyURL"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,6 +47,7 @@ import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
@@ -49,9 +59,15 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-for="(item, index) in dialogStore.dialogStack"
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:auto-z-index="false"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -35,25 +33,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { usePrimeVue } from '@primevue/core'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const primevue = usePrimeVue()
|
||||
|
||||
const baseZIndex = computed(() => {
|
||||
return primevue?.config?.zIndex?.modal ?? 1100
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const mask = document.createElement('div')
|
||||
ZIndex.set('model', mask, baseZIndex.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -408,19 +408,30 @@ const handleGridContainerClick = (event: MouseEvent) => {
|
||||
|
||||
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
|
||||
|
||||
// Track the last pack ID for which we've fetched full registry data
|
||||
const lastFetchedPackId = ref<string | null>(null)
|
||||
|
||||
// Whenever a single pack is selected, fetch its full info once
|
||||
whenever(selectedNodePack, async () => {
|
||||
// Cancel any in-flight requests from previously selected node pack
|
||||
getPackById.cancel()
|
||||
|
||||
if (!selectedNodePack.value?.id) return
|
||||
|
||||
// If only a single node pack is selected, fetch full node pack info from registry
|
||||
const pack = selectedNodePack.value
|
||||
if (!pack?.id) return
|
||||
if (hasMultipleSelections.value) return
|
||||
const data = await getPackById.call(selectedNodePack.value.id)
|
||||
|
||||
if (data?.id === selectedNodePack.value?.id) {
|
||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
||||
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
|
||||
// Only fetch if we haven't already for this pack
|
||||
if (lastFetchedPackId.value === pack.id) return
|
||||
const data = await getPackById.call(pack.id)
|
||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
||||
if (data?.id === pack.id) {
|
||||
lastFetchedPackId.value = pack.id
|
||||
const mergedPack = merge({}, pack, data)
|
||||
selectedNodePacks.value = [mergedPack]
|
||||
// Replace pack in displayPacks so that children receive a fresh prop reference
|
||||
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
||||
if (idx !== -1) {
|
||||
displayPacks.value.splice(idx, 1, mergedPack)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
class="object-cover"
|
||||
:style="{ width: cssWidth, height: cssHeight }"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
width = '100%',
|
||||
height = '12rem'
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
const shouldShowFallback = computed(
|
||||
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
|
||||
)
|
||||
const imgSrc = computed(() =>
|
||||
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
|
||||
)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
</script>
|
||||
@@ -7,19 +7,15 @@
|
||||
}"
|
||||
:pt="{
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl' },
|
||||
title: {
|
||||
class:
|
||||
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
|
||||
},
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: { class: 'p-0 m-0' }
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
<PackCardHeader :node-pack="nodePack" />
|
||||
<PackBanner :node-pack="nodePack" />
|
||||
</template>
|
||||
<template #content>
|
||||
<ContentDivider />
|
||||
<template v-if="isInstalling">
|
||||
<div
|
||||
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
|
||||
@@ -34,46 +30,63 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
|
||||
class="self-stretch inline-flex flex-col justify-start items-start"
|
||||
>
|
||||
<PackIcon :node-pack="nodePack" />
|
||||
<div
|
||||
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
|
||||
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-center items-center gap-2.5"
|
||||
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
|
||||
>
|
||||
<span
|
||||
class="text-base font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div class="text-center justify-center font-medium leading-3">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
class="self-stretch inline-flex justify-start items-center gap-1"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="text-center justify-center font-medium leading-3"
|
||||
>
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
</div>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,11 +105,12 @@ import { whenever } from '@vueuse/core'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
|
||||
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
|
||||
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { IsInstallingKey } from '@/types/comfyManagerTypes'
|
||||
@@ -107,6 +121,8 @@ const { nodePack, isSelected = false } = defineProps<{
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
@@ -122,4 +138,19 @@ whenever(isInstalled, () => (isInstalling.value = false))
|
||||
|
||||
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
|
||||
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
|
||||
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
|
||||
const formattedLatestVersionDate = computed(() => {
|
||||
if (!nodePack.latest_version?.createdAt) return null
|
||||
|
||||
return d(new Date(nodePack.latest_version.createdAt), {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
|
||||
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 cursor-pointer">
|
||||
<span v-if="publisherName" class="max-w-40 truncate">
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="nodePack.latest_version?.createdAt"
|
||||
class="flex items-center gap-2 truncate"
|
||||
>
|
||||
{{ $t('g.updated') }}
|
||||
{{
|
||||
$d(new Date(nodePack.latest_version.createdAt), {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
}}
|
||||
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
<span>{{ formattedDownloads }}</span>
|
||||
</div>
|
||||
<PackInstallButton :node-packs="[nodePack]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
const { n } = useI18n()
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
const formattedDownloads = computed(() =>
|
||||
nodePack.downloads ? n(nodePack.downloads) : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -54,4 +54,21 @@ describe('SettingItem', () => {
|
||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles tooltips with @ symbols without errors', () => {
|
||||
const wrapper = mountComponent({
|
||||
setting: {
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
}
|
||||
})
|
||||
|
||||
// Should not throw an error and tooltip should be preserved as-is
|
||||
expect(wrapper.vm.formItem.tooltip).toBe(
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { SettingOption, SettingParams } from '@/types/settingTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
@@ -64,7 +65,7 @@ const formItem = computed(() => {
|
||||
...props.setting,
|
||||
name: t(`settings.${normalizedId}.name`, props.setting.name),
|
||||
tooltip: props.setting.tooltip
|
||||
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
|
||||
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
|
||||
: undefined,
|
||||
options: props.setting.options
|
||||
? translateOptions(props.setting.options)
|
||||
|
||||
293
src/components/dialog/content/signin/SignInForm.spec.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import SignInForm from './SignInForm.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof SignInForm>
|
||||
|
||||
// Mock firebase auth modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
sendPasswordResetEmail: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock the auth composables and stores
|
||||
const mockSendPasswordReset = vi.fn()
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
sendPasswordReset: mockSendPasswordReset
|
||||
}))
|
||||
}))
|
||||
|
||||
let mockLoading = false
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
get loading() {
|
||||
return mockLoading
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock toast
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('SignInForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSendPasswordReset.mockReset()
|
||||
mockToastAdd.mockReset()
|
||||
mockLoading = false
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
props = {},
|
||||
options = {}
|
||||
): VueWrapper<ComponentInstance> => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(SignInForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, ToastService],
|
||||
components: {
|
||||
Form,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('Forgot Password Link', () => {
|
||||
it('shows disabled style when email is empty', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
|
||||
})
|
||||
|
||||
it('shows toast and focuses email input when clicked while disabled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
|
||||
// Click forgot password link while email is empty
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should show toast warning
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'warn',
|
||||
summary: enMessages.auth.login.emailPlaceholder,
|
||||
life: 5000
|
||||
})
|
||||
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
|
||||
// Should NOT call sendPasswordReset
|
||||
expect(mockSendPasswordReset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls handleForgotPassword with email when link is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Spy on handleForgotPassword
|
||||
const handleForgotPasswordSpy = vi.spyOn(
|
||||
component,
|
||||
'handleForgotPassword'
|
||||
)
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
// Click the forgot password link
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
|
||||
// Should call handleForgotPassword
|
||||
expect(handleForgotPasswordSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('emits submit event when onSubmit is called with valid data', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Call onSubmit directly with valid data
|
||||
component.onSubmit({
|
||||
valid: true,
|
||||
values: { email: 'test@example.com', password: 'password123' }
|
||||
})
|
||||
|
||||
// Check emitted event
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')?.[0]).toEqual([
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not emit submit event when form is invalid', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Call onSubmit with invalid form
|
||||
component.onSubmit({ valid: false, values: {} })
|
||||
|
||||
// Should not emit submit event
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('shows spinner when loading', async () => {
|
||||
mockLoading = true
|
||||
|
||||
try {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(false)
|
||||
} catch (error) {
|
||||
// Fallback test - check HTML content if component rendering fails
|
||||
mockLoading = true
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.html()).toContain('p-progressspinner')
|
||||
expect(wrapper.html()).not.toContain('<button')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows button when not loading', () => {
|
||||
mockLoading = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Structure', () => {
|
||||
it('renders email input with correct attributes', () => {
|
||||
const wrapper = mountComponent()
|
||||
const emailInput = wrapper.findComponent(InputText)
|
||||
|
||||
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
|
||||
expect(emailInput.attributes('autocomplete')).toBe('email')
|
||||
expect(emailInput.attributes('name')).toBe('email')
|
||||
expect(emailInput.attributes('type')).toBe('text')
|
||||
})
|
||||
|
||||
it('renders password input with correct attributes', () => {
|
||||
const wrapper = mountComponent()
|
||||
const passwordInput = wrapper.findComponent(Password)
|
||||
|
||||
// Check props instead of attributes for Password component
|
||||
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
|
||||
// Password component passes name as prop, not attribute
|
||||
expect(passwordInput.props('name')).toBe('password')
|
||||
expect(passwordInput.props('feedback')).toBe(false)
|
||||
expect(passwordInput.props('toggleMask')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders form with correct resolver', () => {
|
||||
const wrapper = mountComponent()
|
||||
const form = wrapper.findComponent(Form)
|
||||
|
||||
expect(form.props('resolver')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus Behavior', () => {
|
||||
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
|
||||
// Call handleForgotPassword with no email
|
||||
await component.handleForgotPassword('', false)
|
||||
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not focus email input when valid email is provided', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Mock getElementById
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
|
||||
// Call handleForgotPassword with valid email
|
||||
await component.handleForgotPassword('test@example.com', true)
|
||||
|
||||
// Should NOT focus email input
|
||||
expect(document.getElementById).not.toHaveBeenCalled()
|
||||
expect(mockFocus).not.toHaveBeenCalled()
|
||||
|
||||
// Should call sendPasswordReset
|
||||
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,15 +7,12 @@
|
||||
>
|
||||
<!-- Email Field -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
class="opacity-80 text-base font-medium mb-2"
|
||||
for="comfy-org-sign-in-email"
|
||||
>
|
||||
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
|
||||
{{ t('auth.login.emailLabel') }}
|
||||
</label>
|
||||
<InputText
|
||||
pt:root:id="comfy-org-sign-in-email"
|
||||
pt:root:autocomplete="email"
|
||||
:id="emailInputId"
|
||||
autocomplete="email"
|
||||
class="h-10"
|
||||
name="email"
|
||||
type="text"
|
||||
@@ -37,8 +34,11 @@
|
||||
{{ t('auth.login.passwordLabel') }}
|
||||
</label>
|
||||
<span
|
||||
class="text-muted text-base font-medium cursor-pointer"
|
||||
@click="handleForgotPassword($form.email?.value)"
|
||||
class="text-muted text-base font-medium cursor-pointer select-none"
|
||||
:class="{
|
||||
'text-link-disabled': !$form.email?.value || $form.email?.invalid
|
||||
}"
|
||||
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
|
||||
>
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
</span>
|
||||
@@ -77,6 +77,7 @@ import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -87,6 +88,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const toast = useToast()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -94,14 +96,34 @@ const emit = defineEmits<{
|
||||
submit: [values: SignInData]
|
||||
}>()
|
||||
|
||||
const emailInputId = 'comfy-org-sign-in-email'
|
||||
|
||||
const onSubmit = (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignInData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgotPassword = async (email: string) => {
|
||||
if (!email) return
|
||||
const handleForgotPassword = async (
|
||||
email: string,
|
||||
isValid: boolean | undefined
|
||||
) => {
|
||||
if (!email || !isValid) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('auth.login.emailPlaceholder'),
|
||||
life: 5_000
|
||||
})
|
||||
// Focus the email input
|
||||
document.getElementById(emailInputId)?.focus?.()
|
||||
return
|
||||
}
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
<SelectionOverlay v-if="selectionToolboxEnabled">
|
||||
<SelectionToolbox />
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
<DomWidgets v-if="!vueNodeRenderingEnabled" />
|
||||
<VueNodeOverlay v-if="vueNodeRenderingEnabled" />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
@@ -56,11 +57,13 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import VueNodeOverlay from '@/components/graph/nodes/VueNodeOverlay.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useTestPhantomNodes } from '@/composables/nodeRendering/useTestPhantomNodes'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
@@ -110,6 +113,19 @@ const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
|
||||
const selectionToolboxEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Canvas.SelectionToolbox')
|
||||
)
|
||||
// Temporarily enable Vue node rendering for testing
|
||||
const vueNodeRenderingEnabled = computed(() => true)
|
||||
|
||||
// Use test helper for automatic phantom mode enabling
|
||||
useTestPhantomNodes()
|
||||
|
||||
// Debug logging
|
||||
watchEffect(() => {
|
||||
console.log(
|
||||
'🖼️ GraphCanvas: Vue node rendering enabled:',
|
||||
vueNodeRenderingEnabled.value
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
@@ -277,6 +293,7 @@ onMounted(async () => {
|
||||
useWorkflowAutoSave()
|
||||
|
||||
comfyApp.vueAppReady = true
|
||||
console.log('🖼️ GraphCanvas: comfyApp.vueAppReady:', comfyApp.vueAppReady)
|
||||
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
@@ -310,6 +327,7 @@ onMounted(async () => {
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
console.log('🖼️ GraphCanvas: comfyAppReady:', comfyAppReady.value)
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
:key="command.id"
|
||||
:command="command"
|
||||
/>
|
||||
<HelpButton />
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
@@ -30,6 +31,7 @@ import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerBu
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
|
||||
|
||||
441
src/components/graph/nodes/VueNode.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<div
|
||||
ref="nodeRef"
|
||||
class="_sb_node_preview vue-node"
|
||||
:style="nodeStyle"
|
||||
@mousedown="onMouseDown"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<div class="_sb_table">
|
||||
<!-- Node header - exactly like NodePreview -->
|
||||
<div
|
||||
class="node_header"
|
||||
:style="{
|
||||
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR as string,
|
||||
color: litegraphColors.NODE_TITLE_COLOR as string
|
||||
}"
|
||||
>
|
||||
<div class="_sb_dot headdot" />
|
||||
{{ (node as any).title }}
|
||||
</div>
|
||||
|
||||
<!-- Node slot I/O - using flexbox for proper positioning -->
|
||||
<div
|
||||
v-for="[slotInput, slotOutput] in slotPairs"
|
||||
:key="((slotInput as any)?.name || '') + ((slotOutput as any)?.name || '')"
|
||||
class="slot-row-flex"
|
||||
>
|
||||
<!-- Left side input slot -->
|
||||
<div class="slot-left" v-if="slotInput">
|
||||
<div :class="['_sb_dot', (slotInput as any)?.type]" />
|
||||
<span class="slot-text">{{ (slotInput as any)?.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Right side output slot -->
|
||||
<div
|
||||
class="slot-right"
|
||||
v-if="slotOutput"
|
||||
:style="{
|
||||
color: litegraphColors.NODE_TEXT_COLOR as string
|
||||
}"
|
||||
>
|
||||
<span class="slot-text">{{ (slotOutput as any)?.name }}</span>
|
||||
<div :class="['_sb_dot', (slotOutput as any)?.type]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widgets using existing widget components -->
|
||||
<VueNodeBody
|
||||
:widgets="nodeWidgets"
|
||||
:node="node"
|
||||
@widget-change="onWidgetChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import _ from 'lodash'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { NodeInteractionEvent } from '@/composables/nodeRendering/useNodeInteractionProxy'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import VueNodeBody from './VueNodeBody.vue'
|
||||
|
||||
interface VueNodeProps {
|
||||
node: LGraphNode
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
canvasScale: number
|
||||
canvasOffset: { x: number, y: number }
|
||||
updateTrigger?: number // Add update trigger to force reactivity
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
interaction: [event: NodeInteractionEvent]
|
||||
}>()
|
||||
|
||||
const nodeRef = ref<HTMLElement>()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const litegraphColors = computed(
|
||||
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
)
|
||||
|
||||
// Get canvas position conversion utilities
|
||||
const canvasPositionConversion = computed(() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas?.canvas) return null
|
||||
|
||||
return useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
|
||||
})
|
||||
|
||||
// Slot pairs - filter out inputs that have corresponding widgets
|
||||
const slotPairs = computed(() => {
|
||||
const allInputs = (props.node as any).inputs || []
|
||||
const outputs = (props.node as any).outputs || []
|
||||
|
||||
// Get widget names to filter out inputs that have widgets
|
||||
const nodeWidgetNames = new Set((props.node as any).widgets?.map((w: any) => w.name) || [])
|
||||
|
||||
// Only show inputs that DON'T have corresponding widgets
|
||||
const slotInputs = allInputs.filter((input: any) => !nodeWidgetNames.has(input.name))
|
||||
|
||||
return _.zip(slotInputs, outputs)
|
||||
})
|
||||
|
||||
// Extract widgets from the node
|
||||
const nodeWidgets = computed(() => {
|
||||
return (props.node as any).widgets || []
|
||||
})
|
||||
|
||||
// Dragging will be handled by LiteGraph's phantom node
|
||||
|
||||
// Node styling based on position and state - using proper canvas position conversion
|
||||
const nodeStyle = computed(() => {
|
||||
try {
|
||||
// Access update trigger to make this reactive to graph changes
|
||||
props.updateTrigger
|
||||
|
||||
const positionConverter = canvasPositionConversion.value
|
||||
if (!positionConverter) {
|
||||
console.warn('🚨 VueNode: No position converter available')
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
left: '100px',
|
||||
top: '100px',
|
||||
width: '200px',
|
||||
minHeight: '100px',
|
||||
backgroundColor: '#ff0000',
|
||||
border: '2px solid #ffffff',
|
||||
zIndex: 999
|
||||
}
|
||||
}
|
||||
|
||||
// Get node position and size in graph space
|
||||
const nodeAny = props.node as any
|
||||
const nodePos: [number, number] = [
|
||||
nodeAny.pos?.[0] ?? 0,
|
||||
nodeAny.pos?.[1] ?? 0
|
||||
]
|
||||
const nodeWidth = nodeAny.size?.[0] ?? 200
|
||||
const nodeHeight = nodeAny.size?.[1] ?? 100
|
||||
|
||||
// Convert from canvas coordinates to client coordinates (absolute positioning)
|
||||
const [clientX, clientY] = positionConverter.canvasPosToClientPos(nodePos)
|
||||
|
||||
// Get the current scale from the canvas
|
||||
const lgCanvas = canvasStore.canvas
|
||||
const scale = lgCanvas?.ds?.scale ?? 1
|
||||
|
||||
// Use original dimensions for positioning, apply scale via CSS transform
|
||||
const scaledWidth = nodeWidth
|
||||
const scaledHeight = nodeHeight
|
||||
|
||||
// Validate coordinates
|
||||
if (!isFinite(clientX) || !isFinite(clientY) || scaledWidth <= 0 || scaledHeight <= 0) {
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
left: '100px',
|
||||
top: '100px',
|
||||
width: '200px',
|
||||
minHeight: '100px',
|
||||
backgroundColor: '#ff0000',
|
||||
border: '2px solid #ffffff',
|
||||
zIndex: 999
|
||||
}
|
||||
}
|
||||
|
||||
// Use colors from palette for authentic LiteGraph appearance
|
||||
const nodeAnyForColors = props.node as any
|
||||
const bgColor = nodeAnyForColors.bgcolor || litegraphColors.value?.NODE_DEFAULT_BGCOLOR || '#353535'
|
||||
const borderColor = props.selected
|
||||
? litegraphColors.value?.NODE_BOX_OUTLINE_COLOR || '#FFF'
|
||||
: (nodeAnyForColors.boxcolor || litegraphColors.value?.NODE_DEFAULT_BOXCOLOR || '#666')
|
||||
|
||||
return {
|
||||
position: 'fixed' as const, // Use fixed positioning like other overlays
|
||||
left: `${clientX}px`,
|
||||
top: `${clientY}px`,
|
||||
minWidth: `${scaledWidth}px`,
|
||||
width: 'auto', // Allow width to expand for content
|
||||
minHeight: `${scaledHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: '0 0', // Scale from top-left corner
|
||||
zIndex: props.selected ? 10 : 1,
|
||||
backgroundColor: bgColor,
|
||||
borderColor: borderColor,
|
||||
borderWidth: props.selected ? '2px' : '1px',
|
||||
borderStyle: 'solid',
|
||||
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
left: '100px',
|
||||
top: '100px',
|
||||
width: '200px',
|
||||
minHeight: '100px',
|
||||
backgroundColor: '#ff0000',
|
||||
border: '2px solid #ffffff',
|
||||
zIndex: 999
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Note: nodeClasses could be used for conditional CSS classes if needed
|
||||
|
||||
// Event handlers
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
// Check if the click is on a widget element
|
||||
const target = event.target as HTMLElement
|
||||
const isOnWidget = target.closest('.widget-content') !== null
|
||||
|
||||
// If clicking on a widget, don't emit the mouse down event for dragging
|
||||
if (isOnWidget) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('interaction', {
|
||||
type: 'mousedown',
|
||||
nodeId: String((props.node as any).id),
|
||||
originalEvent: event
|
||||
})
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
emit('interaction', {
|
||||
type: 'contextmenu',
|
||||
nodeId: String((props.node as any).id),
|
||||
originalEvent: event
|
||||
})
|
||||
}
|
||||
|
||||
// Note: onSlotInteraction and onTitleEdit available for future use
|
||||
|
||||
const onWidgetChange = (widgetIndex: number, value: any) => {
|
||||
const nodeAny = props.node as any
|
||||
if (nodeAny.widgets?.[widgetIndex]) {
|
||||
nodeAny.widgets[widgetIndex].value = value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Copy ALL styles from NodePreview.vue exactly */
|
||||
.slot_row {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Original N-Sidebar styles */
|
||||
._sb_dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: grey;
|
||||
}
|
||||
|
||||
.node_header {
|
||||
line-height: 1;
|
||||
padding: 8px 13px 7px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 15px;
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headdot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
float: inline-start;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.IMAGE {
|
||||
background-color: #64b5f6;
|
||||
}
|
||||
|
||||
.VAE {
|
||||
background-color: #ff6e6e;
|
||||
}
|
||||
|
||||
.LATENT {
|
||||
background-color: #ff9cf9;
|
||||
}
|
||||
|
||||
.MASK {
|
||||
background-color: #81c784;
|
||||
}
|
||||
|
||||
.CONDITIONING {
|
||||
background-color: #ffa931;
|
||||
}
|
||||
|
||||
.CLIP {
|
||||
background-color: #ffd500;
|
||||
}
|
||||
|
||||
.MODEL {
|
||||
background-color: #b39ddb;
|
||||
}
|
||||
|
||||
.CONTROL_NET {
|
||||
background-color: #a5d6a7;
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: small;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
min-width: 200px;
|
||||
width: max-content; /* Allow expansion for wide content */
|
||||
height: fit-content;
|
||||
z-index: 9999;
|
||||
border-radius: 12px;
|
||||
overflow: visible; /* Allow content to be visible outside bounds */
|
||||
font-size: 12px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
._sb_node_preview ._sb_description {
|
||||
margin: 10px;
|
||||
padding: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
._sb_table {
|
||||
display: grid;
|
||||
grid-column-gap: 10px;
|
||||
/* Spazio tra le colonne */
|
||||
width: 100%;
|
||||
/* Imposta la larghezza della tabella al 100% del contenitore */
|
||||
}
|
||||
|
||||
._sb_row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr 1fr 1fr 10px;
|
||||
grid-column-gap: 10px;
|
||||
align-items: center;
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
._sb_row_string {
|
||||
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
|
||||
}
|
||||
|
||||
._sb_col {
|
||||
border: 0 solid #000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
flex-wrap: nowrap;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
._sb_inherit {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
._long_field {
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
margin: 5px 5px 0 5px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.7;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
._sb_arrow {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
._sb_preview_badge {
|
||||
text-align: center;
|
||||
background: var(--comfy-input-bg);
|
||||
font-weight: bold;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
/* Additional styles for Vue node functionality */
|
||||
.vue-node {
|
||||
position: fixed; /* Use fixed positioning for proper overlay behavior */
|
||||
pointer-events: none; /* Let mouse events pass through to phantom nodes */
|
||||
}
|
||||
|
||||
.vue-node .widget-content {
|
||||
pointer-events: auto; /* Enable interaction with widgets only */
|
||||
}
|
||||
|
||||
.vue-node:hover {
|
||||
z-index: 10000; /* Bring to front on hover */
|
||||
}
|
||||
|
||||
.slot-text {
|
||||
font-size: 10px; /* Smaller font for slot labels */
|
||||
}
|
||||
|
||||
/* New flexbox slot layout */
|
||||
.slot-row-flex {
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.slot-left {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slot-right {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
79
src/components/graph/nodes/VueNodeBody.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<!-- Render Vue component widgets only -->
|
||||
<div>
|
||||
<div
|
||||
v-for="widget in vueComponentWidgets"
|
||||
:key="`vue-widget-${widget.name}`"
|
||||
class="_sb_row _long_field"
|
||||
>
|
||||
<div class="_sb_col widget-content">
|
||||
<component
|
||||
:is="widget.component"
|
||||
:model-value="widget.value"
|
||||
:widget="widget"
|
||||
v-bind="widget.props"
|
||||
v-if="widgetsShouldShow"
|
||||
@update:model-value="updateWidgetValue(widget, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { BaseWidget } from '@comfyorg/litegraph'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const widgetsShouldShow = ref(true)
|
||||
|
||||
app.api.addEventListener('graphChanged', () => {
|
||||
widgetsShouldShow.value = app.canvas.ds.scale > .55
|
||||
})
|
||||
|
||||
console.log('app.canvas.ds.scale', app.canvas.ds.scale)
|
||||
interface VueNodeBodyProps {
|
||||
widgets: BaseWidget[]
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeBodyProps>()
|
||||
|
||||
// Note: emit available for future widget change events if needed
|
||||
|
||||
// Get Vue component widgets only
|
||||
const vueComponentWidgets = computed(() => {
|
||||
return props.widgets.filter((widget: any) => isComponentWidget(widget))
|
||||
})
|
||||
|
||||
// Update widget value when component emits changes
|
||||
const updateWidgetValue = (widget: any, value: any) => {
|
||||
if (widget.options?.setValue) {
|
||||
widget.options.setValue(value)
|
||||
}
|
||||
// Also trigger the widget's callback if it exists
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: onWidgetChange available for future use if needed
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-body {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.widget-container {
|
||||
/* Widget containers use flexbox for natural sizing */
|
||||
}
|
||||
|
||||
.legacy-widget {
|
||||
/* Styling for non-Vue widgets */
|
||||
border: 1px dashed #ccc;
|
||||
}
|
||||
</style>
|
||||
142
src/components/graph/nodes/VueNodeHeader.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-node-header flex items-center justify-between px-3 py-2"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-grow">
|
||||
<!-- Collapse dot (like original LiteGraph) -->
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full cursor-pointer"
|
||||
:style="{ backgroundColor: dotColor }"
|
||||
@click="toggleCollapse"
|
||||
/>
|
||||
|
||||
<!-- Editable title -->
|
||||
<EditableText
|
||||
v-model="editableTitle"
|
||||
class="font-medium flex-grow"
|
||||
:style="titleStyle"
|
||||
@update:model-value="onTitleUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node controls (minimized to match LiteGraph style) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Pin indicator (small, unobtrusive) -->
|
||||
<div
|
||||
v-if="node.pinned"
|
||||
class="w-2 h-2 rounded-full"
|
||||
:style="{ backgroundColor: litegraphColors.NODE_TITLE_COLOR }"
|
||||
title="Pinned"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
|
||||
interface VueNodeHeaderProps {
|
||||
node: LGraphNode
|
||||
title: string
|
||||
nodeType?: string
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'title-edit': [title: string]
|
||||
}>()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const litegraphColors = computed(
|
||||
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
)
|
||||
|
||||
// Local editable title
|
||||
const editableTitle = ref(props.title)
|
||||
|
||||
// Watch for external title changes
|
||||
watch(() => props.title, (newTitle) => {
|
||||
editableTitle.value = newTitle
|
||||
})
|
||||
|
||||
// Header styling to match LiteGraph
|
||||
const headerStyle = computed(() => {
|
||||
try {
|
||||
const headerColor = props.node.color || litegraphColors.value?.NODE_DEFAULT_COLOR || '#333'
|
||||
return {
|
||||
backgroundColor: headerColor,
|
||||
borderTopLeftRadius: '4px',
|
||||
borderTopRightRadius: '4px',
|
||||
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ VueNodeHeader: Error in headerStyle:', error)
|
||||
return {
|
||||
backgroundColor: '#333',
|
||||
borderTopLeftRadius: '4px',
|
||||
borderTopRightRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Title styling to match LiteGraph
|
||||
const titleStyle = computed(() => {
|
||||
try {
|
||||
const selected = (props.node as any).selected || false
|
||||
const titleColor = selected
|
||||
? litegraphColors.value?.NODE_SELECTED_TITLE_COLOR || '#FFF'
|
||||
: litegraphColors.value?.NODE_TITLE_COLOR || '#999'
|
||||
|
||||
return {
|
||||
color: titleColor,
|
||||
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
|
||||
fontWeight: 'normal',
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ VueNodeHeader: Error in titleStyle:', error)
|
||||
return {
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Dot color (collapse indicator)
|
||||
const dotColor = computed(() => {
|
||||
try {
|
||||
return litegraphColors.value?.NODE_TITLE_COLOR || '#999'
|
||||
} catch (error) {
|
||||
console.warn('⚠️ VueNodeHeader: Error in dotColor:', error)
|
||||
return '#999'
|
||||
}
|
||||
})
|
||||
|
||||
const onTitleUpdate = (newTitle: string) => {
|
||||
emit('title-edit', newTitle)
|
||||
}
|
||||
|
||||
const toggleCollapse = () => {
|
||||
// Use node collapse method instead of setting property directly
|
||||
if (props.node.collapse) {
|
||||
props.node.collapse()
|
||||
} else {
|
||||
// Fallback to manual property setting if method doesn't exist
|
||||
;(props.node as any).collapsed = !props.node.collapsed
|
||||
}
|
||||
// Trigger canvas redraw
|
||||
props.node.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
144
src/components/graph/nodes/VueNodeOverlay.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-node-overlay absolute inset-0 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<VueNode
|
||||
v-for="node in phantomNodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:selected="isNodeSelected(node.id)"
|
||||
:executing="isNodeExecuting(node.id)"
|
||||
:canvas-scale="canvasScale"
|
||||
:canvas-offset="canvasOffset"
|
||||
:update-trigger="graphUpdateTrigger"
|
||||
@interaction="handleNodeInteraction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useNodeInteractionProxy } from '@/composables/nodeRendering/useNodeInteractionProxy'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
import VueNode from './VueNode.vue'
|
||||
|
||||
const { handleNodeInteraction } = useNodeInteractionProxy()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
// Reactive trigger for graph changes
|
||||
const graphUpdateTrigger = ref(0)
|
||||
|
||||
// Force update phantom nodes when graph changes
|
||||
const forceUpdate = () => {
|
||||
graphUpdateTrigger.value++
|
||||
}
|
||||
|
||||
// Get phantom nodes directly from canvas with reactive trigger
|
||||
const phantomNodes = computed(() => {
|
||||
// Access reactive trigger to ensure computed re-runs on graph changes
|
||||
graphUpdateTrigger.value
|
||||
|
||||
if (!canvasStore.canvas?.graph) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = canvasStore.canvas.graph._nodes
|
||||
const phantomNodes = allNodes.filter(
|
||||
(node: any) => node.phantom_mode === true
|
||||
)
|
||||
|
||||
// Register widgets for phantom nodes if not already registered
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
phantomNodes.forEach((node: any) => {
|
||||
if (node.widgets) {
|
||||
node.widgets.forEach((widget: any) => {
|
||||
// Check if it's a DOM widget that needs registration
|
||||
if (
|
||||
(isDOMWidget(widget) || isComponentWidget(widget)) &&
|
||||
widget.id &&
|
||||
!domWidgetStore.widgetStates.has(widget.id)
|
||||
) {
|
||||
domWidgetStore.registerWidget(widget)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return phantomNodes
|
||||
})
|
||||
|
||||
// Simple animation frame updates - always running for smooth dragging
|
||||
let rafId: number | null = null
|
||||
|
||||
const startFrameUpdates = () => {
|
||||
const updateEveryFrame = () => {
|
||||
forceUpdate()
|
||||
rafId = requestAnimationFrame(updateEveryFrame)
|
||||
}
|
||||
updateEveryFrame()
|
||||
}
|
||||
|
||||
const stopFrameUpdates = () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for graph changes
|
||||
onMounted(() => {
|
||||
// Listen to API events for graph changes (now includes ds changes)
|
||||
api.addEventListener('graphChanged', forceUpdate)
|
||||
|
||||
// Start continuous frame updates for smooth dragging
|
||||
startFrameUpdates()
|
||||
|
||||
// Initial update
|
||||
forceUpdate()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
api.removeEventListener('graphChanged', forceUpdate)
|
||||
stopFrameUpdates()
|
||||
})
|
||||
|
||||
// Get canvas transform directly from canvas
|
||||
const canvasScale = computed(() => {
|
||||
return canvasStore.canvas?.ds?.scale || 1
|
||||
})
|
||||
|
||||
const canvasOffset = computed(() => {
|
||||
const canvas = canvasStore.canvas
|
||||
return {
|
||||
x: canvas?.ds?.offset?.[0] || 0,
|
||||
y: canvas?.ds?.offset?.[1] || 0
|
||||
}
|
||||
})
|
||||
|
||||
// Check if node is selected
|
||||
const isNodeSelected = (nodeId: string) => {
|
||||
return canvasStore.selectedItems.some(
|
||||
(item: any) => item.id === Number(nodeId)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if node is executing
|
||||
const isNodeExecuting = (nodeId: string) => {
|
||||
return executionStore.executingNodeId === Number(nodeId)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-overlay {
|
||||
/* Ensure overlay doesn't interfere with canvas interactions */
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
143
src/components/graph/nodes/VueNodeSlots.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="vue-node-slots">
|
||||
<!-- Input slots -->
|
||||
<div v-if="inputs.length > 0" class="inputs mb-2">
|
||||
<div
|
||||
v-for="(input, index) in inputs"
|
||||
:key="`input-${index}`"
|
||||
class="input-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
|
||||
@click="onSlotClick(index, $event, 'input')"
|
||||
>
|
||||
<!-- Input connection point -->
|
||||
<div
|
||||
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
|
||||
:class="getSlotColor(input.type, 'input')"
|
||||
></div>
|
||||
|
||||
<!-- Input label -->
|
||||
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow">
|
||||
{{ input.name || `Input ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Input type badge -->
|
||||
<span
|
||||
v-if="input.type && input.type !== '*'"
|
||||
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
|
||||
>
|
||||
{{ input.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output slots -->
|
||||
<div v-if="outputs.length > 0" class="outputs">
|
||||
<div
|
||||
v-for="(output, index) in outputs"
|
||||
:key="`output-${index}`"
|
||||
class="output-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
|
||||
@click="onSlotClick(index, $event, 'output')"
|
||||
>
|
||||
<!-- Output type badge -->
|
||||
<span
|
||||
v-if="output.type && output.type !== '*'"
|
||||
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
|
||||
>
|
||||
{{ output.type }}
|
||||
</span>
|
||||
|
||||
<!-- Output label -->
|
||||
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow text-right">
|
||||
{{ output.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Output connection point -->
|
||||
<div
|
||||
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
|
||||
:class="getSlotColor(output.type, 'output')"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeInputSlot, INodeOutputSlot } from '@comfyorg/litegraph'
|
||||
|
||||
interface VueNodeSlotsProps {
|
||||
inputs: INodeInputSlot[]
|
||||
outputs: INodeOutputSlot[]
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeSlotsProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [slotIndex: number, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// Color mapping for different slot types
|
||||
const getSlotColor = (type: string | number | undefined, _direction: 'input' | 'output') => {
|
||||
if (!type || type === '*') {
|
||||
return 'border-gray-400'
|
||||
}
|
||||
|
||||
// Convert type to string for lookup
|
||||
const typeStr = String(type)
|
||||
|
||||
// Map common ComfyUI types to colors
|
||||
const typeColors: Record<string, string> = {
|
||||
'IMAGE': 'border-green-500 bg-green-100 dark-theme:bg-green-900',
|
||||
'LATENT': 'border-purple-500 bg-purple-100 dark-theme:bg-purple-900',
|
||||
'MODEL': 'border-blue-500 bg-blue-100 dark-theme:bg-blue-900',
|
||||
'CONDITIONING': 'border-yellow-500 bg-yellow-100 dark-theme:bg-yellow-900',
|
||||
'VAE': 'border-red-500 bg-red-100 dark-theme:bg-red-900',
|
||||
'CLIP': 'border-orange-500 bg-orange-100 dark-theme:bg-orange-900',
|
||||
'STRING': 'border-gray-500 bg-gray-100 dark-theme:bg-gray-900',
|
||||
'INT': 'border-indigo-500 bg-indigo-100 dark-theme:bg-indigo-900',
|
||||
'FLOAT': 'border-pink-500 bg-pink-100 dark-theme:bg-pink-900'
|
||||
}
|
||||
|
||||
return typeColors[typeStr.toUpperCase()] || 'border-gray-400'
|
||||
}
|
||||
|
||||
const onSlotClick = (index: number, event: MouseEvent, slotType: 'input' | 'output') => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculate the actual slot index based on type
|
||||
// For outputs, we need to add the input count to get the correct index
|
||||
const slotIndex = slotType === 'output' ? props.inputs.length + index : index
|
||||
|
||||
emit('slot-click', slotIndex, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-slots {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.slot-connector {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-connector:hover {
|
||||
transform: scale(1.2);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.input-slot {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.output-slot {
|
||||
border-right: 3px solid transparent;
|
||||
}
|
||||
|
||||
.input-slot:hover {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.output-slot:hover {
|
||||
border-right-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
49
src/components/graph/selectionToolbox/HelpButton.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="nodeDef"
|
||||
v-tooltip.top="{
|
||||
value: $t('g.help'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
class="help-button"
|
||||
text
|
||||
icon="pi pi-question-circle"
|
||||
severity="secondary"
|
||||
@click="showHelp"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
||||
|
||||
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
|
||||
if (canvasStore.selectedItems.length !== 1) return null
|
||||
const item = canvasStore.selectedItems[0]
|
||||
if (!isLGraphNode(item)) return null
|
||||
return nodeDefStore.fromLGraphNode(item)
|
||||
})
|
||||
|
||||
const showHelp = () => {
|
||||
const def = nodeDef.value
|
||||
if (!def) return
|
||||
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
|
||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
}
|
||||
nodeHelpStore.openHelp(def)
|
||||
}
|
||||
</script>
|
||||
204
src/components/graph/widgets/BadgedNumberInput.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="badged-number-input relative w-full">
|
||||
<InputGroup class="w-full rounded-lg border-none px-0.5">
|
||||
<!-- State badge prefix -->
|
||||
<InputGroupAddon
|
||||
v-if="badgeState !== 'normal'"
|
||||
class="rounded-l-lg bg-[#222222] border-[#222222] shadow-none border-r-[#A0A1A2] rounded-r-none"
|
||||
>
|
||||
<i
|
||||
:class="badgeIcon + ' text-xs'"
|
||||
:title="badgeTooltip"
|
||||
:style="{ color: badgeColor }"
|
||||
></i>
|
||||
</InputGroupAddon>
|
||||
|
||||
<!-- Number input for non-slider mode -->
|
||||
<InputNumber
|
||||
v-if="!isSliderMode"
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'bg-[#222222] text-xs shadow-none rounded-none !border-0'
|
||||
}
|
||||
},
|
||||
incrementButton: {
|
||||
class: 'text-xs shadow-none bg-[#222222] rounded-l-none !border-0'
|
||||
},
|
||||
decrementButton: {
|
||||
class: {
|
||||
'text-xs shadow-none bg-[#222222] rounded-r-none !border-0':
|
||||
badgeState === 'normal',
|
||||
'text-xs shadow-none bg-[#222222] rounded-none !border-0':
|
||||
badgeState !== 'normal'
|
||||
}
|
||||
}
|
||||
}"
|
||||
class="flex-1 rounded-none"
|
||||
show-buttons
|
||||
button-layout="horizontal"
|
||||
:increment-button-icon="'pi pi-plus'"
|
||||
:decrement-button-icon="'pi pi-minus'"
|
||||
/>
|
||||
|
||||
<!-- Slider mode -->
|
||||
<div
|
||||
v-else
|
||||
:class="{
|
||||
'rounded-r-lg': badgeState !== 'normal',
|
||||
'rounded-lg': badgeState === 'normal'
|
||||
}"
|
||||
class="flex-1 flex items-center gap-2 px-1 bg-surface-0 border border-surface-300"
|
||||
>
|
||||
<Slider
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
class="flex-1"
|
||||
/>
|
||||
<InputNumber
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
class="w-16 rounded-md"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'bg-[#222222] text-xs shadow-none border-[#222222]'
|
||||
}
|
||||
}
|
||||
}"
|
||||
:show-buttons="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputGroup from 'primevue/inputgroup'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const {
|
||||
widget,
|
||||
badgeState = 'normal',
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
badgeState?: BadgeState
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
// Convert string model value to/from number for the InputNumber component
|
||||
const numericValue = computed({
|
||||
get: () => parseFloat(modelValue.value) || 0,
|
||||
set: (value: number) => {
|
||||
modelValue.value = value.toString()
|
||||
}
|
||||
})
|
||||
|
||||
// Extract options from input spec
|
||||
const inputSpec = widget.inputSpec
|
||||
const min = (inputSpec as any).min ?? 0
|
||||
const max = (inputSpec as any).max ?? 100
|
||||
const step = (inputSpec as any).step ?? 1
|
||||
const placeholder = (inputSpec as any).placeholder ?? 'Enter number'
|
||||
|
||||
// Check if slider mode should be enabled
|
||||
const isSliderMode = computed(() => {
|
||||
console.log('inputSpec', inputSpec)
|
||||
return (inputSpec as any).slider === true
|
||||
})
|
||||
|
||||
// Badge configuration
|
||||
const badgeIcon = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'pi pi-refresh'
|
||||
case 'lock':
|
||||
return 'pi pi-lock'
|
||||
case 'increment':
|
||||
return 'pi pi-arrow-up'
|
||||
case 'decrement':
|
||||
return 'pi pi-arrow-down'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const badgeColor = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'var(--p-primary-color)'
|
||||
case 'lock':
|
||||
return 'var(--p-orange-500)'
|
||||
case 'increment':
|
||||
return 'var(--p-green-500)'
|
||||
case 'decrement':
|
||||
return 'var(--p-red-500)'
|
||||
default:
|
||||
return 'var(--p-text-color)'
|
||||
}
|
||||
})
|
||||
|
||||
const badgeTooltip = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'Random mode: Value randomizes after each run'
|
||||
case 'lock':
|
||||
return 'Locked: Value never changes'
|
||||
case 'increment':
|
||||
return 'Auto-increment: Value increases after each run'
|
||||
case 'decrement':
|
||||
return 'Auto-decrement: Value decreases after each run'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.badged-number-input {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Ensure proper styling for the input group */
|
||||
:deep(.p-inputgroup) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber-input) {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
:deep(.p-badge) {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
545
src/components/graph/widgets/ColorPickerWidget.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="color-picker-widget">
|
||||
<div
|
||||
:style="{ width: widgetWidth }"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border border-surface-300 bg-surface-0 w-full"
|
||||
>
|
||||
<!-- Color picker preview and popup trigger -->
|
||||
<div class="relative">
|
||||
<div
|
||||
:style="{ backgroundColor: parsedColor.hex }"
|
||||
class="w-4 h-4 rounded border-2 border-surface-400 cursor-pointer hover:border-surface-500 transition-colors"
|
||||
title="Click to edit color"
|
||||
@click="toggleColorPicker"
|
||||
/>
|
||||
|
||||
<!-- Color picker popover -->
|
||||
<Popover ref="colorPickerPopover" class="!p-0">
|
||||
<ColorPicker
|
||||
v-model="colorValue"
|
||||
format="hex"
|
||||
class="border-none"
|
||||
@update:model-value="updateColorFromPicker"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Color component inputs -->
|
||||
<div class="flex gap-5">
|
||||
<InputNumber
|
||||
v-for="component in colorComponents"
|
||||
:key="component.name"
|
||||
v-model="component.value"
|
||||
:min="component.min"
|
||||
:max="component.max"
|
||||
:step="component.step"
|
||||
:placeholder="component.name"
|
||||
class="flex-1 text-xs max-w-8"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class:
|
||||
'max-w-12 bg-[#222222] text-xs shadow-none border-[#222222]'
|
||||
}
|
||||
}
|
||||
}"
|
||||
:show-buttons="false"
|
||||
size="small"
|
||||
@update:model-value="updateColorFromComponents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Format dropdown -->
|
||||
<Select
|
||||
v-model="currentFormat"
|
||||
:options="colorFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-24 ml-3 bg-[#222222] text-xs shadow-none border-none p-0"
|
||||
size="small"
|
||||
@update:model-value="handleFormatChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Popover from 'primevue/popover'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
interface ColorComponent {
|
||||
name: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}
|
||||
|
||||
interface ParsedColor {
|
||||
hex: string
|
||||
rgb: { r: number; g: number; b: number; a: number }
|
||||
hsl: { h: number; s: number; l: number; a: number }
|
||||
hsv: { h: number; s: number; v: number; a: number }
|
||||
}
|
||||
|
||||
type ColorFormat = 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
// Color format options
|
||||
const colorFormats = [
|
||||
{ label: 'RGBA', value: 'rgba' },
|
||||
{ label: 'HSLA', value: 'hsla' },
|
||||
{ label: 'HSVA', value: 'hsva' },
|
||||
{ label: 'HEX', value: 'hex' }
|
||||
]
|
||||
|
||||
// Current format state
|
||||
const currentFormat = ref<ColorFormat>('rgba')
|
||||
|
||||
// Color picker popover reference
|
||||
const colorPickerPopover = ref()
|
||||
|
||||
// Internal color value for the PrimeVue ColorPicker
|
||||
const colorValue = ref<string>('#ff0000')
|
||||
|
||||
// Calculate widget width based on node size with padding
|
||||
const widgetWidth = computed(() => {
|
||||
if (!widget?.node?.size) return 'auto'
|
||||
|
||||
const nodeWidth = widget.node.size[0]
|
||||
const WIDGET_PADDING = 16 // Account for padding around the widget
|
||||
const maxWidth = Math.max(200, nodeWidth - WIDGET_PADDING) // Minimum 200px, but scale with node
|
||||
|
||||
return `${maxWidth}px`
|
||||
})
|
||||
|
||||
// Parse color string to various formats
|
||||
const parsedColor = computed<ParsedColor>(() => {
|
||||
const value = modelValue.value || '#ff0000'
|
||||
|
||||
// Handle different input formats
|
||||
if (value.startsWith('#')) {
|
||||
return parseHexColor(value)
|
||||
} else if (value.startsWith('rgb')) {
|
||||
return parseRgbaColor(value)
|
||||
} else if (value.startsWith('hsl')) {
|
||||
return parseHslaColor(value)
|
||||
} else if (value.startsWith('hsv')) {
|
||||
return parseHsvaColor(value)
|
||||
}
|
||||
|
||||
return parseHexColor('#ff0000') // Default fallback
|
||||
})
|
||||
|
||||
// Get color components based on current format
|
||||
const colorComponents = computed<ColorComponent[]>(() => {
|
||||
const { rgb, hsl, hsv } = parsedColor.value
|
||||
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
return [
|
||||
{ name: 'R', value: rgb.r, min: 0, max: 255, step: 1 },
|
||||
{ name: 'G', value: rgb.g, min: 0, max: 255, step: 1 },
|
||||
{ name: 'B', value: rgb.b, min: 0, max: 255, step: 1 },
|
||||
{ name: 'A', value: rgb.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hsla':
|
||||
return [
|
||||
{ name: 'H', value: hsl.h, min: 0, max: 360, step: 1 },
|
||||
{ name: 'S', value: hsl.s, min: 0, max: 100, step: 1 },
|
||||
{ name: 'L', value: hsl.l, min: 0, max: 100, step: 1 },
|
||||
{ name: 'A', value: hsl.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hsva':
|
||||
return [
|
||||
{ name: 'H', value: hsv.h, min: 0, max: 360, step: 1 },
|
||||
{ name: 'S', value: hsv.s, min: 0, max: 100, step: 1 },
|
||||
{ name: 'V', value: hsv.v, min: 0, max: 100, step: 1 },
|
||||
{ name: 'A', value: hsv.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hex':
|
||||
return [] // No components for hex format
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for changes in modelValue to update colorValue
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(newValue) => {
|
||||
if (newValue && newValue !== colorValue.value) {
|
||||
colorValue.value = parsedColor.value.hex
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Toggle color picker popover
|
||||
function toggleColorPicker(event: Event) {
|
||||
colorPickerPopover.value.toggle(event)
|
||||
}
|
||||
|
||||
// Update color from picker
|
||||
function updateColorFromPicker(value: string) {
|
||||
colorValue.value = value
|
||||
updateModelValue(parseHexColor(value))
|
||||
}
|
||||
|
||||
// Update color from component inputs
|
||||
function updateColorFromComponents() {
|
||||
const components = colorComponents.value
|
||||
if (components.length === 0) return
|
||||
|
||||
let newColor: ParsedColor
|
||||
const rgbFromHsl = hslToRgb(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
const rgbFromHsv = hsvToRgb(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
newColor = {
|
||||
hex: rgbToHex(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value
|
||||
),
|
||||
rgb: {
|
||||
r: components[0].value,
|
||||
g: components[1].value,
|
||||
b: components[2].value,
|
||||
a: components[3].value
|
||||
},
|
||||
hsl: rgbToHsl(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
),
|
||||
hsv: rgbToHsv(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'hsla':
|
||||
newColor = {
|
||||
hex: rgbToHex(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b),
|
||||
rgb: rgbFromHsl,
|
||||
hsl: {
|
||||
h: components[0].value,
|
||||
s: components[1].value,
|
||||
l: components[2].value,
|
||||
a: components[3].value
|
||||
},
|
||||
hsv: rgbToHsv(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b, rgbFromHsl.a)
|
||||
}
|
||||
break
|
||||
case 'hsva':
|
||||
newColor = {
|
||||
hex: rgbToHex(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b),
|
||||
rgb: rgbFromHsv,
|
||||
hsl: rgbToHsl(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b, rgbFromHsv.a),
|
||||
hsv: {
|
||||
h: components[0].value,
|
||||
s: components[1].value,
|
||||
v: components[2].value,
|
||||
a: components[3].value
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
updateModelValue(newColor)
|
||||
}
|
||||
|
||||
// Handle format change
|
||||
function handleFormatChange() {
|
||||
updateModelValue(parsedColor.value)
|
||||
}
|
||||
|
||||
// Update the model value based on current format
|
||||
function updateModelValue(color: ParsedColor) {
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
modelValue.value = `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`
|
||||
break
|
||||
case 'hsla':
|
||||
modelValue.value = `hsla(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%, ${color.hsl.a})`
|
||||
break
|
||||
case 'hsva':
|
||||
modelValue.value = `hsva(${color.hsv.h}, ${color.hsv.s}%, ${color.hsv.v}%, ${color.hsv.a})`
|
||||
break
|
||||
case 'hex':
|
||||
modelValue.value = color.hex
|
||||
break
|
||||
}
|
||||
|
||||
colorValue.value = color.hex
|
||||
}
|
||||
|
||||
// Color parsing functions
|
||||
function parseHexColor(hex: string): ParsedColor {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
const a = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1
|
||||
|
||||
return {
|
||||
hex,
|
||||
rgb: { r, g, b, a },
|
||||
hsl: rgbToHsl(r, g, b, a),
|
||||
hsv: rgbToHsv(r, g, b, a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseRgbaColor(rgba: string): ParsedColor {
|
||||
const match = rgba.match(/rgba?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [r, g, b, a = 1] = match[1].split(',').map((v) => parseFloat(v.trim()))
|
||||
|
||||
return {
|
||||
hex: rgbToHex(r, g, b),
|
||||
rgb: { r, g, b, a },
|
||||
hsl: rgbToHsl(r, g, b, a),
|
||||
hsv: rgbToHsv(r, g, b, a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseHslaColor(hsla: string): ParsedColor {
|
||||
const match = hsla.match(/hsla?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [h, s, l, a = 1] = match[1]
|
||||
.split(',')
|
||||
.map((v) => parseFloat(v.trim().replace('%', '')))
|
||||
const rgb = hslToRgb(h, s, l, a)
|
||||
|
||||
return {
|
||||
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||
rgb,
|
||||
hsl: { h, s, l, a },
|
||||
hsv: rgbToHsv(rgb.r, rgb.g, rgb.b, rgb.a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseHsvaColor(hsva: string): ParsedColor {
|
||||
const match = hsva.match(/hsva?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [h, s, v, a = 1] = match[1]
|
||||
.split(',')
|
||||
.map((val) => parseFloat(val.trim().replace('%', '')))
|
||||
const rgb = hsvToRgb(h, s, v, a)
|
||||
|
||||
return {
|
||||
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||
rgb,
|
||||
hsl: rgbToHsl(rgb.r, rgb.g, rgb.b, rgb.a),
|
||||
hsv: { h, s, v, a }
|
||||
}
|
||||
}
|
||||
|
||||
// Color conversion utility functions
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
|
||||
)
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number, a: number) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h: number, s: number
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
default:
|
||||
h = 0
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHsv(r: number, g: number, b: number, a: number) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h: number
|
||||
const v = max
|
||||
const s = max === 0 ? 0 : (max - min) / max
|
||||
|
||||
if (max === min) {
|
||||
h = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
default:
|
||||
h = 0
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
v: Math.round(v * 100),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number, a: number) {
|
||||
h /= 360
|
||||
s /= 100
|
||||
l /= 100
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||
if (t < 1 / 2) return q
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||
return p
|
||||
}
|
||||
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1 / 3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1 / 3)
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number, a: number) {
|
||||
h /= 360
|
||||
s /= 100
|
||||
v /= 100
|
||||
|
||||
const c = v * s
|
||||
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
|
||||
const m = v - c
|
||||
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (h < 1 / 6) {
|
||||
;[r, g, b] = [c, x, 0]
|
||||
} else if (h < 2 / 6) {
|
||||
;[r, g, b] = [x, c, 0]
|
||||
} else if (h < 3 / 6) {
|
||||
;[r, g, b] = [0, c, x]
|
||||
} else if (h < 4 / 6) {
|
||||
;[r, g, b] = [0, x, c]
|
||||
} else if (h < 5 / 6) {
|
||||
;[r, g, b] = [x, 0, c]
|
||||
} else {
|
||||
;[r, g, b] = [c, 0, x]
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255),
|
||||
a
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-picker-widget {
|
||||
min-height: 40px;
|
||||
overflow: hidden; /* Prevent overflow outside node bounds */
|
||||
}
|
||||
|
||||
/* Ensure proper styling for small inputs */
|
||||
:deep(.p-inputnumber-input) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-select) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-select .p-select-label) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-colorpicker) {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
40
src/components/graph/widgets/DropdownComboWidget.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="px-2">
|
||||
<Select
|
||||
v-model="selectedValue"
|
||||
:options="computedOptions"
|
||||
:placeholder="placeholder"
|
||||
class="w-full rounded-lg bg-[#222222] text-xs border-[#222222] shadow-none"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const selectedValue = defineModel<string>()
|
||||
const { widget } = defineProps<{
|
||||
widget?: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
const inputSpec = (widget?.inputSpec ?? {}) as ComboInputSpec
|
||||
const placeholder = 'Select option'
|
||||
const isLoading = computed(() => selectedValue.value === 'Loading...')
|
||||
|
||||
// For remote widgets, we need to dynamically get options
|
||||
const computedOptions = computed(() => {
|
||||
if (inputSpec.remote) {
|
||||
// For remote widgets, the options may be dynamically updated
|
||||
// The useRemoteWidget will update the inputSpec.options
|
||||
return inputSpec.options ?? []
|
||||
}
|
||||
return inputSpec.options ?? []
|
||||
})
|
||||
|
||||
// Tooltip support is available via inputSpec.tooltip if needed in the future
|
||||
</script>
|
||||
210
src/components/graph/widgets/ImagePreviewWidget.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="image-preview-widget relative w-full">
|
||||
<!-- Single image or grid view -->
|
||||
<div
|
||||
v-if="images.length > 0"
|
||||
class="relative rounded-lg overflow-hidden bg-gray-100 dark-theme:bg-gray-800"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<!-- Single image view -->
|
||||
<div
|
||||
v-if="selectedImageIndex !== null && images[selectedImageIndex]"
|
||||
class="relative flex items-center justify-center w-full h-full"
|
||||
>
|
||||
<img
|
||||
:src="images[selectedImageIndex].src"
|
||||
:alt="`Preview ${selectedImageIndex + 1}`"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Action buttons overlay -->
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
<Button
|
||||
v-if="images.length > 1"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="showGrid"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-sun"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleBrightness"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation for multiple images -->
|
||||
<div
|
||||
v-if="images.length > 1"
|
||||
class="absolute bottom-2 right-2 bg-black/60 text-white px-2 py-1 rounded text-sm cursor-pointer hover:bg-black/80"
|
||||
@click="nextImage"
|
||||
>
|
||||
{{ selectedImageIndex + 1 }}/{{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid view for multiple images -->
|
||||
<div
|
||||
v-else-if="allowBatch && images.length > 1"
|
||||
class="grid gap-1 p-2"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="index"
|
||||
class="relative aspect-square bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden hover:ring-2 hover:ring-blue-500"
|
||||
@click="selectImage(index)"
|
||||
>
|
||||
<img
|
||||
:src="image.src"
|
||||
:alt="`Thumbnail ${index + 1}`"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single image in grid mode -->
|
||||
<div v-else-if="images.length === 1" class="p-2">
|
||||
<div
|
||||
class="relative bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden"
|
||||
@click="selectImage(0)"
|
||||
>
|
||||
<img
|
||||
:src="images[0].src"
|
||||
:alt="'Preview'"
|
||||
class="w-full h-auto object-contain"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full bg-gray-100 dark-theme:bg-gray-800 rounded-lg"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<div class="text-gray-500 text-sm">No images to preview</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
interface ImageData {
|
||||
src: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const modelValue = defineModel<string | string[]>({ required: true })
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string | string[]>
|
||||
}>()
|
||||
|
||||
// Widget configuration
|
||||
const inputSpec = widget.inputSpec
|
||||
const allowBatch = computed(() => Boolean(inputSpec.allow_batch))
|
||||
const imageFolder = computed(() => inputSpec.image_folder || 'input')
|
||||
|
||||
// State
|
||||
const selectedImageIndex = ref<number | null>(null)
|
||||
const minHeight = 320
|
||||
|
||||
// Convert model value to image data
|
||||
const images = computed<ImageData[]>(() => {
|
||||
const value = modelValue.value
|
||||
if (!value) return []
|
||||
|
||||
const paths = Array.isArray(value) ? value : [value]
|
||||
return paths.map((path) => ({
|
||||
src: path.startsWith('http')
|
||||
? path
|
||||
: `api/view?filename=${encodeURIComponent(path)}&type=${imageFolder.value}`, // TODO: add subfolder
|
||||
width: undefined,
|
||||
height: undefined
|
||||
}))
|
||||
})
|
||||
|
||||
// Grid layout for batch images
|
||||
const gridStyle = computed(() => {
|
||||
const count = images.value.length
|
||||
if (count <= 1) return {}
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(count))
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectImage = (index: number) => {
|
||||
selectedImageIndex.value = index
|
||||
}
|
||||
|
||||
const showGrid = () => {
|
||||
selectedImageIndex.value = null
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
if (images.value.length === 0) return
|
||||
|
||||
const current = selectedImageIndex.value ?? -1
|
||||
const next = (current + 1) % images.value.length
|
||||
selectedImageIndex.value = next
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
console.warn('Failed to load image:', img.src)
|
||||
}
|
||||
|
||||
// Stub button handlers for now
|
||||
const handleEdit = () => {
|
||||
console.log('Edit button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
const handleBrightness = () => {
|
||||
console.log('Brightness button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('Save button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
// Initialize to show first image if available
|
||||
if (images.value.length === 1) {
|
||||
selectedImageIndex.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-preview-widget {
|
||||
/* Ensure proper dark theme styling */
|
||||
}
|
||||
</style>
|
||||
150
src/components/graph/widgets/MediaLoaderWidget.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="media-loader-widget w-full px-2 max-h-44">
|
||||
<div
|
||||
class="upload-area border-2 border-dashed border-surface-300 dark-theme:border-surface-600 rounded-lg p-6 text-center bg-surface-50 dark-theme:bg-surface-800 hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors cursor-pointer"
|
||||
:class="{
|
||||
'border-primary-500 bg-primary-50 dark-theme:bg-primary-950': isDragOver
|
||||
}"
|
||||
@click="triggerFileUpload"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<i
|
||||
class="pi pi-cloud-upload text-2xl text-surface-500 dark-theme:text-surface-400"
|
||||
></i>
|
||||
<div class="text-sm text-surface-600 dark-theme:text-surface-300">
|
||||
<span>Drop your file here or </span>
|
||||
<span
|
||||
class="text-primary-600 dark-theme:text-primary-400 hover:text-primary-700 dark-theme:hover:text-primary-300 underline cursor-pointer"
|
||||
@click.stop="triggerFileUpload"
|
||||
>
|
||||
browse files
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="accept"
|
||||
class="text-xs text-surface-500 dark-theme:text-surface-400"
|
||||
>
|
||||
Accepted formats: {{ formatAcceptTypes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
// Props and model
|
||||
const modelValue = defineModel<string[]>({ required: true, default: () => [] })
|
||||
const { widget, accept } = defineProps<{
|
||||
widget: ComponentWidget<string[]>
|
||||
accept?: string
|
||||
}>()
|
||||
|
||||
// Reactive state
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const formatAcceptTypes = computed(() => {
|
||||
if (!accept) return ''
|
||||
return accept
|
||||
.split(',')
|
||||
.map((type) =>
|
||||
type
|
||||
.trim()
|
||||
.replace('image/', '')
|
||||
.replace('video/', '')
|
||||
.replace('audio/', '')
|
||||
)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
// Methods
|
||||
const triggerFileUpload = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const onFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
handleFiles(Array.from(target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
|
||||
if (event.dataTransfer?.files) {
|
||||
handleFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFiles = (files: File[]) => {
|
||||
// Filter files based on accept prop if provided
|
||||
let validFiles = files
|
||||
if (accept) {
|
||||
const acceptTypes = accept
|
||||
.split(',')
|
||||
.map((type) => type.trim().toLowerCase())
|
||||
validFiles = files.filter((file) => {
|
||||
return acceptTypes.some((acceptType) => {
|
||||
if (acceptType.includes('*')) {
|
||||
// Handle wildcard types like "image/*"
|
||||
const baseType = acceptType.split('/')[0]
|
||||
return file.type.startsWith(baseType + '/')
|
||||
}
|
||||
return file.type.toLowerCase() === acceptType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
// Emit files to parent component for handling upload
|
||||
const fileNames = validFiles.map((file) => file.name)
|
||||
modelValue.value = fileNames
|
||||
|
||||
// Trigger the widget's upload handler if available
|
||||
if ((widget.options as any)?.onFilesSelected) {
|
||||
;(widget.options as any).onFilesSelected(validFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-area {
|
||||
min-height: 80px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--p-primary-500);
|
||||
}
|
||||
</style>
|
||||
45
src/components/graph/widgets/StringWidget.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="w-full px-2">
|
||||
<!-- Single line text input -->
|
||||
<InputText
|
||||
v-if="!isMultiline"
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
class="w-full rounded-lg px-3 py-2 text-sm bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
|
||||
/>
|
||||
|
||||
<!-- Multi-line textarea -->
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:auto-resize="true"
|
||||
:rows="3"
|
||||
class="w-full rounded-lg px-3 py-2 text-sm resize-none bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { StringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
const inputSpec = widget.inputSpec as StringInputSpec
|
||||
const isMultiline = computed(() => inputSpec.multiline === true)
|
||||
const placeholder = computed(
|
||||
() =>
|
||||
inputSpec.placeholder ??
|
||||
inputSpec.default ??
|
||||
inputSpec.defaultVal ??
|
||||
inputSpec.name
|
||||
)
|
||||
</script>
|
||||
@@ -150,8 +150,8 @@ const handleStopRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,8 +294,8 @@ const listenRecordingStatusChange = (value: boolean) => {
|
||||
isRecording.value = value
|
||||
|
||||
if (!value && load3DSceneRef.value?.load3d) {
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,8 +168,8 @@ const handleStopRecording = () => {
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ const listenRecordingStatusChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +99,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const resizeNodeMatchOutput = () => {
|
||||
console.log('resizeNodeMatchOutput')
|
||||
|
||||
const outputWidth = node.widgets?.find((w) => w.name === 'width')
|
||||
const outputHeight = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
|
||||
@@ -166,10 +166,11 @@ const showContextMenu = (e: CanvasPointerEvent) => {
|
||||
showSearchBox(e)
|
||||
}
|
||||
}
|
||||
const afterRerouteId = firstLink.fromReroute?.id
|
||||
const connectionOptions =
|
||||
toType === 'input'
|
||||
? { nodeFrom: node, slotFrom: fromSlot }
|
||||
: { nodeTo: node, slotTo: fromSlot }
|
||||
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
|
||||
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const menu = canvas.showConnectionMenu({
|
||||
|
||||
@@ -1,67 +1,124 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.nodeLibrary')"
|
||||
class="bg-[var(--p-tree-background)]"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.newFolder')"
|
||||
class="new-folder-button"
|
||||
icon="pi pi-folder-plus"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortOrder')"
|
||||
class="sort-button"
|
||||
:icon="alphabeticalSort ? 'pi pi-sort-alpha-down' : 'pi pi-sort-alt'"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="alphabeticalSort = !alphabeticalSort"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model:modelValue="searchQuery"
|
||||
class="node-lib-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchNodes') + '...'"
|
||||
filter-icon="pi pi-filter"
|
||||
:filters
|
||||
@search="handleSearch"
|
||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
||||
@remove-filter="onRemoveFilter"
|
||||
/>
|
||||
<div class="h-full">
|
||||
<SidebarTabTemplate
|
||||
v-if="!isHelpOpen"
|
||||
:title="$t('sideToolbar.nodeLibrary')"
|
||||
class="bg-[var(--p-tree-background)]"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.newFolder')"
|
||||
class="new-folder-button"
|
||||
icon="pi pi-folder-plus"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
|
||||
:icon="selectedGroupingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="groupingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
|
||||
:icon="selectedSortingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="sortingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="resetOrganization"
|
||||
/>
|
||||
<Popover ref="groupingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in groupingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedGroupingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectGrouping(option.id)"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover ref="sortingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in sortingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedSortingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectSorting(option.id)"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
<template #header>
|
||||
<div>
|
||||
<SearchBox
|
||||
v-model:modelValue="searchQuery"
|
||||
class="node-lib-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchNodes') + '...'"
|
||||
filter-icon="pi pi-filter"
|
||||
:filters
|
||||
@search="handleSearch"
|
||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
||||
@remove-filter="onRemoveFilter"
|
||||
/>
|
||||
|
||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||
</Popover>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeBookmarkTreeExplorer
|
||||
ref="nodeBookmarkTreeExplorerRef"
|
||||
:filtered-node-defs="filteredNodeDefs"
|
||||
/>
|
||||
<Divider
|
||||
v-show="nodeBookmarkStore.bookmarks.length > 0"
|
||||
type="dashed"
|
||||
class="m-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
class="node-lib-tree-explorer"
|
||||
:root="renderedRoot"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<NodeTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div>
|
||||
<NodeBookmarkTreeExplorer
|
||||
ref="nodeBookmarkTreeExplorerRef"
|
||||
:filtered-node-defs="filteredNodeDefs"
|
||||
:open-node-help="openHelp"
|
||||
/>
|
||||
<Divider
|
||||
v-show="nodeBookmarkStore.bookmarks.length > 0"
|
||||
type="dashed"
|
||||
class="m-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
class="node-lib-tree-explorer"
|
||||
:root="renderedRoot"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<NodeTreeLeaf :node="node" :open-node-help="openHelp" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
|
||||
<NodeHelpPage v-else :node="currentHelpNode!" @close="closeHelp" />
|
||||
</div>
|
||||
<div id="node-library-node-preview-container" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
@@ -73,24 +130,31 @@ import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import {
|
||||
ComfyNodeDefImpl,
|
||||
buildNodeDefTree,
|
||||
useNodeDefStore
|
||||
} from '@/stores/nodeDefStore'
|
||||
DEFAULT_GROUPING_ID,
|
||||
DEFAULT_SORTING_ID,
|
||||
nodeOrganizationService
|
||||
} from '@/services/nodeOrganizationService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import type {
|
||||
GroupingStrategyId,
|
||||
SortingStrategyId
|
||||
} from '@/types/nodeOrganizationTypes'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
@@ -98,13 +162,70 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
|
||||
typeof NodeBookmarkTreeExplorer
|
||||
> | null>(null)
|
||||
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const alphabeticalSort = ref(false)
|
||||
const groupingPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const sortingPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const selectedGroupingId = useLocalStorage<GroupingStrategyId>(
|
||||
'Comfy.NodeLibrary.GroupBy',
|
||||
DEFAULT_GROUPING_ID
|
||||
)
|
||||
const selectedSortingId = useLocalStorage<SortingStrategyId>(
|
||||
'Comfy.NodeLibrary.SortBy',
|
||||
DEFAULT_SORTING_ID
|
||||
)
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const { currentHelpNode, isHelpOpen } = storeToRefs(nodeHelpStore)
|
||||
const { openHelp, closeHelp } = nodeHelpStore
|
||||
|
||||
const groupingOptions = computed(() =>
|
||||
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
|
||||
id: strategy.id,
|
||||
label: strategy.label,
|
||||
icon: strategy.icon
|
||||
}))
|
||||
)
|
||||
const sortingOptions = computed(() =>
|
||||
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
|
||||
id: strategy.id,
|
||||
label: strategy.label,
|
||||
icon: strategy.icon
|
||||
}))
|
||||
)
|
||||
|
||||
const selectedGroupingIcon = computed(() =>
|
||||
nodeOrganizationService.getGroupingIcon(selectedGroupingId.value)
|
||||
)
|
||||
const selectedSortingIcon = computed(() =>
|
||||
nodeOrganizationService.getSortingIcon(selectedSortingId.value)
|
||||
)
|
||||
|
||||
const selectGrouping = (groupingId: string) => {
|
||||
selectedGroupingId.value = groupingId as GroupingStrategyId
|
||||
groupingPopover.value?.hide()
|
||||
}
|
||||
const selectSorting = (sortingId: string) => {
|
||||
selectedSortingId.value = sortingId as SortingStrategyId
|
||||
sortingPopover.value?.hide()
|
||||
}
|
||||
|
||||
const resetOrganization = () => {
|
||||
selectedGroupingId.value = DEFAULT_GROUPING_ID
|
||||
selectedSortingId.value = DEFAULT_SORTING_ID
|
||||
}
|
||||
|
||||
const root = computed(() => {
|
||||
const root = filteredRoot.value || nodeDefStore.nodeTree
|
||||
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
|
||||
// Determine which nodes to use
|
||||
const nodes =
|
||||
filteredNodeDefs.value.length > 0
|
||||
? filteredNodeDefs.value
|
||||
: nodeDefStore.visibleNodeDefs
|
||||
|
||||
// Use the service to organize nodes
|
||||
return nodeOrganizationService.organizeNodes(nodes, {
|
||||
groupBy: selectedGroupingId.value,
|
||||
sortBy: selectedSortingId.value
|
||||
})
|
||||
})
|
||||
|
||||
const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
@@ -144,12 +265,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
})
|
||||
|
||||
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
|
||||
const filteredRoot = computed<TreeNode | null>(() => {
|
||||
if (!filteredNodeDefs.value.length) {
|
||||
return null
|
||||
}
|
||||
return buildNodeDefTree(filteredNodeDefs.value)
|
||||
})
|
||||
const filters: Ref<
|
||||
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
|
||||
> = ref([])
|
||||
@@ -175,8 +290,10 @@ const handleSearch = async (query: string) => {
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expandNode(filteredRoot.value)
|
||||
// Expand the search results tree
|
||||
if (filteredNodeDefs.value.length > 0) {
|
||||
expandNode(root.value)
|
||||
}
|
||||
}
|
||||
|
||||
const onAddFilter = async (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<NodeTreeFolder :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<NodeTreeLeaf :node="node" />
|
||||
<NodeTreeLeaf :node="node" :open-node-help="props.openNodeHelp" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
|
||||
const props = defineProps<{
|
||||
filteredNodeDefs: ComfyNodeDefImpl[]
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
}>()
|
||||
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
|
||||
230
src/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-[var(--p-tree-background)] overflow-auto">
|
||||
<div
|
||||
class="px-3 py-2 flex items-center border-b border-[var(--p-divider-color)]"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.back')"
|
||||
icon="pi pi-arrow-left"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<span class="ml-2 font-semibold">{{ node.display_name }}</span>
|
||||
</div>
|
||||
<div class="p-4 flex-grow node-help-content max-w-[600px] mx-auto">
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="m-auto"
|
||||
aria-label="Loading help"
|
||||
/>
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div v-else class="text-sm space-y-6 fallback-content">
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="inputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const inputList = computed(() =>
|
||||
Object.values(node.inputs).map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
|
||||
const outputList = computed(() =>
|
||||
node.outputs.map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.markdown-content :deep(h5),
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content td {
|
||||
color: var(--drag-text);
|
||||
}
|
||||
|
||||
.markdown-content :deep(a),
|
||||
.fallback-content a {
|
||||
color: var(--drag-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.fallback-content th {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
@apply pl-8 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
.markdown-content :deep(ol ol),
|
||||
.markdown-content :deep(ul ol),
|
||||
.markdown-content :deep(ol ul),
|
||||
.fallback-content ul ul,
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
@apply pl-6 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-1;
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
@apply text-[var(--error-text)] bg-[var(--content-bg)] rounded px-1 py-0.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
.fallback-content tr {
|
||||
border-bottom: 1px solid var(--content-bg);
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr:last-child),
|
||||
.fallback-content tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(thead),
|
||||
.fallback-content thead {
|
||||
border-bottom: 1px solid var(--p-text-color);
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,15 @@
|
||||
severity="secondary"
|
||||
@click.stop="toggleBookmark"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.learnMore')"
|
||||
class="help-button"
|
||||
size="small"
|
||||
icon="pi pi-question"
|
||||
text
|
||||
severity="secondary"
|
||||
@click.stop="props.openNodeHelp(nodeDef)"
|
||||
/>
|
||||
</template>
|
||||
</TreeExplorerTreeNode>
|
||||
|
||||
@@ -54,6 +63,7 @@ import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
}>()
|
||||
|
||||
// Note: node.data should be present for leaf nodes.
|
||||
|
||||
@@ -46,10 +46,68 @@ vi.mock('@vueuse/core', () => ({
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => `/fileURL${path}`,
|
||||
apiURL: (path: string) => `/apiURL${path}`
|
||||
apiURL: (path: string) => `/apiURL${path}`,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflowTemplatesStore', () => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
isLoaded: true,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
groupedTemplates: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback: string) => fallback || key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTemplateWorkflows', () => ({
|
||||
useTemplateWorkflows: () => ({
|
||||
getTemplateThumbnailUrl: (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? `/fileURL/templates/${template.name}`
|
||||
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
},
|
||||
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
},
|
||||
getTemplateDescription: (template: TemplateInfo, sourceModule: string) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
},
|
||||
loadWorkflowTemplate: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowCard', () => {
|
||||
const createTemplate = (overrides = {}): TemplateInfo => ({
|
||||
name: 'test-template',
|
||||
|
||||
@@ -86,7 +86,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
|
||||
@@ -102,36 +102,36 @@ const { sourceModule, loading, template } = defineProps<{
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = useElementHover(cardRef)
|
||||
|
||||
const getThumbnailUrl = (index = '') => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
|
||||
useTemplateWorkflows()
|
||||
|
||||
// For templates from custom nodes, multiple images is not yet supported
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
// Determine the effective source module to use (from template or prop)
|
||||
const effectiveSourceModule = computed(
|
||||
() => template.sourceModule || sourceModule
|
||||
)
|
||||
|
||||
const baseThumbnailSrc = computed(() =>
|
||||
getThumbnailUrl(sourceModule === 'default' ? '1' : '')
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '1' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const overlayThumbnailSrc = computed(() =>
|
||||
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '2' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const description = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? ''
|
||||
: template.name
|
||||
})
|
||||
const description = computed(() =>
|
||||
getTemplateDescription(template, effectiveSourceModule.value)
|
||||
)
|
||||
const title = computed(() =>
|
||||
getTemplateTitle(template, effectiveSourceModule.value)
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<template>
|
||||
<DataTable
|
||||
v-model:selection="selectedTemplate"
|
||||
:value="templates"
|
||||
:value="enrichedTemplates"
|
||||
striped-rows
|
||||
selection-mode="single"
|
||||
>
|
||||
<Column field="title" :header="$t('g.title')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateTitle(slotProps.data)">{{
|
||||
getTemplateTitle(slotProps.data)
|
||||
}}</span>
|
||||
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" :header="$t('g.description')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateDescription(slotProps.data)">
|
||||
{{ getTemplateDescription(slotProps.data) }}
|
||||
<span :title="slotProps.data.description">
|
||||
{{ slotProps.data.description }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -38,8 +36,9 @@
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { sourceModule, loading, templates } = defineProps<{
|
||||
@@ -50,21 +49,20 @@ const { sourceModule, loading, templates } = defineProps<{
|
||||
}>()
|
||||
|
||||
const selectedTemplate = ref(null)
|
||||
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
|
||||
|
||||
const enrichedTemplates = computed(() => {
|
||||
return templates.map((template) => {
|
||||
const actualSourceModule = template.sourceModule || sourceModule
|
||||
return {
|
||||
...template,
|
||||
title: getTemplateTitle(template, actualSourceModule),
|
||||
description: getTemplateDescription(template, actualSourceModule)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
const getTemplateTitle = (template: TemplateInfo) => {
|
||||
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="!workflowTemplatesStore.isLoaded || !isReady"
|
||||
v-if="!isTemplatesLoaded || !isReady"
|
||||
class="absolute w-8 h-full inset-0"
|
||||
/>
|
||||
<TemplateWorkflowsSideNav
|
||||
:tabs="tabs"
|
||||
:selected-tab="selectedTab"
|
||||
:tabs="allTemplateGroups"
|
||||
:selected-tab="selectedTemplate"
|
||||
@update:selected-tab="handleTabSelection"
|
||||
/>
|
||||
</aside>
|
||||
@@ -37,14 +37,14 @@
|
||||
}"
|
||||
>
|
||||
<TemplateWorkflowView
|
||||
v-if="isReady && selectedTab"
|
||||
v-if="isReady && selectedTemplate"
|
||||
class="px-12 py-4"
|
||||
:title="selectedTab.title"
|
||||
:source-module="selectedTab.moduleName"
|
||||
:templates="selectedTab.templates"
|
||||
:loading="workflowLoading"
|
||||
:category-title="selectedTab.title"
|
||||
@load-workflow="loadWorkflow"
|
||||
:title="selectedTemplate.title"
|
||||
:source-module="selectedTemplate.moduleName"
|
||||
:templates="selectedTemplate.templates"
|
||||
:loading="loadingTemplateId"
|
||||
:category-title="selectedTemplate.title"
|
||||
@load-workflow="handleLoadWorkflow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,47 +56,46 @@ import { useAsyncState } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
isSmallScreen,
|
||||
isOpen: isSideNavOpen,
|
||||
toggle: toggleSideNav
|
||||
} = useResponsiveCollapse()
|
||||
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const { isReady } = useAsyncState(
|
||||
workflowTemplatesStore.loadWorkflowTemplates,
|
||||
null
|
||||
const {
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
loadWorkflowTemplate
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const { isReady } = useAsyncState(loadTemplates, null)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
() => {
|
||||
if (isReady.value) {
|
||||
selectFirstTemplateCategory()
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
const selectedTab = ref<WorkflowTemplates | null>(null)
|
||||
const selectFirstTab = () => {
|
||||
const firstTab = workflowTemplatesStore.groupedTemplates[0].modules[0]
|
||||
handleTabSelection(firstTab)
|
||||
}
|
||||
watch(isReady, selectFirstTab, { once: true })
|
||||
|
||||
const workflowLoading = ref<string | null>(null)
|
||||
|
||||
const tabs = computed(() => workflowTemplatesStore.groupedTemplates)
|
||||
|
||||
const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
//Listbox allows deselecting so this special case is ignored here
|
||||
if (selection !== selectedTab.value && selection !== null) {
|
||||
selectedTab.value = selection
|
||||
if (selection !== null) {
|
||||
selectTemplateCategory(selection)
|
||||
|
||||
// On small screens, close the sidebar when a category is selected
|
||||
if (isSmallScreen.value) {
|
||||
@@ -105,30 +104,9 @@ const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkflow = async (id: string) => {
|
||||
if (!isReady.value) return
|
||||
const handleLoadWorkflow = async (id: string) => {
|
||||
if (!isReady.value || !selectedTemplate.value) return false
|
||||
|
||||
workflowLoading.value = id
|
||||
let json
|
||||
if (selectedTab.value?.moduleName === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
json = await fetch(api.fileURL(`/templates/${id}.json`)).then((r) =>
|
||||
r.json()
|
||||
)
|
||||
} else {
|
||||
json = await fetch(
|
||||
api.apiURL(
|
||||
`/workflow_templates/${selectedTab.value?.moduleName}/${id}.json`
|
||||
)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
useDialogStore().closeDialog()
|
||||
const workflowName =
|
||||
selectedTab.value?.moduleName === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return false
|
||||
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('CompareSliderThumbnail', () => {
|
||||
it('positions slider based on default value', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const divider = wrapper.find('.bg-white\\/30')
|
||||
expect(divider.attributes('style')).toContain('left: 21%')
|
||||
expect(divider.attributes('style')).toContain('left: 50%')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { ref, watch } from 'vue'
|
||||
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const SLIDER_START_POSITION = 21
|
||||
const SLIDER_START_POSITION = 50
|
||||
|
||||
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
|
||||
baseImageSrc: string
|
||||
|
||||
@@ -50,9 +50,11 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
// Mock the useFirebaseAuthActions composable
|
||||
const mockLogout = vi.fn()
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined)
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -100,8 +102,7 @@ describe('CurrentUserPopover', () => {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Divider: true,
|
||||
Button: true
|
||||
Divider: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -114,6 +115,18 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.text()).toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('renders logout button with correct props', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[1]
|
||||
|
||||
// Check that logout button has correct props
|
||||
expect(logoutButton.props('label')).toBe('Log Out')
|
||||
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -132,12 +145,30 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('calls logout function and emits close event when logout button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[1]
|
||||
|
||||
// Click the logout button
|
||||
await logoutButton.trigger('click')
|
||||
|
||||
// Verify logout was called
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the API pricing button (second one)
|
||||
// Find all buttons and get the API pricing button (third one now)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const apiPricingButton = buttons[1]
|
||||
const apiPricingButton = buttons[2]
|
||||
|
||||
// Click the API pricing button
|
||||
await apiPricingButton.trigger('click')
|
||||
|
||||
@@ -37,6 +37,18 @@
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
@click="handleLogout"
|
||||
/>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('credits.apiPricing')"
|
||||
@@ -90,6 +102,11 @@ const handleTopUp = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authActions.logout()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenApiPricing = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
|
||||
emit('close')
|
||||
|
||||
@@ -101,6 +101,7 @@ Composables for sidebar functionality:
|
||||
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
|
||||
- `useQueueSidebarTab` - Manages the queue sidebar tab
|
||||
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
|
||||
- `useTemplateWorkflows` - Manages template workflow loading, selection, and display
|
||||
|
||||
### Widgets
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -111,10 +112,15 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
// Always add the badge for API nodes, with or without price text
|
||||
const creditsBadge = computed(() => {
|
||||
// Use dynamic background color based on the theme
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
|
||||
const isLightTheme =
|
||||
colorPaletteStore.completedActivePalette.light_theme
|
||||
return new LGraphBadge({
|
||||
@@ -137,7 +143,24 @@ export const useNodeBadge = () => {
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
node.constructor.nodeData?.name
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
widgetNames: relevantWidgetNames,
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
}
|
||||
|
||||
77
src/composables/node/useNodeImagePreview.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const IMAGE_PREVIEW_WIDGET_NAME = '$$node-image-preview'
|
||||
|
||||
/**
|
||||
* Composable for handling node-level operations for ImagePreview widget
|
||||
*/
|
||||
export function useNodeImagePreview() {
|
||||
const imagePreviewWidget = useImagePreviewWidget()
|
||||
|
||||
const findImagePreviewWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === IMAGE_PREVIEW_WIDGET_NAME)
|
||||
|
||||
const addImagePreviewWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec?: Partial<InputSpec>
|
||||
) =>
|
||||
imagePreviewWidget(node, {
|
||||
name: IMAGE_PREVIEW_WIDGET_NAME,
|
||||
type: 'IMAGEPREVIEW',
|
||||
allow_batch: true,
|
||||
image_folder: 'input',
|
||||
...inputSpec
|
||||
} as InputSpec)
|
||||
|
||||
/**
|
||||
* Shows image preview widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param images The images to display (can be single image or array)
|
||||
* @param options Configuration options
|
||||
*/
|
||||
function showImagePreview(
|
||||
node: LGraphNode,
|
||||
images: string | string[],
|
||||
options: {
|
||||
allow_batch?: boolean
|
||||
image_folder?: string
|
||||
imageInputName?: string
|
||||
} = {}
|
||||
) {
|
||||
const widget =
|
||||
findImagePreviewWidget(node) ??
|
||||
addImagePreviewWidget(node, {
|
||||
allow_batch: options.allow_batch,
|
||||
image_folder: options.image_folder || 'input'
|
||||
})
|
||||
|
||||
// Set the widget value
|
||||
widget.value = images
|
||||
node.setDirtyCanvas?.(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes image preview widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeImagePreview(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === IMAGE_PREVIEW_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showImagePreview,
|
||||
removeImagePreview
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,10 @@ export const useNodeImageUpload = (
|
||||
return validPaths
|
||||
}
|
||||
|
||||
// Handle drag & drop
|
||||
// Note: MediaLoader widget functionality is handled directly by
|
||||
// useImageUploadMediaWidget.ts to avoid circular dependencies
|
||||
|
||||
// Traditional approach: Handle drag & drop
|
||||
useNodeDragAndDrop(node, {
|
||||
fileFilter,
|
||||
onDrop: handleUploadBatch
|
||||
|
||||
122
src/composables/node/useNodeMediaUpload.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
interface MediaUploadOptions {
|
||||
fileFilter?: (file: File) => boolean
|
||||
onUploadComplete: (paths: string[]) => void
|
||||
allow_batch?: boolean
|
||||
accept?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling media upload with Vue MediaLoader widget
|
||||
*/
|
||||
export function useNodeMediaUpload() {
|
||||
const mediaLoaderWidget = useMediaLoaderWidget()
|
||||
|
||||
const findMediaLoaderWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === MEDIA_LOADER_WIDGET_NAME)
|
||||
|
||||
const addMediaLoaderWidget = (
|
||||
node: LGraphNode,
|
||||
options: MediaUploadOptions
|
||||
) => {
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the MediaLoader widget
|
||||
const widget = mediaLoaderWidget(node, {
|
||||
name: MEDIA_LOADER_WIDGET_NAME,
|
||||
type: 'MEDIA_LOADER'
|
||||
} as InputSpec)
|
||||
|
||||
// Connect the widget to the upload handler
|
||||
if (widget.options) {
|
||||
;(widget.options as any).onFilesSelected = async (files: File[]) => {
|
||||
const filteredFiles = options.fileFilter
|
||||
? files.filter(options.fileFilter)
|
||||
: files
|
||||
|
||||
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) {
|
||||
options.onUploadComplete(validPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows media loader widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param options Upload configuration options
|
||||
*/
|
||||
function showMediaLoader(node: LGraphNode, options: MediaUploadOptions) {
|
||||
const widget =
|
||||
findMediaLoaderWidget(node) ?? addMediaLoaderWidget(node, options)
|
||||
node.setDirtyCanvas?.(true)
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes media loader widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeMediaLoader(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === MEDIA_LOADER_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showMediaLoader,
|
||||
removeMediaLoader,
|
||||
addMediaLoaderWidget
|
||||
}
|
||||
}
|
||||
85
src/composables/node/useWatchWidget.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computedWithControl } from '@vueuse/core'
|
||||
import { type ComputedRef, ref } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
|
||||
export interface UseComputedWithWidgetWatchOptions {
|
||||
/**
|
||||
* Names of widgets to observe for changes.
|
||||
* If not provided, all widgets will be observed.
|
||||
*/
|
||||
widgetNames?: string[]
|
||||
|
||||
/**
|
||||
* Whether to trigger a canvas redraw when widget values change.
|
||||
* @default false
|
||||
*/
|
||||
triggerCanvasRedraw?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that creates a computed that has a node's widget values as a dependencies.
|
||||
* Essentially `computedWithControl` (https://vueuse.org/shared/computedWithControl/) where
|
||||
* the explicitly defined extra dependencies are LGraphNode widgets.
|
||||
*
|
||||
* @param node - The LGraphNode whose widget values are to be watched
|
||||
* @param options - Configuration options for the watcher
|
||||
* @returns A function to create computed that responds to widget changes
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
* widgetNames: ['width', 'height'],
|
||||
* triggerCanvasRedraw: true
|
||||
* })
|
||||
*
|
||||
* const dynamicPrice = computedWithWidgetWatch(() => {
|
||||
* return calculatePrice(node)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useComputedWithWidgetWatch = (
|
||||
node: LGraphNode,
|
||||
options: UseComputedWithWidgetWatchOptions = {}
|
||||
) => {
|
||||
const { widgetNames, triggerCanvasRedraw = false } = options
|
||||
|
||||
// Create a reactive trigger based on widget values
|
||||
const widgetValues = ref<Record<string, any>>({})
|
||||
|
||||
// Initialize widget observers
|
||||
if (node.widgets) {
|
||||
const widgetsToObserve = widgetNames
|
||||
? node.widgets.filter((widget) => widgetNames.includes(widget.name))
|
||||
: node.widgets
|
||||
|
||||
// Initialize current values
|
||||
const currentValues: Record<string, any> = {}
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
currentValues[widget.name] = widget.value
|
||||
})
|
||||
widgetValues.value = currentValues
|
||||
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
widget.callback = useChainCallback(widget.callback, () => {
|
||||
// Update the reactive widget values
|
||||
widgetValues.value = {
|
||||
...widgetValues.value,
|
||||
[widget.name]: widget.value
|
||||
}
|
||||
|
||||
// Optionally trigger a canvas redraw
|
||||
if (triggerCanvasRedraw) {
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Returns a function that creates a computed that responds to widget changes.
|
||||
// The computed will be re-evaluated whenever any observed widget changes.
|
||||
return <T>(computeFn: () => T): ComputedRef<T> => {
|
||||
return computedWithControl(widgetValues, computeFn)
|
||||
}
|
||||
}
|
||||
71
src/composables/nodeRendering/useNodeInteractionProxy.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { computed } from 'vue'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
export interface NodeInteractionEvent {
|
||||
type: 'mousedown' | 'contextmenu' | 'slot-click'
|
||||
nodeId: string
|
||||
originalEvent: MouseEvent
|
||||
slotIndex?: number
|
||||
}
|
||||
|
||||
export function useNodeInteractionProxy() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
const handleNodeInteraction = (event: NodeInteractionEvent) => {
|
||||
const { type, nodeId, originalEvent } = event
|
||||
|
||||
if (!canvas.value?.graph) return
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return
|
||||
|
||||
switch (type) {
|
||||
case 'mousedown':
|
||||
// Convert Vue event coordinates back to canvas coordinates
|
||||
const rect = canvas.value.canvas.getBoundingClientRect()
|
||||
const canvasX = originalEvent.clientX - rect.left
|
||||
const canvasY = originalEvent.clientY - rect.top
|
||||
|
||||
// Transform to graph coordinates
|
||||
const graphPos = canvas.value.convertOffsetToCanvas([canvasX, canvasY])
|
||||
|
||||
// Note: simulatedEvent not currently used but kept for future expansion
|
||||
|
||||
// Trigger node selection and dragging
|
||||
canvas.value.selectNode(node, originalEvent.ctrlKey || originalEvent.metaKey)
|
||||
canvas.value.node_dragged = node
|
||||
|
||||
// Start drag operation if not holding modifier keys
|
||||
if (!originalEvent.ctrlKey && !originalEvent.metaKey && !originalEvent.shiftKey) {
|
||||
canvas.value.dragging_canvas = false
|
||||
canvas.value.node_dragged = node
|
||||
canvas.value.drag_start = [originalEvent.clientX, originalEvent.clientY]
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'contextmenu':
|
||||
// Show context menu for the node
|
||||
originalEvent.preventDefault()
|
||||
canvas.value.showContextMenu(originalEvent, node)
|
||||
break
|
||||
|
||||
case 'slot-click':
|
||||
// Handle slot connection interactions
|
||||
if (event.slotIndex !== undefined) {
|
||||
const slot = node.inputs?.[event.slotIndex] || node.outputs?.[event.slotIndex]
|
||||
if (slot) {
|
||||
canvas.value.processSlotClick(node, event.slotIndex, originalEvent)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleNodeInteraction
|
||||
}
|
||||
}
|
||||
124
src/composables/nodeRendering/useNodePositionSync.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ref, computed, readonly, watchEffect } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
// Note: useEventListener imported but not currently used - may be used for future enhancements
|
||||
|
||||
export interface NodePosition {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function useNodePositionSync() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodePositions = ref<Record<string, NodePosition>>({})
|
||||
const canvasScale = ref(1)
|
||||
const canvasOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
// Sync canvas transform (scale and offset)
|
||||
watchEffect(() => {
|
||||
if (!canvas.value) return
|
||||
|
||||
const updateTransform = () => {
|
||||
if (!canvas.value?.ds) return
|
||||
|
||||
canvasScale.value = canvas.value.ds.scale
|
||||
canvasOffset.value = {
|
||||
x: canvas.value.ds.offset[0],
|
||||
y: canvas.value.ds.offset[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Hook into the canvas draw cycle to update transform
|
||||
canvas.value.onDrawForeground = useChainCallback(
|
||||
canvas.value.onDrawForeground,
|
||||
updateTransform
|
||||
)
|
||||
|
||||
// Initial transform update
|
||||
updateTransform()
|
||||
})
|
||||
|
||||
// Sync node positions
|
||||
const syncNodePositions = () => {
|
||||
if (!canvas.value?.graph) return
|
||||
|
||||
const positions: Record<string, NodePosition> = {}
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
positions[node.id] = {
|
||||
id: String(node.id),
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
}
|
||||
nodePositions.value = positions
|
||||
}
|
||||
|
||||
// Listen for node position changes
|
||||
watchEffect(() => {
|
||||
if (!canvas.value) return
|
||||
|
||||
// Hook into various node update events
|
||||
const originalOnNodeMoved = canvas.value.onNodeMoved
|
||||
canvas.value.onNodeMoved = useChainCallback(
|
||||
originalOnNodeMoved,
|
||||
syncNodePositions
|
||||
)
|
||||
|
||||
// Hook into general graph changes
|
||||
const originalOnGraphChanged = canvas.value.onGraphChanged
|
||||
canvas.value.onGraphChanged = useChainCallback(
|
||||
originalOnGraphChanged,
|
||||
syncNodePositions
|
||||
)
|
||||
|
||||
// Initial sync
|
||||
syncNodePositions()
|
||||
})
|
||||
|
||||
// Get visible nodes (within viewport bounds)
|
||||
const visibleNodes = computed(() => {
|
||||
if (!canvas.value?.graph) {
|
||||
console.log('🚫 useNodePositionSync: No canvas or graph available')
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = canvas.value.graph._nodes
|
||||
console.log('🔍 useNodePositionSync: Checking', allNodes.length, 'total nodes')
|
||||
|
||||
const phantomNodes = allNodes.filter((node: LGraphNode) => {
|
||||
const isPhantom = node.phantom_mode === true
|
||||
if (isPhantom) {
|
||||
console.log('👻 Found phantom node:', { id: node.id, title: node.title, phantom_mode: node.phantom_mode })
|
||||
}
|
||||
return isPhantom
|
||||
})
|
||||
|
||||
console.log('📊 useNodePositionSync: Found', phantomNodes.length, 'phantom nodes out of', allNodes.length, 'total')
|
||||
|
||||
// TODO: Add viewport culling for performance
|
||||
// For now, return all phantom nodes
|
||||
return phantomNodes
|
||||
})
|
||||
|
||||
// Manual sync function for external triggers
|
||||
const forceSync = () => {
|
||||
syncNodePositions()
|
||||
}
|
||||
|
||||
return {
|
||||
nodePositions: readonly(nodePositions),
|
||||
canvasScale: readonly(canvasScale),
|
||||
canvasOffset: readonly(canvasOffset),
|
||||
visibleNodes: readonly(visibleNodes),
|
||||
forceSync
|
||||
}
|
||||
}
|
||||
142
src/composables/nodeRendering/usePhantomNodes.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { computed } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
export function usePhantomNodes() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
// Check if Vue node rendering is enabled
|
||||
const vueRenderingEnabled = computed(() => true) // Temporarily enabled for testing
|
||||
|
||||
/**
|
||||
* Enable phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to make phantom
|
||||
*/
|
||||
const enablePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
node.phantom_mode = true
|
||||
// Trigger canvas redraw to hide the node visually
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to make visible again
|
||||
*/
|
||||
const disablePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
node.phantom_mode = false
|
||||
// Trigger canvas redraw to show the node visually
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to toggle
|
||||
*/
|
||||
const togglePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
const newMode = !node.phantom_mode
|
||||
node.phantom_mode = newMode
|
||||
// Trigger canvas redraw
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return newMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable phantom mode for all nodes (global Vue rendering)
|
||||
*/
|
||||
const enableAllPhantomMode = () => {
|
||||
if (!canvas.value?.graph) return 0
|
||||
|
||||
let count = 0
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
if (!node.phantom_mode) {
|
||||
node.phantom_mode = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable phantom mode for all nodes (back to canvas rendering)
|
||||
*/
|
||||
const disableAllPhantomMode = () => {
|
||||
if (!canvas.value?.graph) return 0
|
||||
|
||||
let count = 0
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
if (node.phantom_mode) {
|
||||
node.phantom_mode = false
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all phantom nodes
|
||||
*/
|
||||
const getPhantomNodes = (): LGraphNode[] => {
|
||||
if (!canvas.value?.graph) return []
|
||||
|
||||
return canvas.value.graph._nodes.filter((node: LGraphNode) =>
|
||||
node.phantom_mode === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is in phantom mode
|
||||
* @param nodeId The ID of the node to check
|
||||
*/
|
||||
const isPhantomNode = (nodeId: string | number): boolean => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
return node?.phantom_mode === true
|
||||
}
|
||||
|
||||
return {
|
||||
vueRenderingEnabled,
|
||||
enablePhantomMode,
|
||||
disablePhantomMode,
|
||||
togglePhantomMode,
|
||||
enableAllPhantomMode,
|
||||
disableAllPhantomMode,
|
||||
getPhantomNodes,
|
||||
isPhantomNode
|
||||
}
|
||||
}
|
||||
59
src/composables/nodeRendering/useTestPhantomNodes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { usePhantomNodes } from './usePhantomNodes'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Development helper to automatically enable phantom mode for testing
|
||||
*/
|
||||
export function useTestPhantomNodes() {
|
||||
const { enableAllPhantomMode, getPhantomNodes } = usePhantomNodes()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
let graphChangeHandler: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// Function to enable phantom mode for all nodes
|
||||
const enablePhantomModeForAllNodes = () => {
|
||||
if (canvasStore.canvas?.graph) {
|
||||
const count = enableAllPhantomMode()
|
||||
if (count > 0) {
|
||||
console.log(`✅ Enabled phantom mode for ${count} nodes`)
|
||||
}
|
||||
return count
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Listen for graph changes to immediately enable phantom mode for new nodes
|
||||
graphChangeHandler = () => {
|
||||
enablePhantomModeForAllNodes()
|
||||
}
|
||||
|
||||
api.addEventListener('graphChanged', graphChangeHandler)
|
||||
|
||||
// Initial attempt when mounted
|
||||
setTimeout(() => {
|
||||
enablePhantomModeForAllNodes()
|
||||
}, 100) // Much shorter timeout just to ensure canvas is ready
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (graphChangeHandler) {
|
||||
api.removeEventListener('graphChanged', graphChangeHandler)
|
||||
}
|
||||
})
|
||||
|
||||
// Expose helper functions to global scope for manual testing
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).testPhantomNodes = {
|
||||
enableAll: enableAllPhantomMode,
|
||||
getPhantom: getPhantomNodes,
|
||||
enableSingle: (nodeId: string) => {
|
||||
const { enablePhantomMode } = usePhantomNodes()
|
||||
return enablePhantomMode(nodeId)
|
||||
}
|
||||
}
|
||||
console.log('🚀 Phantom node testing helpers available on window.testPhantomNodes')
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
return
|
||||
}
|
||||
app.canvas.fitViewToSelectionAnimated()
|
||||
// Trigger re-render of Vue nodes after view change
|
||||
api.dispatchCustomEvent('graphChanged')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
190
src/composables/useTemplateWorkflows.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/types/workflowTemplateTypes'
|
||||
|
||||
export function useTemplateWorkflows() {
|
||||
const { t } = useI18n()
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// State
|
||||
const selectedTemplate = ref<WorkflowTemplates | null>(null)
|
||||
const loadingTemplateId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
|
||||
const allTemplateGroups = computed<TemplateGroup[]>(
|
||||
() => workflowTemplatesStore.groupedTemplates
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads all template workflows from the API
|
||||
*/
|
||||
const loadTemplates = async () => {
|
||||
if (!workflowTemplatesStore.isLoaded) {
|
||||
await workflowTemplatesStore.loadWorkflowTemplates()
|
||||
}
|
||||
return workflowTemplatesStore.isLoaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first template category as default
|
||||
*/
|
||||
const selectFirstTemplateCategory = () => {
|
||||
if (allTemplateGroups.value.length > 0) {
|
||||
const firstCategory = allTemplateGroups.value[0].modules[0]
|
||||
selectTemplateCategory(firstCategory)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a template category
|
||||
*/
|
||||
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
|
||||
selectedTemplate.value = category
|
||||
return category !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets template thumbnail URL
|
||||
*/
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template title
|
||||
*/
|
||||
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a workflow template
|
||||
*/
|
||||
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
|
||||
if (!isTemplatesLoaded.value) return false
|
||||
|
||||
loadingTemplateId.value = id
|
||||
let json
|
||||
|
||||
try {
|
||||
// Handle "All" category as a special case
|
||||
if (sourceModule === 'all') {
|
||||
// Find "All" category in the ComfyUI Examples group
|
||||
const comfyExamplesGroup = allTemplateGroups.value.find(
|
||||
(g) =>
|
||||
g.label ===
|
||||
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
)
|
||||
const allCategory = comfyExamplesGroup?.modules.find(
|
||||
(m) => m.moduleName === 'all'
|
||||
)
|
||||
const template = allCategory?.templates.find((t) => t.name === id)
|
||||
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
// Use the stored source module for loading
|
||||
const actualSourceModule = template.sourceModule
|
||||
json = await fetchTemplateJson(id, actualSourceModule)
|
||||
|
||||
// Use source module for name
|
||||
const workflowName =
|
||||
actualSourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
const workflowName =
|
||||
sourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
return false
|
||||
} finally {
|
||||
loadingTemplateId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
} else {
|
||||
return fetch(
|
||||
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
|
||||
// Computed
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
|
||||
// Methods
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription,
|
||||
loadWorkflowTemplate
|
||||
}
|
||||
}
|
||||
166
src/composables/widgets/useBadgedNumberInput.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import BadgedNumberInput from '@/components/graph/widgets/BadgedNumberInput.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
|
||||
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
|
||||
|
||||
type NumberWidgetMode = 'int' | 'float'
|
||||
|
||||
interface BadgedNumberInputOptions {
|
||||
defaultValue?: number
|
||||
badgeState?: BadgeState
|
||||
disabled?: boolean
|
||||
serialize?: boolean
|
||||
mode?: NumberWidgetMode
|
||||
}
|
||||
|
||||
// Helper function to map control widget values to badge states
|
||||
const mapControlValueToBadgeState = (controlValue: string): BadgeState => {
|
||||
switch (controlValue) {
|
||||
case 'fixed':
|
||||
return 'lock'
|
||||
case 'increment':
|
||||
return 'increment'
|
||||
case 'decrement':
|
||||
return 'decrement'
|
||||
case 'randomize':
|
||||
return 'random'
|
||||
default:
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
export const useBadgedNumberInput = (
|
||||
options: BadgedNumberInputOptions = {}
|
||||
) => {
|
||||
const {
|
||||
defaultValue = 0,
|
||||
disabled = false,
|
||||
serialize = true,
|
||||
mode = 'int'
|
||||
} = options
|
||||
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value as string to conform to ComponentWidgetImpl requirements
|
||||
const widgetValue = ref<string>(defaultValue.toString())
|
||||
|
||||
// Determine if we should show control widget and badge
|
||||
const shouldShowControlWidget =
|
||||
inputSpec.control_after_generate ??
|
||||
// Legacy compatibility: seed inputs get control widgets
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
// Create reactive props object for the component
|
||||
const componentProps = reactive({
|
||||
badgeState:
|
||||
options.badgeState ??
|
||||
(shouldShowControlWidget ? 'random' : ('normal' as BadgeState)),
|
||||
disabled
|
||||
})
|
||||
|
||||
const controlWidget: any = null
|
||||
|
||||
// Create the main widget instance
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
Omit<
|
||||
InstanceType<typeof BadgedNumberInput>['$props'],
|
||||
'widget' | 'modelValue'
|
||||
>
|
||||
>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: BadgedNumberInput,
|
||||
inputSpec,
|
||||
props: componentProps,
|
||||
options: {
|
||||
// Required: getter for widget value - return as string
|
||||
getValue: () => widgetValue.value as string | object,
|
||||
|
||||
// Required: setter for widget value - accept number, string or object
|
||||
setValue: (value: string | object | number) => {
|
||||
let numValue: number
|
||||
if (typeof value === 'object') {
|
||||
numValue = parseFloat(JSON.stringify(value))
|
||||
} else {
|
||||
numValue =
|
||||
typeof value === 'number' ? value : parseFloat(String(value))
|
||||
}
|
||||
|
||||
if (!isNaN(numValue)) {
|
||||
// Apply int/float specific value processing
|
||||
if (mode === 'int') {
|
||||
const step = (inputSpec as any).step ?? 1
|
||||
if (step === 1) {
|
||||
numValue = Math.round(numValue)
|
||||
} else {
|
||||
const min = (inputSpec as any).min ?? 0
|
||||
const offset = min % step
|
||||
numValue =
|
||||
Math.round((numValue - offset) / step) * step + offset
|
||||
}
|
||||
}
|
||||
widgetValue.value = numValue.toString()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
}
|
||||
})
|
||||
|
||||
// Add control widget if needed - temporarily disabled to fix circular dependency
|
||||
if (shouldShowControlWidget) {
|
||||
// TODO: Re-implement control widget functionality without circular dependency
|
||||
console.warn(
|
||||
'Control widget functionality temporarily disabled due to circular dependency'
|
||||
)
|
||||
// controlWidget = addValueControlWidget(
|
||||
// node,
|
||||
// widget as any, // Cast to satisfy the interface
|
||||
// 'randomize',
|
||||
// undefined,
|
||||
// undefined,
|
||||
// transformInputSpecV2ToV1(inputSpec)
|
||||
// )
|
||||
|
||||
// Set up reactivity to update badge state when control widget changes
|
||||
if (controlWidget) {
|
||||
const originalCallback = controlWidget.callback
|
||||
controlWidget.callback = function (value: string) {
|
||||
componentProps.badgeState = mapControlValueToBadgeState(value)
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize badge state
|
||||
componentProps.badgeState = mapControlValueToBadgeState(
|
||||
controlWidget.value || 'randomize'
|
||||
)
|
||||
|
||||
// Link the widgets
|
||||
;(widget as any).linkedWidgets = [controlWidget]
|
||||
}
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { BadgeState, BadgedNumberInputOptions, NumberWidgetMode }
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type InputSpec,
|
||||
isBooleanInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
export const useBooleanWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
|
||||
@@ -4,9 +4,8 @@ import { ref } from 'vue'
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useChatHistoryWidget = (
|
||||
options: {
|
||||
@@ -32,7 +31,6 @@ export const useChatHistoryWidget = (
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => 400 + PADDING
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
|
||||
200
src/composables/widgets/useColorPickerWidget.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ColorPickerWidget from '@/components/graph/widgets/ColorPickerWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
interface ColorPickerWidgetOptions {
|
||||
defaultValue?: string
|
||||
defaultFormat?: 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
export const useColorPickerWidget = (
|
||||
options: ColorPickerWidgetOptions = {}
|
||||
) => {
|
||||
const {
|
||||
defaultValue = 'rgba(255, 0, 0, 1)',
|
||||
serialize = true
|
||||
} = options
|
||||
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value as string
|
||||
const widgetValue = ref<string>(defaultValue)
|
||||
|
||||
// Create the main widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ColorPickerWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string | any) => {
|
||||
// Handle different input types
|
||||
if (typeof value === 'string') {
|
||||
// Validate and normalize color string
|
||||
const normalizedValue = normalizeColorString(value)
|
||||
if (normalizedValue) {
|
||||
widgetValue.value = normalizedValue
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Handle object input (e.g., from PrimeVue ColorPicker)
|
||||
if (value.hex) {
|
||||
widgetValue.value = value.hex
|
||||
} else {
|
||||
// Try to convert object to string
|
||||
widgetValue.value = String(value)
|
||||
}
|
||||
} else {
|
||||
// Fallback to string conversion
|
||||
widgetValue.value = String(value)
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
}
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes color string inputs to ensure consistent format
|
||||
* @param colorString - The input color string
|
||||
* @returns Normalized color string or null if invalid
|
||||
*/
|
||||
function normalizeColorString(colorString: string): string | null {
|
||||
if (!colorString || typeof colorString !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = colorString.trim()
|
||||
|
||||
// Handle hex colors
|
||||
if (trimmed.startsWith('#')) {
|
||||
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) {
|
||||
// Convert 3-digit hex to 6-digit
|
||||
if (trimmed.length === 4) {
|
||||
return (
|
||||
'#' +
|
||||
trimmed[1] +
|
||||
trimmed[1] +
|
||||
trimmed[2] +
|
||||
trimmed[2] +
|
||||
trimmed[3] +
|
||||
trimmed[3]
|
||||
)
|
||||
}
|
||||
return trimmed.toLowerCase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle rgb/rgba colors
|
||||
if (trimmed.startsWith('rgb')) {
|
||||
const rgbaMatch = trimmed.match(
|
||||
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (rgbaMatch) {
|
||||
const [, r, g, b, a] = rgbaMatch
|
||||
const red = Math.max(0, Math.min(255, parseInt(r)))
|
||||
const green = Math.max(0, Math.min(255, parseInt(g)))
|
||||
const blue = Math.max(0, Math.min(255, parseInt(b)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `rgb(${red}, ${green}, ${blue})`
|
||||
} else {
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle hsl/hsla colors
|
||||
if (trimmed.startsWith('hsl')) {
|
||||
const hslaMatch = trimmed.match(
|
||||
/hsla?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (hslaMatch) {
|
||||
const [, h, s, l, a] = hslaMatch
|
||||
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||
const lightness = Math.max(0, Math.min(100, parseInt(l)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
|
||||
} else {
|
||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle hsv/hsva colors (custom format)
|
||||
if (trimmed.startsWith('hsv')) {
|
||||
const hsvaMatch = trimmed.match(
|
||||
/hsva?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (hsvaMatch) {
|
||||
const [, h, s, v, a] = hsvaMatch
|
||||
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||
const value = Math.max(0, Math.min(100, parseInt(v)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `hsv(${hue}, ${saturation}%, ${value}%)`
|
||||
} else {
|
||||
return `hsva(${hue}, ${saturation}%, ${value}%, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle named colors by converting to hex (basic set)
|
||||
const namedColors: Record<string, string> = {
|
||||
red: '#ff0000',
|
||||
green: '#008000',
|
||||
blue: '#0000ff',
|
||||
white: '#ffffff',
|
||||
black: '#000000',
|
||||
yellow: '#ffff00',
|
||||
cyan: '#00ffff',
|
||||
magenta: '#ff00ff',
|
||||
orange: '#ffa500',
|
||||
purple: '#800080',
|
||||
pink: '#ffc0cb',
|
||||
brown: '#a52a2a',
|
||||
gray: '#808080',
|
||||
grey: '#808080'
|
||||
}
|
||||
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
if (namedColors[lowerTrimmed]) {
|
||||
return namedColors[lowerTrimmed]
|
||||
}
|
||||
|
||||
// If we can't parse it, return null
|
||||
return null
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { ColorPickerWidgetOptions }
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
ComboInputSpec,
|
||||
type InputSpec,
|
||||
@@ -14,19 +12,11 @@ import {
|
||||
ComponentWidgetImpl,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidgets
|
||||
} from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
import { useDropdownComboWidget } from './useDropdownComboWidget'
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
return undefined
|
||||
}
|
||||
// Default value logic is now handled in useDropdownComboWidget
|
||||
|
||||
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const widgetValue = ref<string[]>([])
|
||||
@@ -39,7 +29,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = value
|
||||
}
|
||||
},
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||
@@ -49,49 +41,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
}
|
||||
|
||||
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
const comboOptions = inputSpec.options ?? []
|
||||
const widget = node.addWidget(
|
||||
'combo',
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
() => {},
|
||||
{
|
||||
values: comboOptions
|
||||
}
|
||||
) as IComboWidget
|
||||
|
||||
if (inputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: inputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
// Assertion: Proxy handler passthrough
|
||||
return prop !== 'values'
|
||||
? target[prop as keyof typeof target]
|
||||
: remoteWidget.getValue()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
return widget
|
||||
// Use the new dropdown combo widget for single-selection combo widgets
|
||||
const dropdownWidget = useDropdownComboWidget()
|
||||
return dropdownWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
export const useComboWidget = () => {
|
||||
|
||||
94
src/composables/widgets/useDropdownComboWidget.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DropdownComboWidget from '@/components/graph/widgets/DropdownComboWidget.vue'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
return ''
|
||||
}
|
||||
|
||||
export const useDropdownComboWidget = (
|
||||
options: { defaultValue?: string } = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Type assertion to ComboInputSpec since this is specifically for combo widgets
|
||||
const comboInputSpec = inputSpec as ComboInputSpec
|
||||
|
||||
// Initialize widget value
|
||||
const defaultValue = options.defaultValue ?? getDefaultValue(comboInputSpec)
|
||||
const widgetValue = ref<string>(defaultValue)
|
||||
|
||||
// Create the widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: DropdownComboWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle remote widget functionality
|
||||
if (comboInputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: comboInputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget: widget as any // Cast to be compatible with the remote widget interface
|
||||
})
|
||||
if (comboInputSpec.remote.refresh_button) {
|
||||
remoteWidget.addRefreshButton()
|
||||
}
|
||||
|
||||
// Update the widget to use remote data
|
||||
// Note: The remote widget will handle updating the options through the inputSpec
|
||||
}
|
||||
|
||||
// Handle control_after_generate widgets
|
||||
if (comboInputSpec.control_after_generate) {
|
||||
const linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget as any, // Cast to be compatible with legacy widget interface
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(comboInputSpec)
|
||||
)
|
||||
// Store reference to linked widgets (mimicking original behavior)
|
||||
;(widget as any).linkedWidgets = linkedWidgets
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type InputSpec,
|
||||
isFloatInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
|
||||
@@ -1,285 +1,49 @@
|
||||
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
ICustomWidget,
|
||||
IWidgetOptions
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ImagePreviewWidget from '@/components/graph/widgets/ImagePreviewWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
||||
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const renderPreview = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
shiftY: number
|
||||
// Removed PADDING constant as it's no longer needed for CSS flexbox layout
|
||||
|
||||
export const useImagePreviewWidget = (
|
||||
options: { defaultValue?: string | string[] } = {}
|
||||
) => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const mouse = canvas.graph_mouse
|
||||
|
||||
if (!canvas.pointer_is_down && node.pointerDown) {
|
||||
if (
|
||||
mouse[0] === node.pointerDown.pos[0] &&
|
||||
mouse[1] === node.pointerDown.pos[1]
|
||||
) {
|
||||
node.imageIndex = node.pointerDown.index
|
||||
}
|
||||
node.pointerDown = null
|
||||
}
|
||||
|
||||
const imgs = node.imgs ?? []
|
||||
let { imageIndex } = node
|
||||
const numImages = imgs.length
|
||||
if (numImages === 1 && !imageIndex) {
|
||||
// This skips the thumbnail render section below
|
||||
node.imageIndex = imageIndex = 0
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
|
||||
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
|
||||
const dw = node.size[0]
|
||||
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
|
||||
|
||||
if (imageIndex == null) {
|
||||
// No image selected; draw thumbnails of all
|
||||
let cellWidth: number
|
||||
let cellHeight: number
|
||||
let shiftX: number
|
||||
let cell_padding: number
|
||||
let cols: number
|
||||
|
||||
const compact_mode = is_all_same_aspect_ratio(imgs)
|
||||
if (!compact_mode) {
|
||||
// use rectangle cell style and border line
|
||||
cell_padding = 2
|
||||
// Prevent infinite canvas2d scale-up
|
||||
const largestDimension = imgs.reduce(
|
||||
(acc, current) =>
|
||||
Math.max(acc, current.naturalWidth, current.naturalHeight),
|
||||
0
|
||||
)
|
||||
const fakeImgs = []
|
||||
fakeImgs.length = imgs.length
|
||||
fakeImgs[0] = {
|
||||
naturalWidth: largestDimension,
|
||||
naturalHeight: largestDimension
|
||||
}
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
fakeImgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
} else {
|
||||
cell_padding = 0
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
imgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
}
|
||||
|
||||
let anyHovered = false
|
||||
node.imageRects = []
|
||||
for (let i = 0; i < numImages; i++) {
|
||||
const img = imgs[i]
|
||||
const row = Math.floor(i / cols)
|
||||
const col = i % cols
|
||||
const x = col * cellWidth + shiftX
|
||||
const y = row * cellHeight + shiftY
|
||||
if (!anyHovered) {
|
||||
anyHovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
cellWidth,
|
||||
cellHeight
|
||||
)
|
||||
if (anyHovered) {
|
||||
node.overIndex = i
|
||||
let value = 110
|
||||
if (canvas.pointer_is_down) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
value = 125
|
||||
}
|
||||
ctx.filter = `contrast(${value}%) brightness(${value}%)`
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
}
|
||||
}
|
||||
node.imageRects.push([x, y, cellWidth, cellHeight])
|
||||
|
||||
const wratio = cellWidth / img.width
|
||||
const hratio = cellHeight / img.height
|
||||
const ratio = Math.min(wratio, hratio)
|
||||
|
||||
const imgHeight = ratio * img.height
|
||||
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
|
||||
const imgWidth = ratio * img.width
|
||||
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
imgX + cell_padding,
|
||||
imgY + cell_padding,
|
||||
imgWidth - cell_padding * 2,
|
||||
imgHeight - cell_padding * 2
|
||||
)
|
||||
if (!compact_mode) {
|
||||
// rectangle cell and border line style
|
||||
ctx.strokeStyle = '#8F8F8F'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(
|
||||
x + cell_padding,
|
||||
y + cell_padding,
|
||||
cellWidth - cell_padding * 2,
|
||||
cellHeight - cell_padding * 2
|
||||
)
|
||||
}
|
||||
|
||||
ctx.filter = 'none'
|
||||
}
|
||||
|
||||
if (!anyHovered) {
|
||||
node.pointerDown = null
|
||||
node.overIndex = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
// Draw individual
|
||||
const img = imgs[imageIndex]
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
|
||||
const scaleX = dw / w
|
||||
const scaleY = dh / h
|
||||
const scale = Math.min(scaleX, scaleY, 1)
|
||||
|
||||
w *= scale
|
||||
h *= scale
|
||||
|
||||
const x = (dw - w) / 2
|
||||
const y = (dh - h) / 2 + shiftY
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
|
||||
// Draw image size text below the image
|
||||
if (allowImageSizeDraw) {
|
||||
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.font = '10px sans-serif'
|
||||
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
|
||||
const textY = y + h + 10
|
||||
ctx.fillText(sizeText, x + w / 2, textY)
|
||||
}
|
||||
|
||||
const drawButton = (
|
||||
x: number,
|
||||
y: number,
|
||||
sz: number,
|
||||
text: string
|
||||
): boolean => {
|
||||
const hovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
sz,
|
||||
sz
|
||||
)
|
||||
let fill = '#333'
|
||||
let textFill = '#fff'
|
||||
let isClicking = false
|
||||
if (hovered) {
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
if (canvas.pointer_is_down) {
|
||||
fill = '#1e90ff'
|
||||
isClicking = true
|
||||
} else {
|
||||
fill = '#eee'
|
||||
textFill = '#000'
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
|
||||
return isClicking
|
||||
}
|
||||
|
||||
if (!(numImages > 1)) return
|
||||
|
||||
const imageNum = (node.imageIndex ?? 0) + 1
|
||||
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
|
||||
const i = imageNum >= numImages ? 0 : imageNum
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
|
||||
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== null) {
|
||||
node.pointerDown = { index: null, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewWidget implements ICustomWidget {
|
||||
readonly type: 'custom'
|
||||
readonly name: string
|
||||
readonly options: IWidgetOptions<string | object>
|
||||
/** Dummy value to satisfy type requirements. */
|
||||
value: string
|
||||
y: number = 0
|
||||
/** Don't serialize the widget value. */
|
||||
serialize: boolean = false
|
||||
|
||||
constructor(name: string, options: IWidgetOptions<string | object>) {
|
||||
this.type = 'custom'
|
||||
this.name = name
|
||||
this.options = options
|
||||
this.value = ''
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
_width: number,
|
||||
y: number,
|
||||
_height: number
|
||||
): void {
|
||||
renderPreview(ctx, node, y)
|
||||
}
|
||||
|
||||
computeLayoutSize(this: IBaseWidget) {
|
||||
return {
|
||||
minHeight: 220,
|
||||
minWidth: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useImagePreviewWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(inputSpec.name, {
|
||||
serialize: false
|
||||
})
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string | string[]>(
|
||||
options.defaultValue ?? (inputSpec.allow_batch ? [] : '')
|
||||
)
|
||||
|
||||
// Create the Vue-based widget instance
|
||||
const widget = new ComponentWidgetImpl<string | string[]>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ImagePreviewWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string | string[]) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: false
|
||||
}
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
|
||||
208
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
type InternalFile = string | ResultItem
|
||||
type InternalValue = InternalFile | InternalFile[]
|
||||
type ExposedValue = string | string[]
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||
|
||||
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||
value: ExposedValue
|
||||
}
|
||||
|
||||
export const useImageUploadMediaWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
const isVideo = !!inputOptions.video_upload
|
||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
if (!internalValue) return initialFile
|
||||
if (Array.isArray(internalValue))
|
||||
return allow_batch
|
||||
? internalValue.map(formatPath)
|
||||
: formatPath(internalValue[0])
|
||||
return formatPath(internalValue)
|
||||
}
|
||||
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
|
||||
// Convert the V1 input spec to V2 format for the MediaLoader widget
|
||||
const inputSpecV2 = transformInputSpecV1ToV2(inputData, { name: inputName })
|
||||
|
||||
|
||||
// State for MediaLoader widget
|
||||
const uploadedFiles = ref<string[]>([])
|
||||
|
||||
// Create the MediaLoader widget directly
|
||||
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
|
||||
{
|
||||
node,
|
||||
name: inputName,
|
||||
component: MediaLoaderWidget,
|
||||
inputSpec: inputSpecV2,
|
||||
props: {
|
||||
accept
|
||||
},
|
||||
options: {
|
||||
getValue: () => uploadedFiles.value,
|
||||
setValue: (value: string[]) => {
|
||||
uploadedFiles.value = value
|
||||
},
|
||||
serialize: false,
|
||||
onFilesSelected: async (files: File[]) => {
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and upload files
|
||||
const filteredFiles = files.filter(fileFilter)
|
||||
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
|
||||
if (validPaths.length) {
|
||||
validPaths.forEach((path) =>
|
||||
addToComboValues(fileComboWidget, path)
|
||||
)
|
||||
|
||||
const output = allow_batch ? validPaths : validPaths[0]
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = output
|
||||
|
||||
// Update widget value to show file names
|
||||
uploadedFiles.value = Array.isArray(output) ? output : [output]
|
||||
|
||||
// Trigger the combo widget callback to update all dependent widgets
|
||||
fileComboWidget.callback?.(output)
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
)
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, uploadWidget as any)
|
||||
|
||||
// Store the original callback if it exists
|
||||
const originalCallback = fileComboWidget.callback
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function (value?: any) {
|
||||
// Call original callback first if it exists
|
||||
originalCallback?.call(this, value)
|
||||
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||
if (!isVideo) {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
// On load if we have a value then render the image
|
||||
// The value isnt set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use appropriate preview method
|
||||
if (isVideo) {
|
||||
showPreview({ block: false })
|
||||
} else {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
@@ -41,6 +42,7 @@ export const useImageUploadWidget = () => {
|
||||
const isVideo = !!inputOptions.video_upload
|
||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
@@ -96,6 +98,16 @@ export const useImageUploadWidget = () => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||
if (!isVideo) {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
@@ -106,7 +118,17 @@ export const useImageUploadWidget = () => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
|
||||
// Use appropriate preview method
|
||||
if (isVideo) {
|
||||
showPreview({ block: false })
|
||||
} else {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
type InputSpec,
|
||||
isIntInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidget
|
||||
} from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
@@ -81,15 +77,19 @@ export const useIntWidget = () => {
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const seedControl = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
// TODO: Re-implement control widget functionality without circular dependency
|
||||
console.warn(
|
||||
'Control widget functionality temporarily disabled for int widgets due to circular dependency'
|
||||
)
|
||||
widget.linkedWidgets = [seedControl]
|
||||
// const seedControl = addValueControlWidget(
|
||||
// node,
|
||||
// widget,
|
||||
// 'randomize',
|
||||
// undefined,
|
||||
// undefined,
|
||||
// transformInputSpecV2ToV1(inputSpec)
|
||||
// )
|
||||
// widget.linkedWidgets = [seedControl]
|
||||
}
|
||||
|
||||
return widget
|
||||
|
||||