Compare commits
75 Commits
v1.22.0-su
...
vue-nodes-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e51bc45f9 | ||
|
|
7b25e230c6 | ||
|
|
a8602fce94 | ||
|
|
56f59103a5 | ||
|
|
8129ba2132 | ||
|
|
346cac0889 | ||
|
|
7573bca6a2 | ||
|
|
c25206ad3b | ||
|
|
36e4e79994 | ||
|
|
47145ce4b8 | ||
|
|
6cf77a9814 | ||
|
|
886e4908d4 | ||
|
|
24cbc41832 | ||
|
|
a80a939324 | ||
|
|
8e2d7cabba | ||
|
|
e8dd26ff59 | ||
|
|
3a1bd1829a | ||
|
|
2f9dcd1669 | ||
|
|
e23547dd5a | ||
|
|
f0f40bc39b | ||
|
|
4b32786ef5 | ||
|
|
9942b17388 | ||
|
|
b99214bf5e | ||
|
|
2ef760c599 | ||
|
|
429ab6c365 | ||
|
|
b7693ae9f5 | ||
|
|
ebedf1074d | ||
|
|
0832347f47 | ||
|
|
c745af0f25 | ||
|
|
8c05266b83 | ||
|
|
fa14ec52f4 | ||
|
|
ec9da0b6c5 | ||
|
|
98bb1df436 | ||
|
|
75077fe9ed | ||
|
|
d5ecfb2c99 | ||
|
|
3211875084 | ||
|
|
a6bd04f951 | ||
|
|
5b32d2aad0 | ||
|
|
23ba7e6501 | ||
|
|
1e2b16f14d | ||
|
|
ec27d50333 | ||
|
|
693e156ab2 | ||
|
|
8274df5075 | ||
|
|
55bf36564d | ||
|
|
48ac4a2b36 | ||
|
|
c9c1275e4c | ||
|
|
78ebc54ebe | ||
|
|
88f2cc7847 | ||
|
|
7907e206da | ||
|
|
c4fa3dfe5a | ||
|
|
587d7a19a1 | ||
|
|
9ca705381c | ||
|
|
a937ac59ad | ||
|
|
995979a4e1 | ||
|
|
c02ac95815 | ||
|
|
d01926b043 | ||
|
|
344c6f6244 | ||
|
|
b2918a4cf6 | ||
|
|
6d4eafb07a | ||
|
|
97edaade63 | ||
|
|
83af274339 | ||
|
|
f251af25cc | ||
|
|
e2024c1e79 | ||
|
|
e8236e1a85 | ||
|
|
79a63de70e | ||
|
|
3eee7cde0b | ||
|
|
6bbe46009b | ||
|
|
1ca71caf45 | ||
|
|
65289b1927 | ||
|
|
9e2180dcd8 | ||
|
|
defea56ba5 | ||
|
|
e6bca95a5f | ||
|
|
841e3f743a | ||
|
|
73be826956 | ||
|
|
398dc6d8a6 |
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
|
||||
|
||||
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
|
||||
|
||||
10
.github/workflows/test-ui.yaml
vendored
@@ -46,8 +46,8 @@ jobs:
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache setup
|
||||
uses: actions/cache@v3
|
||||
- name: Save cache
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
@@ -62,9 +62,13 @@ jobs:
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, mobile-chrome]
|
||||
steps:
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
fail-on-cache-miss: true
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
|
||||
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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
22
CLAUDE.md
@@ -4,7 +4,7 @@
|
||||
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
|
||||
- Never add lines to PR descriptions that say "Generated with Claude Code"
|
||||
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
|
||||
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
|
||||
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
|
||||
@@ -12,7 +12,7 @@
|
||||
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
|
||||
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
|
||||
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- The npm script to type check is called "typecheck" NOT "type check"
|
||||
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
|
||||
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
|
||||
@@ -36,3 +36,21 @@
|
||||
- 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
|
||||
* Use `api.apiURL()` for all backend API calls and routes
|
||||
- Actual API endpoints like /prompt, /queue, /view, etc.
|
||||
- Image previews: `api.apiURL('/view?...')`
|
||||
- Any backend-generated content or dynamic routes
|
||||
* Use `api.fileURL()` for static files served from the public folder:
|
||||
- Templates: `api.fileURL('/templates/default.json')`
|
||||
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
|
||||
- Any static assets that exist in the public directory
|
||||
|
||||
62
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
|
||||
|
||||
401
VUE_NODE_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# Vue Node Migration Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan outlines a phased migration from LiteGraph canvas rendering to Vue-based DOM rendering for ComfyUI nodes. Vue-based nodes allows access to component libraries (PrimeVue) and CSS frameworks, which increases iteration speed significantly. The migration preserves LiteGraph as the source of truth for graph logic while leveraging Vue components for rich, accessible node interfaces. Increased development speed will facilitate next gen UI/UX changes: .
|
||||
|
||||
## Goals and Objectives
|
||||
|
||||
### Primary Goals
|
||||
|
||||
- Enable rapid UI iteration with Vue's component model, PrimeVue, and CSS frameworks
|
||||
- Maintain performance for workflows with 100+ nodes
|
||||
- Preserve extension compatibility (90%+ without changes)
|
||||
- Improve developer experience
|
||||
|
||||
### Success Metrics
|
||||
|
||||
- New components implementable in <1 hour (vs. current DOM manipulation)
|
||||
- Performance regression <25% for 100-node workflows
|
||||
- 90% of existing extensions work unmodified
|
||||
- 3x faster UI development iteration
|
||||
- Memory usage within 1.5x of canvas-only approach
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Complete canvas replacement (connections remain canvas-rendered)
|
||||
- Mobile/touch optimization (separate initiative)
|
||||
- Workflow format changes (must remain compatible)
|
||||
- Extension API redesign (compatibility layer only)
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- All core node types render via Vue components
|
||||
- Canvas handles only connections and viewport
|
||||
- Performance benchmarks meet targets
|
||||
- Extension compatibility layer tested with top 20 extensions
|
||||
- Migration guide published for extension developers whose extensions are broken by the migration
|
||||
- Feature flag allows instant rollback
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Current State
|
||||
|
||||
LiteGraph with mixed rendering approaches:
|
||||
|
||||
- **Canvas**: node bodies, connections, grid, selection
|
||||
- **Widgets**: Three types coexist:
|
||||
- Canvas widgets (drawn in 2D context)
|
||||
- DOM widgets (manually positioned HTML elements)
|
||||
- Vue widgets (components positioned via DOM)
|
||||
- **Events**: Canvas handles most interactions, delegates to widgets
|
||||
- **State**: Stored in LiteGraph node/graph objects
|
||||
|
||||
### Target State
|
||||
|
||||
Hybrid rendering with clear separation:
|
||||
|
||||
- **Canvas**: connections, grid, viewport pan/zoom, selection rectangles
|
||||
- **DOM (Vue)**: all node contents and widgets unified as Vue components
|
||||
- **Transform Pane**: single Vue-managed container synchronized with canvas transforms
|
||||
- **State**: LiteGraph remains source of truth, Vue observes changes
|
||||
|
||||
### Hybrid Approach
|
||||
|
||||
During migration, both systems coexist:
|
||||
|
||||
- Feature flag controls Vue rendering per node type
|
||||
- Canvas nodes and Vue nodes can connect normally
|
||||
- Shared event system handles both rendering modes
|
||||
- Progressive migration allows testing at each phase
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Component Architecture
|
||||
|
||||
```
|
||||
GraphCanvas.vue
|
||||
└── TransformPane.vue (synchronized with canvas transforms)
|
||||
└── LGraphNode.vue (v-for visible nodes)
|
||||
├── NodeHeader.vue (title, controls)
|
||||
├── NodeSlots.vue (input/output connection points)
|
||||
│ ├── InputSlot.vue (connection target)
|
||||
│ └── OutputSlot.vue (connection source)
|
||||
├── NodeWidgets.vue (parameter controls)
|
||||
│ └── [Widget components rendered here]
|
||||
│ ├── NumberWidget.vue
|
||||
│ ├── StringWidget.vue
|
||||
│ ├── ComboWidget.vue
|
||||
│ └── [etc...]
|
||||
└── NodeContent.vue (custom content area)
|
||||
```
|
||||
|
||||
Slots = connection points for node edges
|
||||
Widgets = UI controls for node parameters
|
||||
|
||||
### State Management
|
||||
|
||||
- **One-way data flow**: LiteGraph → Vue components (props down, events up)
|
||||
- Widget values flow from LiteGraph to Vue as props
|
||||
- User interactions emit events that update LiteGraph
|
||||
- Updated values flow back to Vue, completing the cycle
|
||||
- **LiteGraph as source of truth**: All node/graph state remains in LiteGraph
|
||||
- **Vue as view layer**: Components observe and reflect LiteGraph state
|
||||
|
||||
### Event System
|
||||
|
||||
- **Canvas events**: Pan, zoom, connection dragging, box selection
|
||||
- **DOM events**: Node interactions, widget inputs, context menus
|
||||
- **Transform sync**: No coordinate mapping needed - transforms handle positioning
|
||||
- **(Future) Event delegation**: Single listener on TransformPane for efficiency
|
||||
- **(Future) Touch handling**: Unified pointer events for mouse/touch consistency
|
||||
|
||||
### Positioning Strategy: CSS Transforms
|
||||
|
||||
For positioning nodes in the DOM, we'll use CSS transforms rather than absolute positioning with top/left. This decision is based on significant performance benefits validated by industry leaders (React Flow, Excalidraw, tldraw, Figma).
|
||||
|
||||
#### Core Implementation
|
||||
|
||||
```vue
|
||||
<!-- TransformPane synchronized with canvas -->
|
||||
<div class="transform-pane" :style="{ transform: `scale(${zoom}) translate(${panX}px, ${panY}px)` }">
|
||||
<!-- Individual nodes use simple translate -->
|
||||
<div v-for="node in visibleNodes"
|
||||
:style="{ transform: `translate(${node.x}px, ${node.y}px)` }">
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Key Optimizations
|
||||
|
||||
- **CSS Containment**: `contain: layout style paint` isolates node rendering
|
||||
- **GPU Acceleration**: `will-change: transform` during interactions only
|
||||
- **Batched Updates**: CSS custom properties for efficient updates
|
||||
|
||||
#### Handling PrimeVue Overlays
|
||||
|
||||
Portal strategy for components with fixed positioning:
|
||||
|
||||
```vue
|
||||
<Teleport to="body" v-if="showOverlay">
|
||||
<Popover :style="{ position: 'fixed', left: `${coords.x}px`, top: `${coords.y}px` }">
|
||||
</Teleport>
|
||||
```
|
||||
|
||||
#### Alternative Approaches
|
||||
|
||||
| Approach | Performance | Complexity | Use Case |
|
||||
|----------|------------|------------|-----------|
|
||||
| **CSS Transforms** | Excellent (GPU) | Medium | ✅ Our choice |
|
||||
| **Absolute Position** | Poor (reflow) | Low | Small node counts |
|
||||
| **Canvas Rendering** | Best | High | Not compatible with Vue |
|
||||
| **SVG** | Good | Medium | Better for connections |
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Widget Migration
|
||||
|
||||
For each widget:
|
||||
|
||||
- Create a new Vue component for the widget, using the API defined here: https://www.notion.so/drip-art/Widget-Componet-APIs-2126d73d365080b0bf30f241c09dd756
|
||||
- If the widget existed before, alias the constructor to the new component (q: why not just replace entirely? any reason to keep the old constructor?)
|
||||
- If the widget is new, create a new constructor for the widget and add to widgets.ts
|
||||
- Implement the existing widget interface in the new component (i.e., create Vue-compatible mappings of the LG widget's props and events)
|
||||
- Avoid components that use things like fixed positioning, teleport, `fill-available`, in the widget component (e.g., PrimeVue's Popover, Tooltip, Select) as they will require a portal strategy to work with transforms
|
||||
|
||||
### Phase 2: Node Migration
|
||||
|
||||
- Create a new Vue component for the node
|
||||
- [maybe later] Create conditional render for LOD, distance cull, and viewport cull
|
||||
- Implement the existing node interface in the new node component (i.e., create Vue-compatible mappings of the LG node's props and events)
|
||||
|
||||
### Phase 3: Transform Pane
|
||||
|
||||
- Create the transform pane
|
||||
- Synchronize the transform pane with the canvas transforms
|
||||
- Use `transform-origin` to position the transform pane in accordance with the canvas
|
||||
- Use `will-change: transform` and verify with DevTools that nodes are on a single layer and not being promoted
|
||||
- NOTE: in future, we need to actively prevent layer promotion (see promotion conditions: https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/platform/graphics/compositing_reasons.h;l=18;drc=4e8e81f6eeb6969973f3ec97132d80339b92d227)
|
||||
|
||||
### Phase 4: Interaction System
|
||||
|
||||
- Map all existing events from previous interface
|
||||
- Map all lifecycle hooks
|
||||
- Add event delegation for transform pane events
|
||||
- Restrict touch events to the transform pane
|
||||
- For any event (except those affecting the entire transform pane) that affects compositing (e.g., moving nodes, resizing nodes), batch in RAF
|
||||
|
||||
### Phase 5: Portals
|
||||
|
||||
- Create a portal component that can be used to render components that use fixed positioning, teleport, `fill-available`, etc.
|
||||
|
||||
### Phase 6: Performance Optimizations
|
||||
|
||||
- Create baseline performance metrics
|
||||
- For each optimization, test and iterate while comparing against baseline:
|
||||
- Implement viewport culling or LOD
|
||||
- It may be less efficient to cull nodes if we are only doing compositing and not actually recalc and reflow. This must be manually verified. In any case, there is still probably a very large threshold to implement viewport culling by, but it may be the case that it's not worth the effort to do.
|
||||
- Implement distance culling and LOD
|
||||
- (optional) Implement virtualization, prefetching, prerendering, preloading, preconnecting if still necessary
|
||||
|
||||
### Phase 7: Extension Migration
|
||||
|
||||
- Review all of the most common touch points from extension space
|
||||
- Determine if any compatibility mappings are still needed, then implement them and add to public API
|
||||
- Gradually test with more and more frontend extensions
|
||||
|
||||
## Performance
|
||||
|
||||
### Benchmarking Strategy
|
||||
|
||||
- Use existing performance wrapper playwright testing strategy created in https://www.notion.so/drip-art/Analyze-Performance-Impact-of-only-using-Vue-widgets-20b6d73d36508080a14cea0b8dce7073?source=copy_link#20d6d73d365080409a8ccc68f501284e
|
||||
- Or, a subset of it
|
||||
|
||||
### Optimization Techniques
|
||||
|
||||
- Use `will-change: transform` and `transform: translateZ(0)` to force GPU acceleration
|
||||
- Use `contain: layout style paint` to tell the browser this element won't affect outside layout
|
||||
- Use `transform-origin` to position the transform pane in accordance with the canvas
|
||||
- ~~Use `transform: translate3d(round(var(--x), 1px), round(var(--y), 1px), 0)` to snap to pixels during non-animated states~~
|
||||
|
||||
### Scaling Targets
|
||||
|
||||
- 256 nodes full LOD
|
||||
- 1000 nodes culled
|
||||
|
||||
### Production Monitoring
|
||||
|
||||
For desktop users who have consented to telemetry:
|
||||
|
||||
- **Mixpanel Events**: Track migration feature adoption and performance metrics
|
||||
- Node rendering time percentiles (p50, p90, p99)
|
||||
- Frame rate during node interactions
|
||||
- Memory usage with different node counts
|
||||
- **Sentry Performance**: Monitor real-world performance regressions
|
||||
- Transaction traces for node operations
|
||||
- Custom performance marks for render phases
|
||||
- Error rates specific to Vue node rendering
|
||||
|
||||
## Extension Compatibility Plan
|
||||
|
||||
### Migration Guide
|
||||
|
||||
Comprehensive migration documentation will be published at https://docs.comfy.org including:
|
||||
|
||||
- Step-by-step migration instructions for common patterns
|
||||
- Code examples for converting canvas widgets to Vue components
|
||||
- API compatibility reference
|
||||
- Performance optimization guidelines
|
||||
|
||||
### Compatibility Layer
|
||||
|
||||
[Placeholder: Supporting both rendering modes]
|
||||
|
||||
### Deprecation Timeline
|
||||
|
||||
[Placeholder: List things that will be deprecated fully]
|
||||
|
||||
### Developer Communication
|
||||
|
||||
- **Email notifications** via existing developer mailing list
|
||||
- **Discord announcements** in dedicated devrel channel
|
||||
- **Automated PRs** to affected repositories (if breaking changes required)
|
||||
|
||||
## Testing
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
#### Component Tests
|
||||
|
||||
- Create component tests for each widget and node
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
- Create integration tests for canvas and transform pane synchronization
|
||||
|
||||
#### Performance Tests
|
||||
|
||||
maybe
|
||||
|
||||
### Migration of Existing Test Suites
|
||||
|
||||
#### Unit Tests
|
||||
|
||||
[Placeholder: strategy for migrating unit tests]
|
||||
|
||||
|
||||
#### Browser Tests
|
||||
|
||||
[Placeholder: strategy for migrating browser tests]
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Technical Risks
|
||||
|
||||
#### Performance Degradation
|
||||
|
||||
- **Risk**: DOM nodes significantly slower than canvas rendering
|
||||
- **Mitigation**: Aggressive viewport culling, CSS containment, GPU acceleration
|
||||
- **Monitoring**: Automated performance benchmarks on each PR
|
||||
|
||||
#### Memory Usage
|
||||
|
||||
- **Risk**: 1000+ DOM nodes consume excessive memory
|
||||
- **Mitigation**: Component pooling, virtualization for large workflows
|
||||
- **Detection**: Memory profiling in browser tests
|
||||
|
||||
#### Extension Breaking Changes
|
||||
|
||||
- **Risk**: Popular extensions stop working
|
||||
- **Mitigation**: Compatibility layer maintaining critical APIs
|
||||
- **Testing**: Top 20 extensions tested before each phase
|
||||
|
||||
#### State Synchronization Bugs
|
||||
|
||||
- **Risk**: LiteGraph and Vue state diverge
|
||||
- **Mitigation**: Strict one-way data flow, comprehensive event testing
|
||||
- **Prevention**: State invariant checks in development mode
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
1. **Feature flag**: `enable_vue_nodes` setting (default: false)
|
||||
2. **Gradual rollout**: Enable for specific node types first
|
||||
3. **Quick revert**: Single flag disables all Vue rendering
|
||||
4. **Data compatibility**: No changes to workflow format ensures backward compatibility
|
||||
|
||||
## Timeline and Milestones
|
||||
|
||||
### Week 1
|
||||
|
||||
[Placeholder: Initial milestones]
|
||||
|
||||
### Week 2
|
||||
|
||||
[Placeholder: Mid-term milestones]
|
||||
|
||||
### Week 3
|
||||
|
||||
[Placeholder: Final milestones]
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Widget Constructor Aliasing
|
||||
|
||||
- Why keep old constructors vs. full replacement? (Phase 1, line 53)
|
||||
- Is this for backwards compatibility with existing extensions?
|
||||
|
||||
### Viewport Culling Efficiency
|
||||
|
||||
- At what node count does viewport culling become beneficial? (Phase 6, line 89)
|
||||
- Does the compositing-only benefit outweigh the Vue mount/unmount cost?
|
||||
|
||||
### Extension Compatibility
|
||||
|
||||
- How much of extension surface area to attempt to cover in compatibility layer?
|
||||
- Which extension APIs are most critical to preserve?
|
||||
|
||||
### Transform Pane Synchronization
|
||||
|
||||
- How to handle canvas zoom/pan events?
|
||||
- Should transform sync be RAF-batched or immediate?
|
||||
|
||||
### Event Delegation
|
||||
|
||||
- Which events stay on canvas vs. move to DOM?
|
||||
- **Option A**: Canvas handles all drag/pan, DOM handles clicks/inputs
|
||||
- **Option B**: DOM handles everything except connection dragging
|
||||
- *Note: This option provides the best UX - users expect DOM elements to be fully interactive while keeping complex connection logic in canvas*
|
||||
- **Option C**: Context-aware delegation based on interaction type
|
||||
|
||||
### LOD (Level of Detail) System
|
||||
|
||||
- What defines each LOD level?
|
||||
- **High**: Full widgets and styling
|
||||
- **Medium**: Simplified widgets, reduced effects?
|
||||
- **Low**: Title and connections only?
|
||||
- Transition triggers: zoom level, node count, or performance metrics?
|
||||
- *Note: Zoom level as primary trigger is most predictable for users, with node count as override for performance protection*
|
||||
|
||||
### Interaction System Migration
|
||||
|
||||
- How to maintain gesture consistency between canvas and DOM nodes?
|
||||
- Multi-select behavior across rendering boundaries?
|
||||
|
||||
## Appendices
|
||||
|
||||
### A. Prototype Learnings
|
||||
|
||||
Detailed findings and discoveries from process of developing Vue widgets and Vue nodes prototype (in vue-node-test branch)
|
||||
|
||||
- Constructing components easy, difficulty is performance and compatibility
|
||||
|
||||
### B. Performance and Browser Rendering
|
||||
|
||||
- https://www.notion.so/drip-art/Analyze-Performance-Impact-of-only-using-Vue-widgets-20b6d73d36508080a14cea0b8dce7073?source=copy_link#20d6d73d365080409a8ccc68f501284e
|
||||
- https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/platform/graphics/compositing_reasons.h;l=18;drc=4e8e81f6eeb6969973f3ec97132d80339b92d227
|
||||
- https://webperf.tips/tip/browser-rendering-pipeline/
|
||||
- https://webperf.tips/tip/layers-and-compositing/
|
||||
- https://webperf.tips/tip/layout-thrashing/
|
||||
|
||||
### C. API Design
|
||||
|
||||
- https://www.notion.so/drip-art/Widget-Componet-APIs-2126d73d365080b0bf30f241c09dd756
|
||||
@@ -762,7 +762,7 @@ export class ComfyPage {
|
||||
y: 625
|
||||
}
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -774,7 +774,7 @@ export class ComfyPage {
|
||||
},
|
||||
button: 'right'
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -1046,6 +1046,8 @@ export class ComfyPage {
|
||||
}
|
||||
}
|
||||
|
||||
export const testComfySnapToGridGridSize = 50
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
@@ -1072,7 +1074,8 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.EnableTooltips': false,
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 98 KiB |
@@ -1,6 +1,12 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { Position } from '@vueuse/core'
|
||||
|
||||
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
testComfySnapToGridGridSize
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
@@ -57,8 +63,10 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
|
||||
})
|
||||
|
||||
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const dragSelectNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
clipNodes: NodeReference[]
|
||||
) => {
|
||||
const clipNode1Pos = await clipNodes[0].getPosition()
|
||||
const clipNode2Pos = await clipNodes[1].getPosition()
|
||||
const offset = 64
|
||||
@@ -74,10 +82,67 @@ test.describe('Node Interaction', () => {
|
||||
}
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Meta')
|
||||
}
|
||||
|
||||
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
|
||||
clipNodes.length
|
||||
)
|
||||
})
|
||||
|
||||
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const getPositions = () =>
|
||||
Promise.all(clipNodes.map((node) => node.getPosition()))
|
||||
const testDirection = async ({
|
||||
direction,
|
||||
expectedPosition
|
||||
}: {
|
||||
direction: string
|
||||
expectedPosition: (originalPosition: Position) => Position
|
||||
}) => {
|
||||
const originalPositions = await getPositions()
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
await comfyPage.executeCommand(
|
||||
`Comfy.Canvas.MoveSelectedNodes.${direction}`
|
||||
)
|
||||
await comfyPage.canvas.press(`Control+Arrow${direction}`)
|
||||
const newPositions = await getPositions()
|
||||
expect(newPositions).toEqual(originalPositions.map(expectedPosition))
|
||||
}
|
||||
await testDirection({
|
||||
direction: 'Down',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
y: originalPosition.y + testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Right',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
x: originalPosition.x + testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Up',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
y: originalPosition.y - testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Left',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
x: originalPosition.x - testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 98 KiB |
|
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 |
1856
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.22.0-sub.3",
|
||||
"version": "1.23.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,11 @@
|
||||
},
|
||||
"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",
|
||||
@@ -75,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.16.0-sub.9",
|
||||
"@comfyorg/litegraph": "^0.15.15",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<div
|
||||
v-if="workflowStore.isSubgraphActive"
|
||||
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
|
||||
>
|
||||
<Breadcrumb
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
@@ -11,30 +14,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
|
||||
const items = computed(() => {
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
if (!workflowStore.subgraphNamePath.length) return []
|
||||
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
|
||||
label: name,
|
||||
command: async () => {
|
||||
const workflow = workflowStore.getWorkflowByPath(name)
|
||||
if (workflow) await workflowService.openWorkflow(workflow)
|
||||
}
|
||||
}))
|
||||
})
|
||||
@@ -42,7 +43,7 @@ const items = computed(() => {
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
command: () => {
|
||||
command: async () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -54,17 +55,14 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<template>
|
||||
<hr
|
||||
<div
|
||||
:class="{
|
||||
'm-0': true,
|
||||
'border-t': orientation === 'horizontal',
|
||||
'border-l': orientation === 'vertical',
|
||||
'h-full': orientation === 'vertical',
|
||||
'w-full': orientation === 'horizontal'
|
||||
'content-divider': true,
|
||||
'content-divider--horizontal': orientation === 'horizontal',
|
||||
'content-divider--vertical': orientation === 'vertical'
|
||||
}"
|
||||
:style="{
|
||||
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
|
||||
borderWidth: `${width}px !important`
|
||||
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -29,3 +26,25 @@ const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-divider {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-divider--horizontal {
|
||||
width: 100%;
|
||||
height: v-bind('width + "px"');
|
||||
}
|
||||
|
||||
.content-divider--vertical {
|
||||
height: 100%;
|
||||
width: v-bind('width + "px"');
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,12 +92,21 @@ whenever(
|
||||
const updateItemSize = () => {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
|
||||
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
|
||||
|
||||
// Don't update item size if the first item is not rendered yet
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
itemHeight.value = firstItem.clientHeight
|
||||
}
|
||||
if (itemWidth.value !== firstItem.clientWidth) {
|
||||
itemWidth.value = firstItem.clientWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -66,4 +50,17 @@ onMounted(() => {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
title="Missing Node Types"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
option-label="label"
|
||||
@@ -31,6 +32,12 @@
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<PackInstallButton
|
||||
:disabled="isLoading || !!error || missingNodePacks.length === 0"
|
||||
:node-packs="missingNodePacks"
|
||||
variant="black"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +48,9 @@ import ListBox from 'primevue/listbox'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
@@ -52,6 +62,10 @@ const props = defineProps<{
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error, missingCoreNodes } =
|
||||
useMissingNodes()
|
||||
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
|
||||
95
src/components/dialog/content/MissingCoreNodesMessage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<Message
|
||||
v-if="hasMissingCoreNodes"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
class="my-2 mx-2"
|
||||
:pt="{
|
||||
root: { class: 'flex-col' },
|
||||
text: { class: 'flex-1' }
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
{{
|
||||
currentComfyUIVersion
|
||||
? $t('loadWorkflowWarning.outdatedVersion', {
|
||||
version: currentComfyUIVersion
|
||||
})
|
||||
: $t('loadWorkflowWarning.outdatedVersionGeneric')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-for="[version, nodes] in sortedMissingCoreNodes"
|
||||
:key="version"
|
||||
class="ml-4"
|
||||
>
|
||||
<div
|
||||
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
|
||||
>
|
||||
{{
|
||||
$t('loadWorkflowWarning.coreNodesFromVersion', {
|
||||
version: version || 'unknown'
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
|
||||
{{ getUniqueNodeNames(nodes).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Message from 'primevue/message'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
}>()
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(props.missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
const currentComfyUIVersion = ref<string | null>(null)
|
||||
whenever(
|
||||
hasMissingCoreNodes,
|
||||
async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
currentComfyUIVersion.value =
|
||||
systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compareVersions(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
|
||||
return nodes
|
||||
.reduce<string[]>((acc, node) => {
|
||||
if (node.type && !acc.includes(node.type)) {
|
||||
acc.push(node.type)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort()
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
|
||||
class="h-full flex flex-col mx-auto overflow-hidden"
|
||||
:aria-label="$t('manager.title')"
|
||||
>
|
||||
<ContentDivider :width="0.3" />
|
||||
<Button
|
||||
v-if="isSmallScreen"
|
||||
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
||||
text
|
||||
severity="secondary"
|
||||
filled
|
||||
class="absolute top-1/2 -translate-y-1/2 z-10"
|
||||
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
|
||||
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
|
||||
@click="toggleSideNav"
|
||||
/>
|
||||
<div class="flex flex-1 relative overflow-hidden">
|
||||
@@ -18,20 +20,20 @@
|
||||
:tabs="tabs"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto pr-80"
|
||||
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
|
||||
:class="{
|
||||
'transition-all duration-300': isSmallScreen,
|
||||
'pl-80': isSideNavOpen || !isSmallScreen,
|
||||
'pl-8': !isSideNavOpen && isSmallScreen
|
||||
'transition-all duration-300': isSmallScreen
|
||||
}"
|
||||
>
|
||||
<div class="px-6 pt-6 flex flex-col h-full">
|
||||
<div class="px-6 flex flex-col h-full">
|
||||
<RegistrySearchBar
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
v-model:sortField="sortField"
|
||||
:search-results="searchResults"
|
||||
:suggestions="suggestions"
|
||||
:is-missing-tab="isMissingTab"
|
||||
:sort-options="sortOptions"
|
||||
/>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
@@ -57,7 +59,7 @@
|
||||
<VirtualGrid
|
||||
id="results-grid"
|
||||
:items="resultsWithKeys"
|
||||
:buffer-rows="3"
|
||||
:buffer-rows="4"
|
||||
:grid-style="GRID_STYLE"
|
||||
@approach-end="onApproachEnd"
|
||||
>
|
||||
@@ -75,9 +77,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
|
||||
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
|
||||
<ContentDivider orientation="vertical" :width="0.2" />
|
||||
<div class="flex-1 flex flex-col isolate">
|
||||
<div class="w-full flex flex-col isolate">
|
||||
<InfoPanel
|
||||
v-if="!hasMultipleSelections && selectedNodePack"
|
||||
:node-pack="selectedNodePack"
|
||||
@@ -93,7 +95,14 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { merge } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -106,6 +115,7 @@ import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
|
||||
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
|
||||
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
|
||||
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
@@ -116,13 +126,15 @@ import type { TabItem } from '@/types/comfyManagerTypes'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { initialTab = ManagerTab.All } = defineProps<{
|
||||
initialTab: ManagerTab
|
||||
const { initialTab } = defineProps<{
|
||||
initialTab?: ManagerTab
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { getPackById } = useComfyRegistryStore()
|
||||
const persistedState = useManagerStatePersistence()
|
||||
const initialState = persistedState.loadStoredState()
|
||||
|
||||
const GRID_STYLE = {
|
||||
display: 'grid',
|
||||
@@ -156,8 +168,10 @@ const tabs = ref<TabItem[]>([
|
||||
icon: 'pi-sync'
|
||||
}
|
||||
])
|
||||
|
||||
const initialTabId = initialTab ?? initialState.selectedTabId
|
||||
const selectedTab = ref<TabItem>(
|
||||
tabs.value.find((tab) => tab.id === initialTab) || tabs.value[0]
|
||||
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -167,8 +181,13 @@ const {
|
||||
searchResults,
|
||||
searchMode,
|
||||
sortField,
|
||||
suggestions
|
||||
} = useRegistrySearch()
|
||||
suggestions,
|
||||
sortOptions
|
||||
} = useRegistrySearch({
|
||||
initialSortField: initialState.sortField,
|
||||
initialSearchMode: initialState.searchMode,
|
||||
initialSearchQuery: initialState.searchQuery
|
||||
})
|
||||
pageNumber.value = 0
|
||||
const onApproachEnd = () => {
|
||||
pageNumber.value++
|
||||
@@ -200,10 +219,6 @@ const {
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
whenever(selectedTab, () => {
|
||||
pageNumber.value = 0
|
||||
})
|
||||
|
||||
const isUpdateAvailableTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
|
||||
)
|
||||
@@ -232,7 +247,11 @@ watch(
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
} else if (!installedPacks.value.length) {
|
||||
} else if (
|
||||
!installedPacks.value.length &&
|
||||
!installedPacksReady.value &&
|
||||
!isLoadingInstalled.value
|
||||
) {
|
||||
await startFetchInstalled()
|
||||
} else {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
@@ -408,19 +427,36 @@ 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)
|
||||
// Update the pack in current selection without changing selection state
|
||||
const packIndex = selectedNodePacks.value.findIndex(
|
||||
(p) => p.id === mergedPack.id
|
||||
)
|
||||
if (packIndex !== -1) {
|
||||
selectedNodePacks.value.splice(packIndex, 1, 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -428,13 +464,23 @@ let gridContainer: HTMLElement | null = null
|
||||
onMounted(() => {
|
||||
gridContainer = document.getElementById('results-grid')
|
||||
})
|
||||
watch(searchQuery, () => {
|
||||
watch([searchQuery, selectedTab], () => {
|
||||
gridContainer ??= document.getElementById('results-grid')
|
||||
if (gridContainer) {
|
||||
pageNumber.value = 0
|
||||
gridContainer.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
persistedState.persistState({
|
||||
selectedTabId: selectedTab.value?.id,
|
||||
searchQuery: searchQuery.value,
|
||||
searchMode: searchMode.value,
|
||||
sortField: sortField.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
getPackById.cancel()
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-normal text-left">
|
||||
{{ $t('manager.discoverCommunityContent') }}
|
||||
</h2>
|
||||
</div>
|
||||
<ContentDivider :width="0.3" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<aside
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
|
||||
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ScrollPanel class="w-80 mt-7">
|
||||
<ScrollPanel class="flex-1">
|
||||
<Listbox
|
||||
v-model="selectedTab"
|
||||
:options="tabs"
|
||||
@@ -10,20 +10,20 @@
|
||||
list-style="max-height:unset"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-5' },
|
||||
option: { class: 'px-8 py-3 text-lg rounded-xl' },
|
||||
list: { class: 'p-3 gap-2' },
|
||||
option: { class: 'px-4 py-2 text-lg rounded-lg' },
|
||||
optionGroup: { class: 'p-0 text-left text-inherit' }
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="text-left flex items-center">
|
||||
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
|
||||
<span class="text-lg">{{ slotProps.option.label }}</span>
|
||||
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
|
||||
<span class="text-sm">{{ slotProps.option.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<ContentDivider orientation="vertical" />
|
||||
<ContentDivider orientation="vertical" :width="0.3" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('PackVersionBadge', () => {
|
||||
return mount(PackVersionBadge, {
|
||||
props: {
|
||||
nodePack: mockNodePack,
|
||||
isSelected: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
@@ -162,4 +163,58 @@ describe('PackVersionBadge', () => {
|
||||
// Verify that the hide method was called
|
||||
expect(mockHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('selection state changes', () => {
|
||||
it('closes the popover when card is deselected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: true }
|
||||
})
|
||||
|
||||
// Change isSelected from true to false
|
||||
await wrapper.setProps({ isSelected: false })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was called
|
||||
expect(mockHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close the popover when card is selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: false }
|
||||
})
|
||||
|
||||
// Change isSelected from false to true
|
||||
await wrapper.setProps({ isSelected: true })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was NOT called
|
||||
expect(mockHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close the popover when isSelected remains false', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: false }
|
||||
})
|
||||
|
||||
// Change isSelected from false to false (no change)
|
||||
await wrapper.setProps({ isSelected: false })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was NOT called
|
||||
expect(mockHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close the popover when isSelected remains true', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: true }
|
||||
})
|
||||
|
||||
// Change isSelected from true to true (no change)
|
||||
await wrapper.setProps({ isSelected: true })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was NOT called
|
||||
expect(mockHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
@@ -43,8 +43,9 @@ import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
const { nodePack, isSelected } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
isSelected: boolean
|
||||
}>()
|
||||
|
||||
const popoverRef = ref()
|
||||
@@ -69,4 +70,14 @@ const toggleVersionSelector = (event: Event) => {
|
||||
const closeVersionSelector = () => {
|
||||
popoverRef.value.hide()
|
||||
}
|
||||
|
||||
// If the card is unselected, automatically close the version selector popover
|
||||
watch(
|
||||
() => isSelected,
|
||||
(isSelected, wasSelected) => {
|
||||
if (wasSelected && !isSelected) {
|
||||
closeVersionSelector()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -191,6 +191,100 @@ describe('PackVersionSelectorPopover', () => {
|
||||
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
|
||||
})
|
||||
|
||||
describe('nodePack.id changes', () => {
|
||||
it('re-fetches versions when nodePack.id changes', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Verify initial fetch
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
|
||||
|
||||
// Set up the mock for the second fetch
|
||||
const newVersions = [
|
||||
{ version: '2.0.0', createdAt: '2023-06-01' },
|
||||
{ version: '1.9.0', createdAt: '2023-05-01' }
|
||||
]
|
||||
mockGetPackVersions.mockResolvedValueOnce(newVersions)
|
||||
|
||||
// Update the nodePack with a new ID
|
||||
const newNodePack = {
|
||||
...mockNodePack,
|
||||
id: 'different-pack',
|
||||
name: 'Different Pack'
|
||||
}
|
||||
await wrapper.setProps({ nodePack: newNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Should fetch versions for the new nodePack
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
|
||||
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
|
||||
|
||||
// Check that new versions are displayed
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
const options = listbox.props('options')!
|
||||
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
|
||||
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-fetch when nodePack changes but id remains the same', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Verify initial fetch
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Update the nodePack with same ID but different properties
|
||||
const updatedNodePack = {
|
||||
...mockNodePack,
|
||||
name: 'Updated Test Pack',
|
||||
description: 'New description'
|
||||
}
|
||||
await wrapper.setProps({ nodePack: updatedNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Should NOT fetch versions again
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('maintains selected version when switching to a new pack', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Select a specific version
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
await listbox.setValue('0.9.0')
|
||||
expect(listbox.props('modelValue')).toBe('0.9.0')
|
||||
|
||||
// Set up the mock for the second fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce([
|
||||
{ version: '3.0.0', createdAt: '2023-07-01' },
|
||||
{ version: '0.9.0', createdAt: '2023-04-01' }
|
||||
])
|
||||
|
||||
// Update to a new pack that also has version 0.9.0
|
||||
const newNodePack = {
|
||||
id: 'another-pack',
|
||||
name: 'Another Pack',
|
||||
latest_version: { version: '3.0.0' }
|
||||
}
|
||||
await wrapper.setProps({ nodePack: newNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Selected version should remain the same if available
|
||||
expect(listbox.props('modelValue')).toBe('0.9.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unclaimed GitHub packs handling', () => {
|
||||
it('falls back to nightly when no versions exist', async () => {
|
||||
// Set up the mock to return versions
|
||||
|
||||
@@ -62,7 +62,7 @@ import { whenever } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -161,9 +161,11 @@ const onNodePackChange = async () => {
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => nodePack,
|
||||
() => {
|
||||
void onNodePackChange()
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
void onNodePackChange()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
@@ -182,8 +184,4 @@ const handleSubmit = async () => {
|
||||
isQueueing.value = false
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
managerStore.installPack.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="m-0 p-0 rounded-lg border-neutral-700"
|
||||
:class="{
|
||||
'w-full': fullWidth,
|
||||
'w-min-content': !fullWidth
|
||||
}"
|
||||
class="!m-0 p-0 rounded-lg"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'border-neutral-700',
|
||||
fullWidth ? 'w-full' : 'w-min-content'
|
||||
]"
|
||||
:disabled="loading"
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2.5 px-3">
|
||||
<span class="py-2.5 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
@@ -27,12 +29,14 @@ import Button from 'primevue/button'
|
||||
const {
|
||||
label,
|
||||
loadingMessage,
|
||||
fullWidth = false
|
||||
fullWidth = false,
|
||||
variant = 'default'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
fullWidth?: boolean
|
||||
variant?: 'default' | 'black'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<PackActionButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
|
||||
label ??
|
||||
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
|
||||
"
|
||||
severity="secondary"
|
||||
:severity="variant === 'black' ? undefined : 'secondary'"
|
||||
:variant="variant"
|
||||
:loading="isInstalling"
|
||||
:loading-message="$t('g.installing')"
|
||||
@action="installAllPacks"
|
||||
@@ -27,8 +29,10 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
const { nodePacks, variant, label } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
variant?: 'default' | 'black'
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const isInstalling = inject(IsInstallingKey, ref(false))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<template v-if="nodePack">
|
||||
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
|
||||
<div class="flex flex-col h-full z-40 overflow-hidden relative">
|
||||
<div class="top-0 z-10 px-6 pt-6 w-full">
|
||||
<InfoPanelHeader :node-packs="[nodePack]" />
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
|
||||
</MetadataRow>
|
||||
</div>
|
||||
<div class="mb-6 overflow-hidden">
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
|
||||
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,7 +118,15 @@ const onNodePackChange = () => {
|
||||
y.value = 0
|
||||
}
|
||||
|
||||
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
|
||||
whenever(
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
onNodePackChange()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.hidden-scrollbar {
|
||||
|
||||
@@ -51,7 +51,11 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
|
||||
if (!pack.latest_version?.version) return []
|
||||
const nodeDefs = await getNodeDefs.call({
|
||||
packId: pack.id,
|
||||
version: pack.latest_version?.version
|
||||
version: pack.latest_version?.version,
|
||||
// Fetch all nodes.
|
||||
// TODO: Render all nodes previews and handle pagination.
|
||||
// For determining length, use the `totalNumberOfPages` field of response
|
||||
limit: 8192
|
||||
})
|
||||
return nodeDefs?.comfy_nodes ?? []
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const isLoading = ref(false)
|
||||
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
|
||||
|
||||
const fetchNodeDefs = async () => {
|
||||
getNodeDefs.cancel()
|
||||
isLoading.value = true
|
||||
|
||||
const { id: packId } = nodePack
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="w-full h-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
alt="default banner"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner_url or icon show -->
|
||||
<div v-else class="relative w-full h-full">
|
||||
<!-- blur background -->
|
||||
<div
|
||||
v-if="imgSrc"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{
|
||||
backgroundImage: `url(${imgSrc})`,
|
||||
filter: 'blur(10px)'
|
||||
}"
|
||||
></div>
|
||||
<!-- image -->
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative w-full h-full object-cover z-10'
|
||||
: 'relative w-full h-full object-contain z-10'
|
||||
"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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']
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
|
||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
</script>
|
||||
@@ -1,25 +1,21 @@
|
||||
<template>
|
||||
<Card
|
||||
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-2xl shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
|
||||
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-lg shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
|
||||
:class="{
|
||||
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
|
||||
'selected-card': isSelected,
|
||||
'opacity-60': isDisabled
|
||||
}"
|
||||
: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'
|
||||
},
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-lg 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,66 @@
|
||||
</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"
|
||||
:is-selected="isSelected"
|
||||
/>
|
||||
</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,21 +108,28 @@ 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'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import {
|
||||
IsInstallingKey,
|
||||
type MergedNodePack,
|
||||
type RegistryPack,
|
||||
isMergedNodePack
|
||||
} from '@/types/comfyManagerTypes'
|
||||
|
||||
const { nodePack, isSelected = false } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
nodePack: MergedNodePack | RegistryPack
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
@@ -120,6 +143,40 @@ const isDisabled = computed(
|
||||
|
||||
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 nodesCount = computed(() =>
|
||||
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
|
||||
)
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.selected-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 3px solid var(--p-primary-color);
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
<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 v-if="!isInstalled" :node-packs="[nodePack]" />
|
||||
<PackEnableToggle v-else :node-pack="nodePack" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
const { n } = useI18n()
|
||||
|
||||
const formattedDownloads = computed(() =>
|
||||
nodePack.downloads ? n(nodePack.downloads) : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
<template>
|
||||
<div class="relative w-full p-6">
|
||||
<div class="flex items-center w-full">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-5/12 rounded-2xl'
|
||||
<div class="h-12 flex items-center gap-1 justify-between">
|
||||
<div class="flex items-center w-5/12">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-full rounded-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
</div>
|
||||
<PackInstallButton
|
||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||
variant="black"
|
||||
:disabled="isLoading || !!error"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
@@ -34,7 +43,7 @@
|
||||
/>
|
||||
<SearchFilterDropdown
|
||||
v-model:modelValue="sortField"
|
||||
:options="sortOptions"
|
||||
:options="availableSortOptions"
|
||||
:label="$t('g.sort')"
|
||||
/>
|
||||
</div>
|
||||
@@ -55,43 +64,55 @@ import AutoComplete, {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
|
||||
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import {
|
||||
type SearchOption,
|
||||
SortableAlgoliaField
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type {
|
||||
QuerySuggestion,
|
||||
SearchMode,
|
||||
SortableField
|
||||
} from '@/types/searchServiceTypes'
|
||||
|
||||
const { searchResults } = defineProps<{
|
||||
const { searchResults, sortOptions } = defineProps<{
|
||||
searchResults?: components['schemas']['Node'][]
|
||||
suggestions?: NodesIndexSuggestion[]
|
||||
suggestions?: QuerySuggestion[]
|
||||
sortOptions?: SortableField[]
|
||||
isMissingTab?: boolean
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
|
||||
const sortField = defineModel<SortableAlgoliaField>('sortField', {
|
||||
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
|
||||
const sortField = defineModel<string>('sortField', {
|
||||
default: SortableAlgoliaField.Downloads
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
const hasResults = computed(
|
||||
() => searchQuery.value?.trim() && searchResults?.length
|
||||
)
|
||||
|
||||
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
|
||||
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
|
||||
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
|
||||
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
|
||||
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
|
||||
{ id: SortableAlgoliaField.Name, label: t('g.name') }
|
||||
]
|
||||
const filterOptions: SearchOption<string>[] = [
|
||||
const availableSortOptions = computed<SearchOption<string>[]>(() => {
|
||||
if (!sortOptions) return []
|
||||
return sortOptions.map((field) => ({
|
||||
id: field.id,
|
||||
label: field.label
|
||||
}))
|
||||
})
|
||||
const filterOptions: SearchOption<SearchMode>[] = [
|
||||
{ id: 'packs', label: t('manager.filter.nodePack') },
|
||||
{ id: 'nodes', label: t('g.nodes') }
|
||||
]
|
||||
|
||||
// When a dropdown query suggestion is selected, update the search query
|
||||
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
|
||||
searchQuery.value = event.value.query
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,14 +21,16 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
const widgetStates = computed(() =>
|
||||
Array.from(domWidgetStore.widgetStates.values())
|
||||
)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
for (const widgetState of widgetStates.value) {
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
|
||||
@@ -12,12 +12,10 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
@@ -41,11 +39,12 @@
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
@@ -85,7 +84,6 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -194,10 +192,10 @@ watch(
|
||||
// Update the progress of the executing node
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
executionStore.executingNodeId,
|
||||
executionStore.executingNodeProgress
|
||||
] satisfies [NodeId | null, number | null],
|
||||
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
|
||||
NodeId | null,
|
||||
number | null
|
||||
],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
@@ -336,16 +334,6 @@ onMounted(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshButton />
|
||||
<ExtensionCommandButton
|
||||
@@ -29,7 +28,6 @@ import { computed } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-box"
|
||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
canvasStore.nodeSelected
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isDeletable"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
@@ -14,17 +13,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isDeletable = computed(() =>
|
||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -25,9 +25,8 @@ const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isSingleImageNode = computed(() => {
|
||||
const { selectedItems } = canvasStore
|
||||
const item = selectedItems[0]
|
||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
||||
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
|
||||
return nodes.length === 1 && nodes.some(isImageNode)
|
||||
})
|
||||
|
||||
const openMaskEditor = () => {
|
||||
|
||||
68
src/components/graph/vueNodes/InputSlot.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--input flex items-center gap-2 py-1 pl-2 pr-4 cursor-crosshair hover:bg-black/5"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
'lg-slot--compatible': compatible
|
||||
}"
|
||||
@pointerdown="handleClick"
|
||||
>
|
||||
<!-- Connection Dot -->
|
||||
<div
|
||||
class="lg-slot__dot w-3 h-3 rounded-full border-2"
|
||||
:style="{
|
||||
backgroundColor: connected ? slotColor : 'transparent',
|
||||
borderColor: slotColor
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<span class="text-xs text-surface-700 whitespace-nowrap">
|
||||
{{ slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
|
||||
interface InputSlotProps {
|
||||
node: LGraphNode
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<InputSlotProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [event: PointerEvent]
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue input slot error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Handle click events
|
||||
const handleClick = (event: PointerEvent) => {
|
||||
if (!props.readonly) {
|
||||
emit('slot-click', event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
168
src/components/graph/vueNodes/LGraphNode.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Render Error
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'lg-node absolute border-2 rounded bg-surface-0',
|
||||
'contain-layout contain-style contain-paint',
|
||||
selected
|
||||
? 'border-primary-500 ring-2 ring-primary-300'
|
||||
: 'border-surface-300',
|
||||
executing ? 'animate-pulse' : '',
|
||||
node.mode === 4 ? 'opacity-50' : '', // bypassed
|
||||
error ? 'border-red-500 bg-red-50' : '',
|
||||
isDragging ? 'will-change-transform' : ''
|
||||
]"
|
||||
:style="{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
width: node.size ? `${node.size[0]}px` : 'auto',
|
||||
minWidth: '200px'
|
||||
}"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<!-- Header only updates on title/color changes -->
|
||||
<NodeHeader
|
||||
v-memo="[node.title, node.color]"
|
||||
:node="node"
|
||||
:readonly="readonly"
|
||||
@collapse="handleCollapse"
|
||||
/>
|
||||
|
||||
<!-- Node Body (only visible when not collapsed) -->
|
||||
<div v-if="!node.flags?.collapsed" class="flex flex-col gap-2 p-2">
|
||||
<!-- Slots only update when connections change -->
|
||||
<NodeSlots
|
||||
v-memo="[node.inputs?.length, node.outputs?.length]"
|
||||
:node="node"
|
||||
:readonly="readonly"
|
||||
@slot-click="handleSlotClick"
|
||||
/>
|
||||
|
||||
<!-- Widgets update on value changes -->
|
||||
<NodeWidgets
|
||||
v-if="node.widgets?.length"
|
||||
v-memo="[
|
||||
node.widgets?.length,
|
||||
...(node.widgets?.map((w) => w.value) ?? [])
|
||||
]"
|
||||
:node="node"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<!-- Custom content area -->
|
||||
<NodeContent v-if="hasCustomContent" :node="node" :readonly="readonly" />
|
||||
</div>
|
||||
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
|
||||
:style="{ width: `${progress * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, reactive, ref, watch } from 'vue'
|
||||
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
|
||||
// Extended props for main node component
|
||||
interface LGraphNodeProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
selected?: boolean
|
||||
executing?: boolean
|
||||
progress?: number
|
||||
error?: string | null
|
||||
zoomLevel?: number
|
||||
}
|
||||
|
||||
const props = defineProps<LGraphNodeProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'node-click': [event: PointerEvent, node: LGraphNode]
|
||||
'slot-click': [
|
||||
event: PointerEvent,
|
||||
node: LGraphNode,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
]
|
||||
collapse: []
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node component error:', error)
|
||||
return false // Prevent error propagation
|
||||
})
|
||||
|
||||
// Position state - initialized from node.pos but then controlled via transforms
|
||||
const position = reactive({
|
||||
x: props.node.pos[0],
|
||||
y: props.node.pos[1]
|
||||
})
|
||||
|
||||
// Track dragging state for will-change optimization
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Only update position when node.pos changes AND we're not dragging
|
||||
// This prevents reflows during drag operations
|
||||
watch(
|
||||
() => props.node.pos,
|
||||
(newPos) => {
|
||||
if (!isDragging.value) {
|
||||
position.x = newPos[0]
|
||||
position.y = newPos[1]
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Check if node has custom content
|
||||
const hasCustomContent = computed(() => {
|
||||
// Currently all content is handled through widgets
|
||||
// This remains false but provides extensibility point
|
||||
return false
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
emit('node-click', event, props.node)
|
||||
// The parent component will handle setting isDragging when appropriate
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
// Parent component should handle node mutations
|
||||
// This is just emitting the event upwards
|
||||
emit('collapse')
|
||||
}
|
||||
|
||||
const handleSlotClick = (
|
||||
event: PointerEvent,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) => {
|
||||
emit('slot-click', event, props.node, slotIndex, isInput)
|
||||
}
|
||||
|
||||
// Expose methods for parent to control position during drag
|
||||
defineExpose({
|
||||
setPosition(x: number, y: number) {
|
||||
position.x = x
|
||||
position.y = y
|
||||
},
|
||||
setDragging(dragging: boolean) {
|
||||
isDragging.value = dragging
|
||||
}
|
||||
})
|
||||
</script>
|
||||
33
src/components/graph/vueNodes/NodeContent.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Content Error
|
||||
</div>
|
||||
<div v-else class="lg-node-content">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<!-- This component serves as a placeholder for future extensibility -->
|
||||
<!-- Currently all node content is rendered through the widget system -->
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
|
||||
interface NodeContentProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
defineProps<NodeContentProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node content error:', error)
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
107
src/components/graph/vueNodes/NodeHeader.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Header Error
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header flex items-center justify-between px-3 py-2 rounded-t cursor-move"
|
||||
:style="{
|
||||
backgroundColor: headerColor,
|
||||
color: textColor
|
||||
}"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<!-- Node Title -->
|
||||
<span class="text-sm font-medium truncate flex-1">
|
||||
{{ node.title || node.constructor.title || 'Untitled' }}
|
||||
</span>
|
||||
|
||||
<!-- Node Controls -->
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<button
|
||||
v-if="!readonly"
|
||||
class="lg-node-header__control p-0.5 rounded hover:bg-black/10 transition-colors"
|
||||
:title="node.flags?.collapsed ? 'Expand' : 'Collapse'"
|
||||
@click.stop="handleCollapse"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 transition-transform"
|
||||
:class="{ 'rotate-180': node.flags?.collapsed }"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Additional controls can be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
'title-edit': []
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node header error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Compute header color based on node color property or type
|
||||
const headerColor = computed(() => {
|
||||
if (props.node.color) {
|
||||
return props.node.color
|
||||
}
|
||||
// Default color based on node mode
|
||||
if (props.node.mode === 4) return '#666' // Bypassed
|
||||
if (props.node.mode === 2) return '#444' // Muted
|
||||
return '#353535' // Default
|
||||
})
|
||||
|
||||
// Compute text color for contrast
|
||||
const textColor = computed(() => {
|
||||
// Simple contrast calculation - could be improved
|
||||
const color = headerColor.value
|
||||
if (!color || color === '#353535' || color === '#444' || color === '#666') {
|
||||
return '#fff'
|
||||
}
|
||||
// For custom colors, use a simple heuristic
|
||||
const rgb = parseInt(color.slice(1), 16)
|
||||
const r = (rgb >> 16) & 255
|
||||
const g = (rgb >> 8) & 255
|
||||
const b = rgb & 255
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||
return brightness > 128 ? '#000' : '#fff'
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
emit('collapse')
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (!props.readonly) {
|
||||
emit('title-edit')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
89
src/components/graph/vueNodes/NodeSlots.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Slots Error
|
||||
</div>
|
||||
<div v-else class="lg-node-slots relative">
|
||||
<!-- Input Slots -->
|
||||
<div
|
||||
v-if="node.inputs?.length"
|
||||
class="lg-node-slots__inputs absolute left-0 top-0 flex flex-col"
|
||||
>
|
||||
<InputSlot
|
||||
v-for="(input, index) in node.inputs"
|
||||
:key="`input-${index}`"
|
||||
:node="node"
|
||||
:slot-data="input"
|
||||
:index="index"
|
||||
:connected="isInputConnected(index)"
|
||||
:compatible="false"
|
||||
:readonly="readonly"
|
||||
@slot-click="(e) => handleSlotClick(e, index, true)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Output Slots -->
|
||||
<div
|
||||
v-if="node.outputs?.length"
|
||||
class="lg-node-slots__outputs absolute right-0 top-0 flex flex-col"
|
||||
>
|
||||
<OutputSlot
|
||||
v-for="(output, index) in node.outputs"
|
||||
:key="`output-${index}`"
|
||||
:node="node"
|
||||
:slot-data="output"
|
||||
:index="index"
|
||||
:connected="isOutputConnected(index)"
|
||||
:compatible="false"
|
||||
:readonly="readonly"
|
||||
@slot-click="(e) => handleSlotClick(e, index, false)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
interface NodeSlotsProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeSlotsProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [event: PointerEvent, slotIndex: number, isInput: boolean]
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node slots error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Check if input slot has a connection
|
||||
const isInputConnected = (index: number) => {
|
||||
return props.node.inputs?.[index]?.link != null
|
||||
}
|
||||
|
||||
// Check if output slot has any connections
|
||||
const isOutputConnected = (index: number) => {
|
||||
return (props.node.outputs?.[index]?.links?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
// Handle slot click events
|
||||
const handleSlotClick = (
|
||||
event: PointerEvent,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) => {
|
||||
emit('slot-click', event, slotIndex, isInput)
|
||||
}
|
||||
</script>
|
||||
97
src/components/graph/vueNodes/NodeWidgets.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Widgets Error
|
||||
</div>
|
||||
<div v-else class="lg-node-widgets flex flex-col gap-2">
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
v-for="(widget, index) in widgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
v-model="widget.value"
|
||||
:widget="simplifiedWidget(widget)"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(value: any) => handleWidgetUpdate(widget, value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import {
|
||||
WidgetType,
|
||||
getWidgetComponent as getWidgetComponentFromRegistry
|
||||
} from '@/components/graph/vueWidgets/widgetRegistry'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeWidgetsProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node widgets error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get non-hidden widgets
|
||||
const widgets = computed(() => {
|
||||
return props.node.widgets?.filter((w) => !w.options?.hidden) || []
|
||||
})
|
||||
|
||||
// Map widget type to our widget registry
|
||||
const getWidgetComponent = (widget: IBaseWidget) => {
|
||||
// Map LiteGraph widget types to our WidgetType enum
|
||||
const typeMapping: Record<string, WidgetType> = {
|
||||
number: WidgetType.SLIDER,
|
||||
float: WidgetType.SLIDER,
|
||||
int: WidgetType.SLIDER,
|
||||
string: WidgetType.STRING,
|
||||
text: WidgetType.TEXTAREA,
|
||||
combo: WidgetType.COMBO,
|
||||
toggle: WidgetType.BOOLEAN,
|
||||
boolean: WidgetType.BOOLEAN,
|
||||
button: WidgetType.BUTTON,
|
||||
color: WidgetType.COLOR,
|
||||
image: WidgetType.IMAGE,
|
||||
file: WidgetType.FILEUPLOAD
|
||||
}
|
||||
|
||||
const widgetType = typeMapping[widget.type] || widget.type.toUpperCase()
|
||||
return getWidgetComponentFromRegistry(widgetType) || null
|
||||
}
|
||||
|
||||
// Convert LiteGraph widget to SimplifiedWidget interface
|
||||
const simplifiedWidget = (widget: IBaseWidget): SimplifiedWidget => {
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widget.value,
|
||||
options: widget.options,
|
||||
callback: widget.callback
|
||||
}
|
||||
}
|
||||
|
||||
// Handle widget value updates
|
||||
const handleWidgetUpdate = (widget: IBaseWidget, value: any) => {
|
||||
widget.value = value
|
||||
|
||||
// Call widget callback if exists
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
|
||||
// Mark node as dirty for LiteGraph
|
||||
if (props.node.onWidgetChanged) {
|
||||
props.node.onWidgetChanged(widget.name, value, null, widget)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
68
src/components/graph/vueNodes/OutputSlot.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--output flex items-center gap-2 py-1 pr-2 pl-4 cursor-crosshair hover:bg-black/5 justify-end"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
'lg-slot--compatible': compatible
|
||||
}"
|
||||
@pointerdown="handleClick"
|
||||
>
|
||||
<!-- Slot Name -->
|
||||
<span class="text-xs text-surface-700 whitespace-nowrap">
|
||||
{{ slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Connection Dot -->
|
||||
<div
|
||||
class="lg-slot__dot w-3 h-3 rounded-full border-2"
|
||||
:style="{
|
||||
backgroundColor: connected ? slotColor : 'transparent',
|
||||
borderColor: slotColor
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
|
||||
interface OutputSlotProps {
|
||||
node: LGraphNode
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<OutputSlotProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [event: PointerEvent]
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue output slot error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Handle click events
|
||||
const handleClick = (event: PointerEvent) => {
|
||||
if (!props.readonly) {
|
||||
emit('slot-click', event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
38
src/components/graph/vueWidgets/WidgetButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Button v-bind="filteredProps" :disabled="readonly" @click="handleClick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
BADGE_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
// Button widgets don't have a v-model value, they trigger actions
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<void>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// Button specific excluded props
|
||||
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.readonly && props.widget.callback) {
|
||||
props.widget.callback()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
65
src/components/graph/vueWidgets/WidgetChart.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<div class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded">
|
||||
<!-- Simple chart placeholder - can be enhanced with Chart.js when available -->
|
||||
<div
|
||||
v-if="!value || !Array.isArray(value.data)"
|
||||
class="text-center text-gray-500 dark-theme:text-gray-400"
|
||||
>
|
||||
No chart data available
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="value.title" class="text-center font-semibold">
|
||||
{{ value.title }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(item, index) in value.data"
|
||||
:key="index"
|
||||
class="flex justify-between items-center"
|
||||
>
|
||||
<span class="text-sm">{{ item.label || `Item ${index + 1}` }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-3 bg-blue-500 rounded"
|
||||
:style="{
|
||||
width: `${Math.max((item.value / maxValue) * 100, 5)}px`
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm font-mono">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
interface ChartData {
|
||||
title?: string
|
||||
data: Array<{
|
||||
label: string
|
||||
value: number
|
||||
}>
|
||||
}
|
||||
|
||||
const value = defineModel<ChartData>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
widget: SimplifiedWidget<ChartData>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const maxValue = computed(() => {
|
||||
if (!value.value?.data?.length) return 1
|
||||
return Math.max(...value.value.data.map((item) => item.value))
|
||||
})
|
||||
</script>
|
||||
33
src/components/graph/vueWidgets/WidgetColorPicker.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<ColorPicker v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<string>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
66
src/components/graph/vueWidgets/WidgetFileUpload.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<FileUpload
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
@upload="handleUpload"
|
||||
@select="handleSelect"
|
||||
@remove="handleRemove"
|
||||
@clear="handleClear"
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FileUpload from 'primevue/fileupload'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
// FileUpload doesn't have a traditional v-model, it handles files through events
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<File[] | null>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const handleUpload = (event: any) => {
|
||||
if (!props.readonly && props.widget.callback) {
|
||||
props.widget.callback(event.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (event: any) => {
|
||||
if (!props.readonly && props.widget.callback) {
|
||||
props.widget.callback(event.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (event: any) => {
|
||||
if (!props.readonly && props.widget.callback) {
|
||||
props.widget.callback(event.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (!props.readonly && props.widget.callback) {
|
||||
props.widget.callback([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (event: any) => {
|
||||
// Could be extended to handle error reporting
|
||||
console.warn('File upload error:', event)
|
||||
}
|
||||
</script>
|
||||
101
src/components/graph/vueWidgets/WidgetGalleria.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Galleria
|
||||
v-model:activeIndex="activeIndex"
|
||||
:value="galleryImages"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:show-thumbnails="showThumbnails"
|
||||
:show-indicators="showIndicators"
|
||||
:show-nav-buttons="showNavButtons"
|
||||
class="max-w-full"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img
|
||||
:src="item.itemImageSrc || item.src || item"
|
||||
:alt="item.alt || 'Gallery image'"
|
||||
class="w-full h-auto max-h-64 object-contain"
|
||||
/>
|
||||
</template>
|
||||
<template #thumbnail="{ item }">
|
||||
<img
|
||||
:src="item.thumbnailImageSrc || item.src || item"
|
||||
:alt="item.alt || 'Gallery thumbnail'"
|
||||
class="w-16 h-16 object-cover"
|
||||
/>
|
||||
</template>
|
||||
</Galleria>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
GALLERIA_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
interface GalleryImage {
|
||||
itemImageSrc?: string
|
||||
thumbnailImageSrc?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const galleryImages = computed(() => {
|
||||
if (!value.value || !Array.isArray(value.value)) return []
|
||||
|
||||
return value.value.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
itemImageSrc: item,
|
||||
thumbnailImageSrc: item,
|
||||
alt: `Image ${index + 1}`
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
})
|
||||
|
||||
const showThumbnails = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showThumbnails !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
|
||||
const showIndicators = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showIndicators !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
|
||||
const showNavButtons = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showNavButtons !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
</script>
|
||||
29
src/components/graph/vueWidgets/WidgetImage.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Image v-bind="filteredProps" :src="widget.value" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Image from 'primevue/image'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
IMAGE_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
// Image widgets typically don't have v-model, they display a source URL/path
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
172
src/components/graph/vueWidgets/WidgetImageCompare.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<div
|
||||
class="image-compare-container relative overflow-hidden rounded border border-gray-300 dark-theme:border-gray-600"
|
||||
>
|
||||
<div
|
||||
v-if="!beforeImage || !afterImage"
|
||||
class="p-4 text-center text-gray-500 dark-theme:text-gray-400"
|
||||
>
|
||||
Before and after images required
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<!-- After image (base layer) -->
|
||||
<Image
|
||||
v-bind="filteredProps"
|
||||
:src="afterImage"
|
||||
class="w-full h-auto"
|
||||
:alt="afterAlt"
|
||||
/>
|
||||
<!-- Before image (overlay layer) -->
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full overflow-hidden transition-all duration-300 ease-in-out"
|
||||
:style="{ width: `${sliderPosition}%` }"
|
||||
>
|
||||
<Image
|
||||
v-bind="filteredProps"
|
||||
:src="beforeImage"
|
||||
class="w-full h-auto"
|
||||
:alt="beforeAlt"
|
||||
/>
|
||||
</div>
|
||||
<!-- Slider handle -->
|
||||
<div
|
||||
class="absolute top-0 h-full w-0.5 bg-white shadow-lg cursor-col-resize z-10 transition-all duration-100"
|
||||
:style="{ left: `${sliderPosition}%` }"
|
||||
@mousedown="startDrag"
|
||||
@touchstart="startDrag"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-8 h-8 bg-white rounded-full shadow-md flex items-center justify-center"
|
||||
>
|
||||
<div class="w-4 h-4 flex items-center justify-center">
|
||||
<div class="w-0.5 h-3 bg-gray-600 mr-0.5"></div>
|
||||
<div class="w-0.5 h-3 bg-gray-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Labels -->
|
||||
<div
|
||||
v-if="showLabels"
|
||||
class="absolute top-2 left-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
|
||||
>
|
||||
{{ beforeLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showLabels"
|
||||
class="absolute top-2 right-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
|
||||
>
|
||||
{{ afterLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Image from 'primevue/image'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
IMAGE_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
interface ImageCompareValue {
|
||||
before: string
|
||||
after: string
|
||||
beforeAlt?: string
|
||||
afterAlt?: string
|
||||
beforeLabel?: string
|
||||
afterLabel?: string
|
||||
showLabels?: boolean
|
||||
initialPosition?: number
|
||||
}
|
||||
|
||||
// Image compare widgets typically don't have v-model, they display comparison
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ImageCompareValue>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const sliderPosition = ref(50) // Default to 50% (middle)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const beforeImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? value : value?.before
|
||||
})
|
||||
|
||||
const afterImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? value : value?.after
|
||||
})
|
||||
|
||||
const beforeAlt = computed(
|
||||
() => props.widget.value?.beforeAlt || 'Before image'
|
||||
)
|
||||
const afterAlt = computed(() => props.widget.value?.afterAlt || 'After image')
|
||||
const beforeLabel = computed(() => props.widget.value?.beforeLabel || 'Before')
|
||||
const afterLabel = computed(() => props.widget.value?.afterLabel || 'After')
|
||||
const showLabels = computed(() => props.widget.value?.showLabels !== false)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.widget.value?.initialPosition !== undefined) {
|
||||
sliderPosition.value = Math.max(
|
||||
0,
|
||||
Math.min(100, props.widget.value.initialPosition)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const startDrag = (event: MouseEvent | TouchEvent) => {
|
||||
if (props.readonly) return
|
||||
|
||||
isDragging.value = true
|
||||
event.preventDefault()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) return
|
||||
updateSliderPosition(e)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.removeEventListener('touchmove', handleMouseMove)
|
||||
document.removeEventListener('touchend', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.addEventListener('touchmove', handleMouseMove)
|
||||
document.addEventListener('touchend', handleMouseUp)
|
||||
}
|
||||
|
||||
const updateSliderPosition = (event: MouseEvent | TouchEvent) => {
|
||||
const container = (event.target as HTMLElement).closest(
|
||||
'.image-compare-container'
|
||||
)
|
||||
if (!container) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
|
||||
const x = clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
|
||||
|
||||
sliderPosition.value = percentage
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
isDragging.value = false
|
||||
})
|
||||
</script>
|
||||
30
src/components/graph/vueWidgets/WidgetInputText.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<InputText v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
INPUT_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<string>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
36
src/components/graph/vueWidgets/WidgetMultiSelect.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<MultiSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<any[]>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any[]>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// MultiSelect specific excluded props include overlay styles
|
||||
const MULTISELECT_EXCLUDED_PROPS = [
|
||||
...PANEL_EXCLUDED_PROPS,
|
||||
'overlayStyle'
|
||||
] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
30
src/components/graph/vueWidgets/WidgetSelect.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Select v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<any>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
30
src/components/graph/vueWidgets/WidgetSelectButton.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<SelectButton v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<any>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
30
src/components/graph/vueWidgets/WidgetSlider.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Slider v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<number>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
30
src/components/graph/vueWidgets/WidgetTextarea.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Textarea v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
INPUT_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<string>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
30
src/components/graph/vueWidgets/WidgetToggleSwitch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<ToggleSwitch v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<boolean>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<boolean>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
37
src/components/graph/vueWidgets/WidgetTreeSelect.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<TreeSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TreeSelect from 'primevue/treeselect'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<any>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// TreeSelect specific excluded props
|
||||
const TREE_SELECT_EXCLUDED_PROPS = [
|
||||
...PANEL_EXCLUDED_PROPS,
|
||||
'inputClass',
|
||||
'inputStyle'
|
||||
] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
80
src/components/graph/vueWidgets/widgetRegistry.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Widget type registry and component mapping for Vue-based widgets
|
||||
*/
|
||||
import type { Component } from 'vue'
|
||||
|
||||
// Component imports
|
||||
import WidgetButton from './WidgetButton.vue'
|
||||
import WidgetChart from './WidgetChart.vue'
|
||||
import WidgetColorPicker from './WidgetColorPicker.vue'
|
||||
import WidgetFileUpload from './WidgetFileUpload.vue'
|
||||
import WidgetGalleria from './WidgetGalleria.vue'
|
||||
import WidgetImage from './WidgetImage.vue'
|
||||
import WidgetImageCompare from './WidgetImageCompare.vue'
|
||||
import WidgetInputText from './WidgetInputText.vue'
|
||||
import WidgetMultiSelect from './WidgetMultiSelect.vue'
|
||||
import WidgetSelect from './WidgetSelect.vue'
|
||||
import WidgetSelectButton from './WidgetSelectButton.vue'
|
||||
import WidgetSlider from './WidgetSlider.vue'
|
||||
import WidgetTextarea from './WidgetTextarea.vue'
|
||||
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
|
||||
import WidgetTreeSelect from './WidgetTreeSelect.vue'
|
||||
|
||||
/**
|
||||
* Enum of all available widget types
|
||||
*/
|
||||
export enum WidgetType {
|
||||
BUTTON = 'BUTTON',
|
||||
STRING = 'STRING',
|
||||
INT = 'INT',
|
||||
FLOAT = 'FLOAT',
|
||||
NUMBER = 'NUMBER',
|
||||
BOOLEAN = 'BOOLEAN',
|
||||
COMBO = 'COMBO',
|
||||
COLOR = 'COLOR',
|
||||
MULTISELECT = 'MULTISELECT',
|
||||
SELECTBUTTON = 'SELECTBUTTON',
|
||||
SLIDER = 'SLIDER',
|
||||
TEXTAREA = 'TEXTAREA',
|
||||
TOGGLESWITCH = 'TOGGLESWITCH',
|
||||
CHART = 'CHART',
|
||||
IMAGE = 'IMAGE',
|
||||
IMAGECOMPARE = 'IMAGECOMPARE',
|
||||
GALLERIA = 'GALLERIA',
|
||||
FILEUPLOAD = 'FILEUPLOAD',
|
||||
TREESELECT = 'TREESELECT'
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps widget types to their corresponding Vue components
|
||||
* Components will be added as they are implemented
|
||||
*/
|
||||
export const widgetTypeToComponent: Record<string, Component> = {
|
||||
// Components will be uncommented as they are implemented
|
||||
[WidgetType.BUTTON]: WidgetButton,
|
||||
[WidgetType.STRING]: WidgetInputText,
|
||||
[WidgetType.INT]: WidgetSlider,
|
||||
[WidgetType.FLOAT]: WidgetSlider,
|
||||
[WidgetType.NUMBER]: WidgetSlider, // For compatibility
|
||||
[WidgetType.BOOLEAN]: WidgetToggleSwitch,
|
||||
[WidgetType.COMBO]: WidgetSelect,
|
||||
[WidgetType.COLOR]: WidgetColorPicker,
|
||||
[WidgetType.MULTISELECT]: WidgetMultiSelect,
|
||||
[WidgetType.SELECTBUTTON]: WidgetSelectButton,
|
||||
[WidgetType.SLIDER]: WidgetSlider,
|
||||
[WidgetType.TEXTAREA]: WidgetTextarea,
|
||||
[WidgetType.TOGGLESWITCH]: WidgetToggleSwitch,
|
||||
[WidgetType.CHART]: WidgetChart,
|
||||
[WidgetType.IMAGE]: WidgetImage,
|
||||
[WidgetType.IMAGECOMPARE]: WidgetImageCompare,
|
||||
[WidgetType.GALLERIA]: WidgetGalleria,
|
||||
[WidgetType.FILEUPLOAD]: WidgetFileUpload,
|
||||
[WidgetType.TREESELECT]: WidgetTreeSelect
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get widget component by type
|
||||
*/
|
||||
export function getWidgetComponent(type: string): Component | undefined {
|
||||
return widgetTypeToComponent[type]
|
||||
}
|
||||
@@ -95,14 +95,12 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
return
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
|
||||
if (disconnectOnReset) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
}
|
||||
disconnectOnReset = false
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-auto max-w-full">
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full">
|
||||
<WorkflowTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
54
src/composables/manager/useManagerStatePersistence.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ManagerState,
|
||||
ManagerTab,
|
||||
SortableAlgoliaField
|
||||
} from '@/types/comfyManagerTypes'
|
||||
|
||||
const STORAGE_KEY = 'Comfy.Manager.UI.State'
|
||||
|
||||
export const useManagerStatePersistence = () => {
|
||||
/**
|
||||
* Load the UI state from localStorage.
|
||||
*/
|
||||
const loadStoredState = (): ManagerState => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load manager UI state:', e)
|
||||
}
|
||||
return {
|
||||
selectedTabId: ManagerTab.All,
|
||||
searchQuery: '',
|
||||
searchMode: 'packs',
|
||||
sortField: SortableAlgoliaField.Downloads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the UI state to localStorage.
|
||||
*/
|
||||
const persistState = (state: ManagerState) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the UI state to the default values.
|
||||
*/
|
||||
const reset = () => {
|
||||
persistState({
|
||||
selectedTabId: ManagerTab.All,
|
||||
searchQuery: '',
|
||||
searchMode: 'packs',
|
||||
sortField: SortableAlgoliaField.Downloads
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
loadStoredState,
|
||||
persistState,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,29 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
interface ImageUploadFormFields {
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
type: ResultItemType
|
||||
}
|
||||
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean,
|
||||
formFields: Partial<ImageUploadFormFields> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -36,6 +50,11 @@ interface ImageUploadOptions {
|
||||
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
|
||||
*/
|
||||
accept?: string
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
folder?: ResultItemType
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +72,9 @@ export const useNodeImageUpload = (
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
const path = await uploadFile(file, isPastedFile(file), {
|
||||
type: options.folder
|
||||
})
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
|
||||
@@ -18,6 +19,16 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
const startFetchInstalled = async () => {
|
||||
await comfyManagerStore.refreshInstalledList()
|
||||
await startFetch()
|
||||
}
|
||||
|
||||
// When installedPackIds changes, we need to update the nodePacks
|
||||
whenever(installedPackIds, async () => {
|
||||
await startFetch()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
@@ -27,7 +38,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
isLoading,
|
||||
isReady,
|
||||
installedPacks: nodePacks,
|
||||
startFetchInstalled: startFetch,
|
||||
startFetchInstalled,
|
||||
filterInstalledPack
|
||||
}
|
||||
}
|
||||
|
||||
76
src/composables/nodePack/useMissingNodes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { NodeProperty } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
import { groupBy } from 'lodash'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
/**
|
||||
* Composable to find missing NodePacks from workflow
|
||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
||||
* Automatically fetches workflow pack data when initialized
|
||||
*/
|
||||
export const useMissingNodes = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||
useWorkflowPacks()
|
||||
|
||||
// Same filtering logic as ManagerDialogContent.vue
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
// Filter only uninstalled packs from workflow packs
|
||||
const missingNodePacks = computed(() => {
|
||||
if (!workflowPacks.value.length) return []
|
||||
return filterMissingPacks(workflowPacks.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
|
||||
* @param packId - The id of the pack to check
|
||||
* @returns True if the pack is the comfy-core pack, false otherwise
|
||||
*/
|
||||
const isCorePack = (packId: NodeProperty) => {
|
||||
return packId === 'comfy-core'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a missing core node
|
||||
* A missing core node is a node that is in the workflow and originates from
|
||||
* the comfy-core pack (pre-installed) but not registered in the node def
|
||||
* store (the node def was not found on the server)
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a missing core node, false otherwise
|
||||
*/
|
||||
const isMissingCoreNode = (node: LGraphNode) => {
|
||||
const packId = node.properties?.cnr_id
|
||||
if (packId === undefined || !isCorePack(packId)) return false
|
||||
const nodeName = node.type
|
||||
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
|
||||
return !isRegisteredNodeDef
|
||||
}
|
||||
|
||||
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
|
||||
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
|
||||
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
|
||||
})
|
||||
|
||||
// Automatically fetch workflow pack data when composable is used
|
||||
onMounted(async () => {
|
||||
if (!workflowPacks.value.length && !isLoading.value) {
|
||||
await startFetchWorkflowPacks()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
missingNodePacks,
|
||||
missingCoreNodes,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import { Point } from '@comfyorg/litegraph'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -27,6 +28,8 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -34,7 +37,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
@@ -59,6 +61,20 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
}
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
if (selectedNodes.length === 0) return
|
||||
|
||||
const gridSize = useSettingStore().get('Comfy.SnapToGrid.GridSize')
|
||||
selectedNodes.forEach((node) => {
|
||||
node.pos = positionUpdater(node.pos, gridSize)
|
||||
})
|
||||
app.canvas.state.selectionChanged = true
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
const commands = [
|
||||
{
|
||||
id: 'Comfy.NewBlankWorkflow',
|
||||
@@ -642,19 +658,19 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
{
|
||||
id: 'Comfy.Manager.CustomNodesManager',
|
||||
icon: 'pi pi-puzzle',
|
||||
label: 'Custom Nodes Manager',
|
||||
label: 'Toggle the Custom Nodes Manager',
|
||||
versionAdded: '1.12.10',
|
||||
function: () => {
|
||||
dialogService.showManagerDialog()
|
||||
dialogService.toggleManagerDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ToggleManagerProgressDialog',
|
||||
icon: 'pi pi-spinner',
|
||||
label: 'Toggle Progress Dialog',
|
||||
label: 'Toggle the Custom Nodes Manager Progress Bar',
|
||||
versionAdded: '1.13.9',
|
||||
function: () => {
|
||||
dialogService.showManagerProgressDialog()
|
||||
dialogService.toggleManagerProgressDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -676,28 +692,32 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ConvertToSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Up',
|
||||
icon: 'pi pi-arrow-up',
|
||||
label: 'Move Selected Nodes Up',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y - gridSize])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Down',
|
||||
icon: 'pi pi-arrow-down',
|
||||
label: 'Move Selected Nodes Down',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y + gridSize])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Left',
|
||||
icon: 'pi pi-arrow-left',
|
||||
label: 'Move Selected Nodes Left',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x - gridSize, y])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Right',
|
||||
icon: 'pi pi-arrow-right',
|
||||
label: 'Move Selected Nodes Right',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ export const useProgressFavicon = () => {
|
||||
if (isIdle) {
|
||||
favicon.value = defaultFavicon
|
||||
} else {
|
||||
const frame = Math.floor(progress * totalFrames)
|
||||
const frame = Math.min(
|
||||
Math.max(0, Math.floor(progress * totalFrames)),
|
||||
totalFrames - 1
|
||||
)
|
||||
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +1,61 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import type { Hit } from 'algoliasearch/dist/lite/browser'
|
||||
import { memoize, orderBy } from 'lodash'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { orderBy } from 'lodash'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
AlgoliaNodePack,
|
||||
SearchAttribute,
|
||||
useAlgoliaSearchService
|
||||
} from '@/services/algoliaSearchService'
|
||||
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import type { SearchAttribute } from '@/types/algoliaTypes'
|
||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 320
|
||||
const DEFAULT_PAGE_SIZE = 64
|
||||
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
|
||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
||||
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
|
||||
[SortableAlgoliaField.Downloads]: 'desc',
|
||||
[SortableAlgoliaField.Created]: 'desc',
|
||||
[SortableAlgoliaField.Updated]: 'desc',
|
||||
[SortableAlgoliaField.Publisher]: 'asc',
|
||||
[SortableAlgoliaField.Name]: 'asc'
|
||||
}
|
||||
|
||||
const isDateField = (field: SortableAlgoliaField): boolean =>
|
||||
field === SortableAlgoliaField.Created ||
|
||||
field === SortableAlgoliaField.Updated
|
||||
|
||||
/**
|
||||
* Composable for managing UI state of Comfy Node Registry search.
|
||||
*/
|
||||
export function useRegistrySearch(
|
||||
options: {
|
||||
maxCacheSize?: number
|
||||
initialSortField?: string
|
||||
initialSearchMode?: SearchMode
|
||||
initialSearchQuery?: string
|
||||
initialPageNumber?: number
|
||||
} = {}
|
||||
) {
|
||||
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
|
||||
const {
|
||||
initialSortField = DEFAULT_SORT_FIELD,
|
||||
initialSearchMode = 'packs',
|
||||
initialSearchQuery = '',
|
||||
initialPageNumber = 0
|
||||
} = options
|
||||
|
||||
const isLoading = ref(false)
|
||||
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
|
||||
const searchMode = ref<'nodes' | 'packs'>('packs')
|
||||
const sortField = ref<string>(initialSortField)
|
||||
const searchMode = ref<SearchMode>(initialSearchMode)
|
||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||
const pageNumber = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const results = ref<AlgoliaNodePack[]>([])
|
||||
const suggestions = ref<NodesIndexSuggestion[]>([])
|
||||
const pageNumber = ref(initialPageNumber)
|
||||
const searchQuery = ref(initialSearchQuery)
|
||||
const searchResults = ref<RegistryNodePack[]>([])
|
||||
const suggestions = ref<QuerySuggestion[]>([])
|
||||
|
||||
const searchAttributes = computed<SearchAttribute[]>(() =>
|
||||
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
|
||||
)
|
||||
|
||||
const resultsAsRegistryPacks = computed(() =>
|
||||
results.value ? results.value.map(algoliaToRegistry) : []
|
||||
)
|
||||
const resultsAsNodes = computed(() =>
|
||||
results.value
|
||||
? results.value.reduce(
|
||||
(acc, hit) => acc.concat(hit.comfy_nodes),
|
||||
[] as string[]
|
||||
)
|
||||
: []
|
||||
)
|
||||
const searchGateway = useRegistrySearchGateway()
|
||||
|
||||
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
|
||||
useAlgoliaSearchService({
|
||||
maxCacheSize
|
||||
})
|
||||
|
||||
const algoliaToRegistry = memoize(
|
||||
toRegistryPack,
|
||||
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
|
||||
)
|
||||
const getSortValue = (pack: Hit<AlgoliaNodePack>) => {
|
||||
if (isDateField(sortField.value)) {
|
||||
const value = pack[sortField.value]
|
||||
return value ? new Date(value).getTime() : 0
|
||||
} else {
|
||||
const value = pack[sortField.value]
|
||||
return value ?? 0
|
||||
}
|
||||
}
|
||||
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
|
||||
searchGateway
|
||||
|
||||
const updateSearchResults = async (options: { append?: boolean }) => {
|
||||
isLoading.value = true
|
||||
if (!options.append) {
|
||||
pageNumber.value = 0
|
||||
}
|
||||
const { nodePacks, querySuggestions } = await searchPacksCached(
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
@@ -98,17 +68,22 @@ export function useRegistrySearch(
|
||||
|
||||
// Results are sorted by the default field to begin with -- so don't manually sort again
|
||||
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
|
||||
// Get the sort direction from the provider's sortable fields
|
||||
const sortableFields = getSortableFields()
|
||||
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
|
||||
const direction = fieldConfig?.direction || 'desc'
|
||||
|
||||
sortedPacks = orderBy(
|
||||
nodePacks,
|
||||
[getSortValue],
|
||||
[SORT_DIRECTIONS[sortField.value]]
|
||||
[(pack) => getSortValue(pack, sortField.value)],
|
||||
[direction]
|
||||
)
|
||||
}
|
||||
|
||||
if (options.append && results.value?.length) {
|
||||
results.value = results.value.concat(sortedPacks)
|
||||
if (options.append && searchResults.value?.length) {
|
||||
searchResults.value = searchResults.value.concat(sortedPacks)
|
||||
} else {
|
||||
results.value = sortedPacks
|
||||
searchResults.value = sortedPacks
|
||||
}
|
||||
suggestions.value = querySuggestions
|
||||
isLoading.value = false
|
||||
@@ -124,7 +99,9 @@ export function useRegistrySearch(
|
||||
immediate: true
|
||||
})
|
||||
|
||||
onUnmounted(clearSearchPacksCache)
|
||||
const sortOptions = computed(() => {
|
||||
return getSortableFields()
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
@@ -134,7 +111,8 @@ export function useRegistrySearch(
|
||||
searchMode,
|
||||
searchQuery,
|
||||
suggestions,
|
||||
searchResults: resultsAsRegistryPacks,
|
||||
nodeSearchResults: resultsAsNodes
|
||||
searchResults,
|
||||
sortOptions,
|
||||
clearCache: clearSearchCache
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -33,8 +34,15 @@ export const useImageUploadWidget = () => {
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
if (!isImageUploadInput(inputData)) {
|
||||
throw new Error(
|
||||
'Image upload widget requires imageInputName augmentation'
|
||||
)
|
||||
}
|
||||
|
||||
const inputOptions = inputData[1]
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const folder: ResultItemType | undefined = image_folder
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
@@ -43,11 +51,9 @@ export const useImageUploadWidget = () => {
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
|
||||
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 => {
|
||||
@@ -67,10 +73,10 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
// Setup file upload handling
|
||||
const { openFileSelection } = useNodeImageUpload(node, {
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
allow_batch,
|
||||
fileFilter,
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
|
||||