Compare commits

..

55 Commits

Author SHA1 Message Date
filtered
28f2788cd5 Subgraph testing release v1.22.0-sub.0 (#4086) 2025-06-05 13:56:31 -07:00
Blake
a1ed6ddbf8 [chore] Update litegraph 0.16.0-sub.7 2025-06-05 12:23:20 -07:00
Blake
3d0a3264fe [Test] Remove failing test (auto-generated) 2025-06-05 05:30:12 -07:00
Blake
ceaf47c40e Fix convert to subgraph shown on IO node 2025-06-05 03:21:15 -07:00
filtered
085ed03e46 Fix Vue unwrap using markRaw
Involves minor runtime change.
2025-06-04 03:09:59 -07:00
filtered
465903a962 [Test] Revert invalid test generation 2025-06-04 03:08:24 -07:00
filtered
f3f7eea87c [chore] Update litegraph 0.16.0-sub.6 2025-06-04 02:33:43 -07:00
filtered
38a118da2e Reimpl. escape key handling in frontend 2025-06-04 00:55:24 -07:00
filtered
f76eae3383 Add subgraph nodes to before/after exec 2025-06-04 00:55:24 -07:00
filtered
97765136ed nit 2025-06-04 00:55:24 -07:00
filtered
ced55b1cfc Remove deprecated group node conversion menu 2025-06-04 00:55:24 -07:00
filtered
bdae544c4a Add convert to subgraph toolbox button 2025-06-04 00:55:24 -07:00
filtered
88bb9eea39 [chore] Update litegraph 0.16.0-sub.5 2025-06-04 00:55:24 -07:00
filtered
1bb18b837e Fix delete button shown for io nodes 2025-06-04 00:55:24 -07:00
filtered
ab3a1fff63 Fix edit mask button shown when non-nodes selected 2025-06-04 00:55:24 -07:00
filtered
0de0d1645d Fix breadcrumbs overlap 2nd row tabs 2025-06-04 00:55:24 -07:00
filtered
907f1e27cc Fix active subgraph requires breadcrumb component 2025-06-04 00:55:24 -07:00
filtered
ed80211ae6 Fix unwarranted workflow validation warning 2025-06-04 00:55:24 -07:00
filtered
afd7741927 [chore] Update litegraph 0.16.0-sub.4 2025-06-04 00:55:24 -07:00
filtered
083ef7d779 [Cleanup] Remove redundant LinkConnector call 2025-06-04 00:55:24 -07:00
filtered
9cc9e20955 Update node create to use active subgraph 2025-06-04 00:55:24 -07:00
filtered
b2760a345f [chore] Update litegraph 0.16.0-sub.3 2025-06-04 00:55:24 -07:00
filtered
a3cda65a7b Fix invalid links in nested subgraph conversion 2025-06-04 00:55:24 -07:00
filtered
c646ba8c53 Update litegraph 0.16.0-sub.2 2025-06-04 00:55:24 -07:00
filtered
2918eb2213 Fix crash on graph load - reactive proxy leak 2025-06-04 00:55:24 -07:00
filtered
38fed2f2c9 Update litegraph 0.16.0-sub.1 2025-06-04 00:55:24 -07:00
filtered
53a1598459 Fix breadcrumb reactivity 2025-06-04 00:55:24 -07:00
github-actions
d06728dad3 Update locales [skip ci] 2025-06-04 00:55:24 -07:00
filtered
2405fb16bc Use subgraph npm 2025-06-04 00:55:24 -07:00
filtered
a56b616571 Add simpler interface for active DOM widgets 2025-06-04 00:54:51 -07:00
filtered
5888f673e4 [Test] Update expectations 2025-06-04 00:54:51 -07:00
filtered
9fdcc02d0f Keep subgraph nav state when swapping workflows 2025-06-04 00:54:51 -07:00
filtered
f862afafb4 Clear DOM widgets instead of trying to manage refs 2025-06-04 00:54:51 -07:00
filtered
7f6d0b3c47 [Debug] Include more items in graph diff output 2025-06-04 00:54:27 -07:00
filtered
cae1845d6c Add convert to subgraph command 2025-06-04 00:54:27 -07:00
filtered
9541735bef Fix DOM widgets disappear 2025-06-04 00:54:27 -07:00
filtered
3f5113f49d nit 2025-06-04 00:54:27 -07:00
filtered
a0059b4ba2 Track subgraph open history for breadcrumbs 2025-06-04 00:54:27 -07:00
filtered
5d78cec293 [TS] Replace ts-ignore w/error 2025-06-04 00:54:27 -07:00
filtered
2a41b90538 nit 2025-06-04 00:54:27 -07:00
filtered
ecdf89d8d8 Fix desync of litegraph type and nodedefs 2025-06-04 00:54:27 -07:00
filtered
6d5cbcc8b6 [TS] Remove unnecessary assertion 2025-06-04 00:54:27 -07:00
filtered
024212c735 Prune DOM widgets outside of current subgraph 2025-06-04 00:54:27 -07:00
filtered
4342fc46ab Remove unnecessary copy of litegraph objects 2025-06-04 00:54:27 -07:00
filtered
aebc35f4fb Fix nested subgraph breadcrumbs 2025-06-04 00:54:27 -07:00
filtered
afb441654f nit - prevent duplicate subgraph registrations 2025-06-04 00:54:27 -07:00
filtered
03af7516a4 Fix DOM widgets after rebase 2025-06-04 00:54:27 -07:00
filtered
27779ce7c5 [PARTIAL] Add Subgraph 2025-06-04 00:54:27 -07:00
filtered
f2bc74f0b7 Fix corruption when app.graph is changed 2025-06-04 00:54:27 -07:00
filtered
1589f2cce6 [TS] Remove unintended type assertion 2025-06-04 00:54:27 -07:00
filtered
c686c9c144 Add subgraph workflow fields to change tracker 2025-06-04 00:54:27 -07:00
filtered
d7bca4aa28 Add i18n for nothing selected notification 2025-06-04 00:54:27 -07:00
filtered
7d4050de05 [Refactor] Generic TS type dedupe 2025-06-04 00:54:27 -07:00
filtered
5077dee42b [Nope] Zod schema recursive II 2025-06-04 00:54:26 -07:00
filtered
03bfa0ae71 [Nope] Add Zod recursive schema requirements 2025-06-04 00:54:26 -07:00
143 changed files with 2859 additions and 23073 deletions

View File

@@ -1,25 +1,26 @@
# Vue 3 Composition API Project Rules
// Vue 3 Composition API .cursorrules
## 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:
// 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:
```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",
]
## Project Structure
```
// Folder structure
const folderStructure = `
src/
components/
constants/
@@ -29,25 +30,16 @@ src/
services/
App.vue
main.ts
```
`;
## Styling Guidelines
- Use Tailwind CSS for styling
- Implement responsive design with Tailwind CSS
// Tailwind CSS best practices
const tailwindCssBestPractices = [
"Use Tailwind CSS for styling",
"Implement responsive design with Tailwind CSS",
]
## PrimeVue Component Guidelines
DO NOT use deprecated PrimeVue components. Use these replacements instead:
- Dropdown → Use Select (import from 'primevue/select')
- OverlayPanel → Use Popover (import from 'primevue/popover')
- Calendar → Use DatePicker (import from 'primevue/datepicker')
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
- Sidebar → Use Drawer (import from 'primevue/drawer')
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
- TabMenu → Use Tabs without panels
- Steps → Use Stepper without panels
- InlineMessage → Use Message component
## Development Guidelines
// Additional instructions
const additionalInstructions = `
1. Leverage VueUse functions for performance-enhancing styles
2. Use lodash for utility functions
3. Use TypeScript for type safety
@@ -57,5 +49,6 @@ DO NOT use deprecated PrimeVue components. Use these replacements instead:
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
11. Never use deprecated PrimeVue components listed above
10. Use vue-i18n in composition API for any string literals. Place new translation
entries in src/locales/en/main.json.
`;

View File

@@ -29,7 +29,3 @@ 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

View File

@@ -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@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -136,7 +136,7 @@ jobs:
git commit -m "Update locales"
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
uses: shimataro/ssh-key-action@v2
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}

View File

@@ -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@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: "Update locales for node definitions"

View File

@@ -54,7 +54,7 @@ jobs:
name: dist-files
- name: Create release
id: create_release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: softprops/action-gh-release@v2
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@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -46,8 +46,8 @@ jobs:
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
- name: Cache setup
uses: actions/cache@v3
with:
path: |
ComfyUI
@@ -62,13 +62,9 @@ jobs:
matrix:
browser: [chromium, chromium-2x, mobile-chrome]
steps:
- name: Wait for cache propagation
run: sleep 10
- name: Restore cached setup
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
uses: actions/cache@v3
with:
fail-on-cache-miss: true
path: |
ComfyUI
ComfyUI_frontend

View File

@@ -30,7 +30,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -29,7 +29,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -1,92 +0,0 @@
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

View File

@@ -75,7 +75,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'

View File

@@ -38,7 +38,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
}
}
}

View File

@@ -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 specific 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 speicifc 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 reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- 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
- 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,21 +36,3 @@
- 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

View File

@@ -609,68 +609,6 @@ 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

View File

@@ -762,7 +762,7 @@ export class ComfyPage {
y: 625
}
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -774,7 +774,7 @@ export class ComfyPage {
},
button: 'right'
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -1046,8 +1046,6 @@ export class ComfyPage {
}
}
export const testComfySnapToGridGridSize = 50
export const comfyPageFixture = base.extend<{
comfyPage: ComfyPage
comfyMouse: ComfyMouse
@@ -1074,8 +1072,7 @@ 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.SnapToGrid.GridSize': testComfySnapToGridGridSize
'Comfy.TutorialCompleted': true
})
} catch (e) {
console.error(e)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test.skip('Can be added to canvas using node library sidebar', async ({
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
).toHaveLength(0)
})
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
)
})
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test.skip('Manage group opens with the correct group selected', async ({
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
expect(visibleInputCount).toBe(2)
})
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {

View File

@@ -1,12 +1,6 @@
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -63,10 +57,8 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
const dragSelectNodes = async (
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -82,67 +74,10 @@ 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 }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -24,7 +24,7 @@ test.describe('Canvas Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test.skip('Can convert to group node', async ({ comfyPage }) => {
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

1858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.23.2-sub.13",
"version": "1.22.0-sub.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -29,11 +29,10 @@
},
"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.52.0",
"@playwright/test": "^1.44.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
@@ -76,7 +75,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.17",
"@comfyorg/litegraph": "^0.16.0-sub.7",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -71,15 +71,8 @@ useEventListener(document, 'keydown', (event) => {
.subgraph-breadcrumb {
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
color: #d26565;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
0 0 0.375rem #000;
user-select: none;
}
}
</style>

View File

@@ -1,12 +1,15 @@
<template>
<div
<hr
:class="{
'content-divider': true,
'content-divider--horizontal': orientation === 'horizontal',
'content-divider--vertical': orientation === 'vertical'
'm-0': true,
'border-t': orientation === 'horizontal',
'border-l': orientation === 'vertical',
'h-full': orientation === 'vertical',
'w-full': orientation === 'horizontal'
}"
:style="{
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
borderWidth: `${width}px !important`
}"
/>
</template>
@@ -26,25 +29,3 @@ 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>

View File

@@ -92,21 +92,12 @@ whenever(
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
// 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
}
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
}
}
const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
onResize.cancel() // Clear pending debounced calls
})

View File

@@ -1,12 +1,14 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="item in dialogStore.dialogStack"
v-for="(item, index) 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>
@@ -33,11 +35,25 @@
</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>
@@ -50,17 +66,4 @@ const dialogStore = useDialogStore()
@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>

View File

@@ -5,7 +5,6 @@
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"
@@ -32,12 +31,6 @@
</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>
@@ -48,9 +41,6 @@ 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'
@@ -62,10 +52,6 @@ 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

View File

@@ -1,95 +0,0 @@
<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>

View File

@@ -1,16 +1,14 @@
<template>
<div
class="h-full flex flex-col mx-auto overflow-hidden"
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
:aria-label="$t('manager.title')"
>
<ContentDivider :width="0.3" />
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
severity="secondary"
filled
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
@@ -20,20 +18,20 @@
:tabs="tabs"
/>
<div
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
class="flex-1 overflow-auto pr-80"
:class="{
'transition-all duration-300': isSmallScreen
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
}"
>
<div class="px-6 flex flex-col h-full">
<div class="px-6 pt-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
@@ -59,7 +57,7 @@
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="4"
:buffer-rows="3"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
@@ -77,9 +75,9 @@
</div>
</div>
</div>
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="w-full flex flex-col isolate">
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
@@ -95,14 +93,7 @@
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -115,7 +106,6 @@ 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'
@@ -126,15 +116,13 @@ import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { initialTab } = defineProps<{
initialTab?: ManagerTab
const { initialTab = ManagerTab.All } = defineProps<{
initialTab: ManagerTab
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
const GRID_STYLE = {
display: 'grid',
@@ -168,10 +156,8 @@ const tabs = ref<TabItem[]>([
icon: 'pi-sync'
}
])
const initialTabId = initialTab ?? initialState.selectedTabId
const selectedTab = ref<TabItem>(
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
tabs.value.find((tab) => tab.id === initialTab) || tabs.value[0]
)
const {
@@ -181,13 +167,8 @@ const {
searchResults,
searchMode,
sortField,
suggestions,
sortOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,
initialSearchQuery: initialState.searchQuery
})
suggestions
} = useRegistrySearch()
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
@@ -219,6 +200,10 @@ 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
)
@@ -247,11 +232,7 @@ watch(
if (!isEmptySearch.value) {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
} else if (!installedPacks.value.length) {
await startFetchInstalled()
} else {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
@@ -427,36 +408,19 @@ 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
// 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)
}
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)]
}
})
@@ -464,23 +428,13 @@ let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch([searchQuery, selectedTab], () => {
watch(searchQuery, () => {
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()
})

View File

@@ -1,9 +1,14 @@
<template>
<div class="w-full">
<div class="flex items-center">
<div class="px-6 py-4">
<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>

View File

@@ -1,8 +1,8 @@
<template>
<aside
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
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"
>
<ScrollPanel class="flex-1">
<ScrollPanel class="w-80 mt-7">
<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-3 gap-2' },
option: { class: 'px-4 py-2 text-lg rounded-lg' },
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
<span class="text-sm">{{ slotProps.option.label }}</span>
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
<span class="text-lg">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" :width="0.3" />
<ContentDivider orientation="vertical" />
</aside>
</template>

View File

@@ -62,7 +62,6 @@ describe('PackVersionBadge', () => {
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
isSelected: false,
...props
},
global: {
@@ -163,58 +162,4 @@ 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()
})
})
})

View File

@@ -33,7 +33,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
@@ -43,9 +43,8 @@ import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
const { nodePack, isSelected } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
isSelected: boolean
}>()
const popoverRef = ref()
@@ -70,14 +69,4 @@ 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>

View File

@@ -191,100 +191,6 @@ 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

View File

@@ -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, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -161,11 +161,9 @@ const onNodePackChange = async () => {
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
void onNodePackChange()
}
() => nodePack,
() => {
void onNodePackChange()
},
{ deep: true, immediate: true }
)
@@ -184,4 +182,8 @@ const handleSubmit = async () => {
isQueueing.value = false
emit('submit')
}
onUnmounted(() => {
managerStore.installPack.clear()
})
</script>

View File

@@ -1,18 +1,16 @@
<template>
<Button
outlined
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'
]"
class="m-0 p-0 rounded-lg border-neutral-700"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3 whitespace-nowrap">
<span class="py-2.5 px-3">
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -29,14 +27,12 @@ import Button from 'primevue/button'
const {
label,
loadingMessage,
fullWidth = false,
variant = 'default'
fullWidth = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
}>()
const emit = defineEmits<{

View File

@@ -2,11 +2,9 @@
<PackActionButton
v-bind="$attrs"
:label="
label ??
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
"
:severity="variant === 'black' ? undefined : 'secondary'"
:variant="variant"
severity="secondary"
:loading="isInstalling"
:loading-message="$t('g.installing')"
@action="installAllPacks"
@@ -29,10 +27,8 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, variant, label } = defineProps<{
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
}>()
const isInstalling = inject(IsInstallingKey, ref(false))

View File

@@ -1,6 +1,6 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="flex flex-col h-full z-40 w-80 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" :is-selected="true" />
<PackVersionBadge :node-pack="nodePack" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
@@ -42,7 +42,7 @@
</div>
</template>
<template v-else>
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
@@ -118,15 +118,7 @@ const onNodePackChange = () => {
y.value = 0
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
onNodePackChange()
}
},
{ immediate: true }
)
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
</script>
<style scoped>
.hidden-scrollbar {

View File

@@ -51,11 +51,7 @@ 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,
// Fetch all nodes.
// TODO: Render all nodes previews and handle pagination.
// For determining length, use the `totalNumberOfPages` field of response
limit: 8192
version: pack.latest_version?.version
})
return nodeDefs?.comfy_nodes ?? []
}

View File

@@ -51,7 +51,6 @@ const isLoading = ref(false)
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
const fetchNodeDefs = async () => {
getNodeDefs.cancel()
isLoading.value = true
const { id: packId } = nodePack

View File

@@ -1,64 +0,0 @@
<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>

View File

@@ -1,21 +1,25 @@
<template>
<Card
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="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="{
'selected-card': isSelected,
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
'opacity-60': isDisabled
}"
:pt="{
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' },
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'
},
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<PackBanner :node-pack="nodePack" />
<PackCardHeader :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"
@@ -30,66 +34,46 @@
</template>
<template v-else>
<div
class="self-stretch inline-flex flex-col justify-start items-start"
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<div
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
>
<div
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 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"
>
<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 break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
</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="self-stretch inline-flex justify-start items-center gap-1"
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<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>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
@@ -108,28 +92,21 @@ 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,
type MergedNodePack,
type RegistryPack,
isMergedNodePack
} from '@/types/comfyManagerTypes'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack, isSelected = false } = defineProps<{
nodePack: MergedNodePack | RegistryPack
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const { d } = useI18n()
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
@@ -143,40 +120,6 @@ const isDisabled = computed(
whenever(isInstalled, () => (isInstalling.value = false))
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'
})
})
// 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)
</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>

View File

@@ -1,35 +1,39 @@
<template>
<div
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
<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>
<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 { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const publisherName = computed(() => {
if (!nodePack) return null
const { n } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
</script>

View File

@@ -1,37 +1,28 @@
<template>
<div class="relative w-full p-6">
<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'
<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'
}
}"
: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')"
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<div class="flex mt-3 text-sm">
@@ -43,7 +34,7 @@
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="availableSortOptions"
:options="sortOptions"
:label="$t('g.sort')"
/>
</div>
@@ -64,55 +55,43 @@ 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 { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
const { searchResults, sortOptions } = defineProps<{
const { searchResults } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
suggestions?: NodesIndexSuggestion[]
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
const sortField = defineModel<SortableAlgoliaField>('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 availableSortOptions = computed<SearchOption<string>[]>(() => {
if (!sortOptions) return []
return sortOptions.map((field) => ({
id: field.id,
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
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>[] = [
{ 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
}

View File

@@ -54,21 +54,4 @@ 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'
)
})
})

View File

@@ -28,7 +28,6 @@ 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'
@@ -65,7 +64,7 @@ const formItem = computed(() => {
...props.setting,
name: t(`settings.${normalizedId}.name`, props.setting.name),
tooltip: props.setting.tooltip
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
: undefined,
options: props.setting.options
? translateOptions(props.setting.options)

View File

@@ -194,10 +194,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) {

View File

@@ -46,68 +46,10 @@ vi.mock('@vueuse/core', () => ({
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `/fileURL${path}`,
apiURL: (path: string) => `/apiURL${path}`,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
apiURL: (path: string) => `/apiURL${path}`
}
}))
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',

View File

@@ -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 { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { api } from '@/scripts/api'
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 { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
useTemplateWorkflows()
const getThumbnailUrl = (index = '') => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
// Determine the effective source module to use (from template or prop)
const effectiveSourceModule = computed(
() => template.sourceModule || sourceModule
)
// For templates from custom nodes, multiple images is not yet supported
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
const baseThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '1' : ''
)
getThumbnailUrl(sourceModule === 'default' ? '1' : '')
)
const overlayThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '2' : ''
)
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
)
const description = computed(() =>
getTemplateDescription(template, effectiveSourceModule.value)
)
const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
const description = computed(() => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description.replace(/[-_]/g, ' ').trim()
})
const title = computed(() => {
return sourceModule === 'default'
? template.localizedTitle ?? ''
: template.name
})
defineEmits<{
loadWorkflow: [name: string]

View File

@@ -1,19 +1,21 @@
<template>
<DataTable
v-model:selection="selectedTemplate"
:value="enrichedTemplates"
:value="templates"
striped-rows
selection-mode="single"
>
<Column field="title" :header="$t('g.title')">
<template #body="slotProps">
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
<span :title="getTemplateTitle(slotProps.data)">{{
getTemplateTitle(slotProps.data)
}}</span>
</template>
</Column>
<Column field="description" :header="$t('g.description')">
<template #body="slotProps">
<span :title="slotProps.data.description">
{{ slotProps.data.description }}
<span :title="getTemplateDescription(slotProps.data)">
{{ getTemplateDescription(slotProps.data) }}
</span>
</template>
</Column>
@@ -36,9 +38,8 @@
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
const { sourceModule, loading, templates } = defineProps<{
@@ -49,20 +50,21 @@ 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>

View File

@@ -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="!isTemplatesLoaded || !isReady"
v-if="!workflowTemplatesStore.isLoaded || !isReady"
class="absolute w-8 h-full inset-0"
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="handleTabSelection"
/>
</aside>
@@ -37,14 +37,14 @@
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
v-if="isReady && selectedTab"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
:title="selectedTab.title"
:source-module="selectedTab.moduleName"
:templates="selectedTab.templates"
:loading="workflowLoading"
:category-title="selectedTab.title"
@load-workflow="loadWorkflow"
/>
</div>
</div>
@@ -56,46 +56,47 @@ import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
const { t } = useI18n()
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const {
selectedTemplate,
loadingTemplateId,
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
}
},
{ once: true }
const workflowTemplatesStore = useWorkflowTemplatesStore()
const { isReady } = useAsyncState(
workflowTemplatesStore.loadWorkflowTemplates,
null
)
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) => {
if (selection !== null) {
selectTemplateCategory(selection)
//Listbox allows deselecting so this special case is ignored here
if (selection !== selectedTab.value && selection !== null) {
selectedTab.value = selection
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
@@ -104,9 +105,30 @@ const handleTabSelection = (selection: WorkflowTemplates | null) => {
}
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false
const loadWorkflow = async (id: string) => {
if (!isReady.value) return
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
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
}
</script>

View File

@@ -101,7 +101,6 @@ 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

View File

@@ -1,54 +0,0 @@
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
}
}

View File

@@ -7,7 +7,6 @@ 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'
@@ -112,15 +111,10 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
// 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 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
const isLightTheme =
colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
@@ -143,24 +137,7 @@ 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)
}

View File

@@ -3,29 +3,15 @@ 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
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 uploadFile = async (file: File, isPasted: boolean) => {
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',
@@ -50,11 +36,6 @@ 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
}
/**
@@ -72,9 +53,7 @@ export const useNodeImageUpload = (
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file), {
type: options.folder
})
const path = await uploadFile(file, isPastedFile(file))
if (!path) return
return path
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
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)
}
}

View File

@@ -1,4 +1,3 @@
import { whenever } from '@vueuse/core'
import { computed, onUnmounted } from 'vue'
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
@@ -19,16 +18,6 @@ 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()
})
@@ -38,7 +27,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
isLoading,
isReady,
installedPacks: nodePacks,
startFetchInstalled,
startFetchInstalled: startFetch,
filterInstalledPack
}
}

View File

@@ -1,76 +0,0 @@
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
}
}

View File

@@ -4,7 +4,6 @@ import {
LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import { Point } from '@comfyorg/litegraph'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import {
@@ -28,8 +27,6 @@ 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()
@@ -62,20 +59,6 @@ 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',
@@ -659,19 +642,19 @@ export function useCoreCommands(): ComfyCommand[] {
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Toggle the Custom Nodes Manager',
label: 'Custom Nodes Manager',
versionAdded: '1.12.10',
function: () => {
dialogService.toggleManagerDialog()
dialogService.showManagerDialog()
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle the Custom Nodes Manager Progress Bar',
label: 'Toggle Progress Dialog',
versionAdded: '1.13.9',
function: () => {
dialogService.toggleManagerProgressDialog()
dialogService.showManagerProgressDialog()
}
},
{
@@ -692,34 +675,6 @@ export function useCoreCommands(): ComfyCommand[] {
await firebaseAuthActions.logout()
}
},
{
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])
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',

View File

@@ -9,7 +9,6 @@ export function useErrorHandling() {
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
console.error(error)
}
const wrapWithErrorHandling =

View File

@@ -15,10 +15,7 @@ export const useProgressFavicon = () => {
if (isIdle) {
favicon.value = defaultFavicon
} else {
const frame = Math.min(
Math.max(0, Math.floor(progress * totalFrames)),
totalFrames - 1
)
const frame = Math.floor(progress * totalFrames)
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
}
}

View File

@@ -1,61 +1,91 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
import type { Hit } from 'algoliasearch/dist/lite/browser'
import { memoize, orderBy } from 'lodash'
import { computed, onUnmounted, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import {
AlgoliaNodePack,
SearchAttribute,
useAlgoliaSearchService
} from '@/services/algoliaSearchService'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
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: {
initialSortField?: string
initialSearchMode?: SearchMode
initialSearchQuery?: string
initialPageNumber?: number
maxCacheSize?: number
} = {}
) {
const {
initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
const isLoading = ref(false)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
const searchMode = ref<'nodes' | 'packs'>('packs')
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const pageNumber = ref(0)
const searchQuery = ref('')
const results = ref<AlgoliaNodePack[]>([])
const suggestions = ref<NodesIndexSuggestion[]>([])
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
const searchGateway = useRegistrySearchGateway()
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 { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
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 updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
const { nodePacks, querySuggestions } = await searchPacksCached(
searchQuery.value,
{
pageSize: pageSize.value,
@@ -68,22 +98,17 @@ 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,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
[getSortValue],
[SORT_DIRECTIONS[sortField.value]]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
if (options.append && results.value?.length) {
results.value = results.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
results.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
@@ -99,9 +124,7 @@ export function useRegistrySearch(
immediate: true
})
const sortOptions = computed(() => {
return getSortableFields()
})
onUnmounted(clearSearchPacksCache)
return {
isLoading,
@@ -111,8 +134,7 @@ export function useRegistrySearch(
searchMode,
searchQuery,
suggestions,
searchResults,
sortOptions,
clearCache: clearSearchCache
searchResults: resultsAsRegistryPacks,
nodeSearchResults: resultsAsNodes
}
}

View File

@@ -1,190 +0,0 @@
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
}
}

View File

@@ -5,11 +5,10 @@ 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, ResultItemType } from '@/schemas/apiSchema'
import type { ResultItem } 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'
@@ -34,15 +33,8 @@ export const useImageUploadWidget = () => {
inputName: string,
inputData: InputSpec
) => {
if (!isImageUploadInput(inputData)) {
throw new Error(
'Image upload widget requires imageInputName augmentation'
)
}
const inputOptions = inputData[1]
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
@@ -51,9 +43,11 @@ 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 => {
@@ -73,10 +67,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

View File

@@ -2,9 +2,7 @@ import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph'
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -222,46 +220,6 @@ export function useRemoteWidget<
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,

View File

@@ -1,3 +0,0 @@
export const SEARCH_CACHE_MAX_SIZE = 64
export const DEFAULT_PAGE_SIZE = 64
export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2

View File

@@ -160,43 +160,22 @@ class Load3d {
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
}
renderMainScene(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(true)
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
this.INITIAL_RENDER_DONE = true
}
private getActiveCamera(): THREE.Camera {
@@ -219,17 +198,20 @@ class Load3d {
return
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
const delta = this.clock.getDelta()
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
@@ -316,18 +298,17 @@ class Load3d {
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.previewManager.setPreviewBackgroundColor(color)
this.forceRender()
}
async setBackgroundImage(uploadPath: string): Promise<void> {
await this.sceneManager.setBackgroundImage(uploadPath)
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
if (this.previewManager.previewRenderer) {
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
}
this.forceRender()
}
@@ -335,9 +316,12 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.previewManager.setPreviewBackgroundColor(
this.sceneManager.currentBackgroundColor
)
if (
this.previewManager.previewRenderer &&
this.previewManager.previewCamera
) {
this.previewManager.updateBackgroundTexture(null)
}
this.forceRender()
}
@@ -364,6 +348,10 @@ class Load3d {
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
if (this.previewManager.showPreview) {
this.previewManager.syncWithMainCamera()
}
this.forceRender()
}

View File

@@ -42,6 +42,10 @@ class Load3dAnimation extends Load3d {
return
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
const delta = this.clock.getDelta()
this.animationManager.update(delta)
@@ -50,13 +54,12 @@ class Load3dAnimation extends Load3d {
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)

View File

@@ -4,8 +4,9 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
export class PreviewManager implements PreviewManagerInterface {
previewRenderer: THREE.WebGLRenderer | null = null
previewCamera: THREE.Camera
previewContainer: HTMLDivElement = null!
previewContainer: HTMLDivElement = {} as HTMLDivElement
showPreview: boolean = true
previewWidth: number = 120
@@ -16,6 +17,7 @@ export class PreviewManager implements PreviewManagerInterface {
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
// @ts-expect-error unused variable
private getRenderer: () => THREE.WebGLRenderer
private previewBackgroundScene: THREE.Scene
@@ -23,9 +25,6 @@ export class PreviewManager implements PreviewManagerInterface {
private previewBackgroundMesh: THREE.Mesh | null = null
private previewBackgroundTexture: THREE.Texture | null = null
private previewBackgroundColorMaterial: THREE.MeshBasicMaterial | null = null
private currentBackgroundColor: THREE.Color = new THREE.Color(0x282828)
constructor(
scene: THREE.Scene,
getActiveCamera: () => THREE.Camera,
@@ -46,24 +45,15 @@ export class PreviewManager implements PreviewManagerInterface {
this.previewBackgroundScene = backgroundScene.clone()
this.previewBackgroundCamera = backgroundCamera.clone()
this.initPreviewBackgroundScene()
}
private initPreviewBackgroundScene(): void {
const planeGeometry = new THREE.PlaneGeometry(2, 2)
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
color: this.currentBackgroundColor.clone(),
transparent: false,
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.previewBackgroundMesh = new THREE.Mesh(
planeGeometry,
this.previewBackgroundColorMaterial
)
this.previewBackgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundScene.add(this.previewBackgroundMesh)
}
@@ -71,23 +61,40 @@ export class PreviewManager implements PreviewManagerInterface {
init(): void {}
dispose(): void {
if (this.previewRenderer) {
this.previewRenderer.forceContextLoss()
const canvas = this.previewRenderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.previewRenderer.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.previewBackgroundColorMaterial) {
this.previewBackgroundColorMaterial.dispose()
}
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.geometry.dispose()
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
this.previewBackgroundMesh.material.dispose()
}
;(this.previewBackgroundMesh.material as THREE.Material).dispose()
}
}
createCapturePreview(container: Element | HTMLElement): void {
this.previewRenderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
preserveDrawingBuffer: true
})
this.previewRenderer.setSize(this.targetWidth, this.targetHeight)
this.previewRenderer.setClearColor(0x282828)
this.previewRenderer.autoClear = false
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewContainer = document.createElement('div')
this.previewContainer.style.cssText = `
position: absolute;
@@ -97,6 +104,7 @@ export class PreviewManager implements PreviewManagerInterface {
display: block;
transition: border-color 0.1s ease;
`
this.previewContainer.appendChild(this.previewRenderer.domElement)
const MIN_PREVIEW_WIDTH = 120
const MAX_PREVIEW_WIDTH = 240
@@ -123,6 +131,7 @@ export class PreviewManager implements PreviewManagerInterface {
}
this.updatePreviewSize()
this.updatePreviewRender()
})
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
@@ -150,54 +159,57 @@ export class PreviewManager implements PreviewManagerInterface {
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewContainer.style.width = `${this.previewWidth}px`
this.previewContainer.style.height = `${previewHeight}px`
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
}
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null {
if (!this.showPreview || !this.previewContainer) {
return null
syncWithMainCamera(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview) {
return
}
const renderer = this.getRenderer()
const canvas = renderer.domElement
this.previewCamera = this.getActiveCamera().clone()
const containerRect = this.previewContainer.getBoundingClientRect()
const canvasRect = canvas.getBoundingClientRect()
this.previewCamera.position.copy(this.getActiveCamera().position)
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
if (
containerRect.bottom < canvasRect.top ||
containerRect.top > canvasRect.bottom ||
containerRect.right < canvasRect.left ||
containerRect.left > canvasRect.right
) {
return null
const aspect = this.targetWidth / this.targetHeight
if (this.getActiveCamera() instanceof THREE.OrthographicCamera) {
const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera
const previewOrtho = this.previewCamera as THREE.OrthographicCamera
previewOrtho.zoom = activeOrtho.zoom
const frustumHeight =
(activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom
const frustumWidth = frustumHeight * aspect
previewOrtho.top = frustumHeight / 2
previewOrtho.left = -frustumWidth / 2
previewOrtho.right = frustumWidth / 2
previewOrtho.bottom = -frustumHeight / 2
previewOrtho.updateProjectionMatrix()
} else {
const activePerspective =
this.getActiveCamera() as THREE.PerspectiveCamera
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
}
const width = parseFloat(this.previewContainer.style.width)
const height = parseFloat(this.previewContainer.style.height)
this.previewCamera.lookAt(this.getControls().target)
const left = this.getRenderer().domElement.clientWidth - width
const bottom = 0
return { left, bottom, width, height }
this.updatePreviewRender()
}
renderPreview(): void {
const viewport = this.getPreviewViewport()
if (!viewport) return
const renderer = this.getRenderer()
const originalClearColor = renderer.getClearColor(new THREE.Color())
const originalClearAlpha = renderer.getClearAlpha()
updatePreviewRender(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
return
if (
!this.previewCamera ||
@@ -231,77 +243,45 @@ export class PreviewManager implements PreviewManagerInterface {
previewOrtho.updateProjectionMatrix()
} else {
const activePerspective =
;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect
;(this.previewCamera as THREE.PerspectiveCamera).fov = (
this.getActiveCamera() as THREE.PerspectiveCamera
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
).fov
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
}
this.previewCamera.lookAt(this.getControls().target)
renderer.setViewport(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
renderer.setScissor(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewRenderer.clear()
renderer.setClearColor(0x000000, 0)
renderer.clear()
if (this.previewBackgroundMesh && this.previewBackgroundTexture) {
const material = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.previewRenderer.toneMapping
const currentExposure = this.previewRenderer.toneMappingExposure
this.renderPreviewBackground(renderer)
this.previewRenderer.toneMapping = THREE.NoToneMapping
this.previewRenderer.render(
this.previewBackgroundScene,
this.previewBackgroundCamera
)
renderer.render(this.scene, this.previewCamera)
renderer.setClearColor(originalClearColor, originalClearAlpha)
}
private renderPreviewBackground(renderer: THREE.WebGLRenderer): void {
if (this.previewBackgroundMesh) {
const currentToneMapping = renderer.toneMapping
const currentExposure = renderer.toneMappingExposure
renderer.toneMapping = THREE.NoToneMapping
renderer.render(this.previewBackgroundScene, this.previewBackgroundCamera)
renderer.toneMapping = currentToneMapping
renderer.toneMappingExposure = currentExposure
}
}
setPreviewBackgroundColor(color: string | number | THREE.Color): void {
this.currentBackgroundColor.set(color)
if (!this.previewBackgroundMesh || !this.previewBackgroundColorMaterial) {
this.initPreviewBackgroundScene()
this.previewRenderer.toneMapping = currentToneMapping
this.previewRenderer.toneMappingExposure = currentExposure
}
}
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
this.previewBackgroundTexture = null
}
this.previewRenderer.render(this.scene, this.previewCamera)
}
togglePreview(showPreview: boolean): void {
this.showPreview = showPreview
if (this.previewContainer) {
if (this.previewRenderer) {
this.showPreview = showPreview
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
}
@@ -326,7 +306,7 @@ export class PreviewManager implements PreviewManagerInterface {
)
}
if (this.previewCamera) {
if (this.previewRenderer && this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height
this.previewCamera.updateProjectionMatrix()
@@ -342,45 +322,30 @@ export class PreviewManager implements PreviewManagerInterface {
handleResize(): void {
this.updatePreviewSize()
this.updatePreviewRender()
}
updateBackgroundTexture(texture: THREE.Texture | null): void {
if (texture) {
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
this.previewBackgroundTexture = texture
this.previewBackgroundTexture = texture
if (this.previewBackgroundMesh) {
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
if (texture && this.previewBackgroundMesh) {
const material2 = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
material2.map = texture
material2.needsUpdate = true
if (
this.previewBackgroundMesh.material instanceof THREE.Material &&
this.previewBackgroundMesh.material !==
this.previewBackgroundColorMaterial
) {
this.previewBackgroundMesh.material.dispose()
}
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundMesh.material = imageMaterial
this.previewBackgroundMesh.position.set(0, 0, 0)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
}
} else {
this.setPreviewBackgroundColor(this.currentBackgroundColor)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
}
}

View File

@@ -13,10 +13,6 @@ export class SceneManager implements SceneManagerInterface {
backgroundMesh: THREE.Mesh | null = null
backgroundTexture: THREE.Texture | null = null
backgroundColorMaterial: THREE.MeshBasicMaterial | null = null
currentBackgroundType: 'color' | 'image' = 'color'
currentBackgroundColor: string = '#282828'
private eventManager: EventManagerInterface
private renderer: THREE.WebGLRenderer
@@ -44,28 +40,17 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundScene = new THREE.Scene()
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
this.initBackgroundScene()
}
private initBackgroundScene(): void {
const planeGeometry = new THREE.PlaneGeometry(2, 2)
this.backgroundColorMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(this.currentBackgroundColor),
transparent: false,
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.backgroundMesh = new THREE.Mesh(
planeGeometry,
this.backgroundColorMaterial
)
this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.backgroundMesh.position.set(0, 0, 0)
this.backgroundScene.add(this.backgroundMesh)
this.renderer.setClearColor(0x000000, 0)
}
init(): void {}
@@ -75,15 +60,9 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundTexture.dispose()
}
if (this.backgroundColorMaterial) {
this.backgroundColorMaterial.dispose()
}
if (this.backgroundMesh) {
this.backgroundMesh.geometry.dispose()
if (this.backgroundMesh.material instanceof THREE.Material) {
this.backgroundMesh.material.dispose()
}
;(this.backgroundMesh.material as THREE.Material).dispose()
}
this.scene.clear()
@@ -98,39 +77,18 @@ export class SceneManager implements SceneManagerInterface {
}
setBackgroundColor(color: string): void {
this.currentBackgroundColor = color
this.currentBackgroundType = 'color'
if (!this.backgroundMesh || !this.backgroundColorMaterial) {
this.initBackgroundScene()
}
this.backgroundColorMaterial!.color.set(color)
this.backgroundColorMaterial!.map = null
this.backgroundColorMaterial!.transparent = false
this.backgroundColorMaterial!.needsUpdate = true
if (this.backgroundMesh) {
this.backgroundMesh.material = this.backgroundColorMaterial!
}
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
this.renderer.setClearColor(new THREE.Color(color))
this.eventManager.emitEvent('backgroundColorChange', color)
}
async setBackgroundImage(uploadPath: string): Promise<void> {
if (uploadPath === '') {
this.setBackgroundColor(this.currentBackgroundColor)
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
if (uploadPath === '') {
this.removeBackgroundImage()
return
}
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath)
)
@@ -152,31 +110,12 @@ export class SceneManager implements SceneManagerInterface {
texture.colorSpace = THREE.SRGBColorSpace
this.backgroundTexture = texture
this.currentBackgroundType = 'image'
if (!this.backgroundMesh) {
this.initBackgroundScene()
}
const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial
material.map = texture
material.needsUpdate = true
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
if (this.backgroundMesh) {
if (
this.backgroundMesh.material !== this.backgroundColorMaterial &&
this.backgroundMesh.material instanceof THREE.Material
) {
this.backgroundMesh.material.dispose()
}
this.backgroundMesh.material = imageMaterial
this.backgroundMesh.position.set(0, 0, 0)
}
this.backgroundMesh?.position.set(0, 0, 0)
this.updateBackgroundSize(
this.backgroundTexture,
@@ -190,12 +129,20 @@ export class SceneManager implements SceneManagerInterface {
} catch (error) {
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
console.error('Error loading background image:', error)
this.setBackgroundColor(this.currentBackgroundColor)
}
}
removeBackgroundImage(): void {
this.setBackgroundColor(this.currentBackgroundColor)
if (this.backgroundMesh) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
material.map = null
material.needsUpdate = true
}
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
}
@@ -225,11 +172,7 @@ export class SceneManager implements SceneManagerInterface {
}
handleResize(width: number, height: number): void {
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
@@ -240,25 +183,18 @@ export class SceneManager implements SceneManagerInterface {
}
renderBackground(): void {
if (this.backgroundMesh) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
getCurrentBackgroundInfo(): { type: 'color' | 'image'; value: string } {
return {
type: this.currentBackgroundType,
value:
this.currentBackgroundType === 'color'
? this.currentBackgroundColor
: ''
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
}
@@ -274,6 +210,8 @@ export class SceneManager implements SceneManagerInterface {
new THREE.Color()
)
const originalClearAlpha = this.renderer.getClearAlpha()
const originalToneMapping = this.renderer.toneMapping
const originalExposure = this.renderer.toneMappingExposure
const originalOutputColorSpace = this.renderer.outputColorSpace
this.renderer.setSize(width, height)
@@ -299,11 +237,7 @@ export class SceneManager implements SceneManagerInterface {
orthographicCamera.updateProjectionMatrix()
}
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
@@ -318,7 +252,19 @@ export class SceneManager implements SceneManagerInterface {
>()
this.renderer.clear()
this.renderBackground()
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = originalToneMapping
this.renderer.toneMappingExposure = originalExposure
}
}
this.renderer.render(this.scene, this.getActiveCamera())
const sceneData = this.renderer.domElement.toDataURL('image/png')

View File

@@ -100,23 +100,18 @@ export interface ViewHelperManagerInterface extends BaseManager {
}
export interface PreviewManagerInterface extends BaseManager {
previewRenderer: THREE.WebGLRenderer | null
previewCamera: THREE.Camera
previewContainer: HTMLDivElement
showPreview: boolean
previewWidth: number
createCapturePreview(container: Element | HTMLElement): void
updatePreviewSize(): void
updatePreviewRender(): void
togglePreview(showPreview: boolean): void
setTargetSize(width: number, height: number): void
handleResize(): void
updateBackgroundTexture(texture: THREE.Texture | null): void
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null
renderPreview(): void
}
export interface EventManagerInterface {

View File

@@ -5,7 +5,6 @@ import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
@@ -13,6 +12,8 @@ import { useToastStore } from '@/stores/toastStore'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
type FolderType = 'input' | 'output' | 'temp'
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
@@ -27,7 +28,7 @@ function splitFilePath(path: string): [string, string] {
function getResourceURL(
subfolder: string,
filename: string,
type: ResultItemType = 'input'
type: FolderType = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Fit view to selected nodes"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Move Selected Nodes Down"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Move Selected Nodes Left"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Move Selected Nodes Right"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Move Selected Nodes Up"
},
"Comfy_Canvas_ResetView": {
"label": "Reset View"
},
@@ -150,10 +138,10 @@
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Toggle the Custom Nodes Manager"
"label": "Custom Nodes Manager"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle the Custom Nodes Manager Progress Bar"
"label": "Toggle Progress Dialog"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"

View File

@@ -161,7 +161,6 @@
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
@@ -515,8 +514,7 @@
"3D": "3D",
"Audio": "Audio",
"Image API": "Image API",
"Video API": "Video API",
"All": "All Templates"
"Video API": "Video API"
},
"templateDescription": {
"Basics": {
@@ -795,10 +793,6 @@
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Fit view to selected nodes": "Fit view to selected nodes",
"Move Selected Nodes Down": "Move Selected Nodes Down",
"Move Selected Nodes Left": "Move Selected Nodes Left",
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
@@ -830,8 +824,8 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Custom Nodes Manager": "Custom Nodes Manager",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"New": "New",
"Clipspace": "Clipspace",
@@ -1196,11 +1190,6 @@
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
},
"loadWorkflowWarning": {
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajustar vista a los nodos seleccionados"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Mover nodos seleccionados hacia abajo"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Mover nodos seleccionados a la izquierda"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Mover nodos seleccionados a la derecha"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Mover nodos seleccionados hacia arriba"
},
"Comfy_Canvas_ResetView": {
"label": "Restablecer vista"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "Subir imagen de fondo",
"uploadTexture": "Subir textura"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
},
"maintenance": {
"None": "Ninguno",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
"installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización",
@@ -701,6 +695,7 @@
"Contact Support": "Contactar soporte",
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes Manager": "Gestor de nodos personalizados",
"Delete Selected Items": "Eliminar elementos seleccionados",
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
@@ -715,10 +710,6 @@
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
"New": "Nuevo",
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
@@ -754,13 +745,12 @@
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de biblioteca de modelos",
"Toggle Node Library Sidebar": "Alternar barra lateral de biblioteca de nodos",
"Toggle Progress Dialog": "Alternar diálogo de progreso",
"Toggle Queue Sidebar": "Alternar barra lateral de cola",
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle Workflows Sidebar": "Alternar barra lateral de flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
"Workflow": "Flujo de trabajo",
@@ -1149,7 +1139,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Todas las plantillas",
"Area Composition": "Composición de Área",
"Audio": "Audio",
"Basics": "Básicos",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajuster la vue aux nœuds sélectionnés"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Déplacer les nœuds sélectionnés vers le bas"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Déplacer les nœuds sélectionnés vers la gauche"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Déplacer les nœuds sélectionnés vers la droite"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Déplacer les nœuds sélectionnés vers le haut"
},
"Comfy_Canvas_ResetView": {
"label": "Réinitialiser la vue"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "Télécharger l'image de fond",
"uploadTexture": "Télécharger Texture"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
"outdatedVersion": "Certains nœuds nécessitent une version plus récente de ComfyUI (actuelle : {version}). Veuillez mettre à jour pour utiliser tous les nœuds.",
"outdatedVersionGeneric": "Certains nœuds nécessitent une version plus récente de ComfyUI. Veuillez mettre à jour pour utiliser tous les nœuds."
},
"maintenance": {
"None": "Aucun",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "Dans le flux de travail",
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
"installAllMissingNodes": "Installer tous les nœuds manquants",
"installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour",
@@ -701,6 +695,7 @@
"Contact Support": "Contacter le support",
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -715,10 +710,6 @@
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
"New": "Nouveau",
"Next Opened Workflow": "Prochain flux de travail ouvert",
@@ -754,13 +745,12 @@
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
"Toggle Progress Dialog": "Basculer la boîte de dialogue de progression",
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle Workflows Sidebar": "Basculer la barre latérale des flux de travail",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
"Workflow": "Flux de travail",
@@ -1149,7 +1139,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Tous les modèles",
"Area Composition": "Composition de zone",
"Audio": "Audio",
"Basics": "Basiques",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "選択したノードにビューを合わせる"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "選択したノードを下に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "選択したノードを左に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "選択したノードを右に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "選択したノードを上に移動"
},
"Comfy_Canvas_ResetView": {
"label": "ビューをリセット"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "背景画像をアップロード",
"uploadTexture": "テクスチャをアップロード"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
"outdatedVersion": "一部のードはより新しいバージョンのComfyUIが必要です現在のバージョン{version})。すべてのノードを使用するにはアップデートしてください。",
"outdatedVersionGeneric": "一部のードはより新しいバージョンのComfyUIが必要です。すべてのードを使用するにはアップデートしてください。"
},
"maintenance": {
"None": "なし",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "ワークフロー内",
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
"installAllMissingNodes": "すべての不足しているノードをインストール",
"installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー",
"lastUpdated": "最終更新日",
@@ -701,6 +695,7 @@
"Contact Support": "サポートに連絡",
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -715,10 +710,6 @@
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
"Move Selected Nodes Down": "選択したノードを下へ移動",
"Move Selected Nodes Left": "選択したノードを左へ移動",
"Move Selected Nodes Right": "選択したノードを右へ移動",
"Move Selected Nodes Up": "選択したノードを上へ移動",
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
"New": "新規",
"Next Opened Workflow": "次に開いたワークフロー",
@@ -754,13 +745,12 @@
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
"Toggle Progress Dialog": "進行状況ダイアログの切り替え",
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
"Workflow": "ワークフロー",
@@ -1149,7 +1139,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "すべてのテンプレート",
"Area Composition": "エリア構成",
"Audio": "オーディオ",
"Basics": "基本",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "선택한 노드에 뷰 맞추기"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "선택한 노드 아래로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "선택한 노드 왼쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "선택한 노드 오른쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "선택한 노드 위로 이동"
},
"Comfy_Canvas_ResetView": {
"label": "뷰 재설정"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "배경 이미지 업로드",
"uploadTexture": "텍스처 업로드"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
},
"maintenance": {
"None": "없음",
"OK": "확인",
@@ -591,7 +586,6 @@
},
"inWorkflow": "워크플로우 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
"installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
@@ -701,6 +695,7 @@
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -715,10 +710,6 @@
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
"Move Selected Nodes Up": "선택한 노드 위로 이동",
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
@@ -754,13 +745,12 @@
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
"Workflow": "워크플로",
@@ -1149,7 +1139,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "모든 템플릿",
"Area Composition": "영역 구성",
"Audio": "오디오",
"Basics": "기본",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Подогнать вид к выбранным нодам"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Переместить выбранные узлы вниз"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Переместить выбранные узлы влево"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Переместить выбранные узлы вправо"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Переместить выбранные узлы вверх"
},
"Comfy_Canvas_ResetView": {
"label": "Сбросить вид"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "Загрузить фоновое изображение",
"uploadTexture": "Загрузить текстуру"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
},
"maintenance": {
"None": "Нет",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "В рабочем процессе",
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
"installAllMissingNodes": "Установить все отсутствующие узлы",
"installSelected": "Установить выбранное",
"installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление",
@@ -701,6 +695,7 @@
"Contact Support": "Связаться с поддержкой",
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -715,10 +710,6 @@
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
"New": "Новый",
"Next Opened Workflow": "Следующий открытый рабочий процесс",
@@ -754,13 +745,12 @@
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Переключение боковой панели библиотеки моделей",
"Toggle Node Library Sidebar": "Переключение боковой панели библиотеки нод",
"Toggle Progress Dialog": "Переключить диалоговое окно прогресса",
"Toggle Queue Sidebar": "Переключение боковой панели очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle Workflows Sidebar": "Переключение боковой панели рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
"Workflow": "Рабочий процесс",
@@ -1149,7 +1139,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Все шаблоны",
"Area Composition": "Композиция области",
"Audio": "Аудио",
"Basics": "Основы",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "适应视图到选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "下移选中的节点"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "向左移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "向右移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "上移选中的节点"
},
"Comfy_Canvas_ResetView": {
"label": "重置视图"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "上传背景图片",
"uploadTexture": "上传纹理"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "需要 ComfyUI {version}",
"outdatedVersion": "某些节点需要更高版本的 ComfyUI当前版本{version})。请更新以使用所有节点。",
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
},
"maintenance": {
"None": "无",
"OK": "确定",
@@ -591,7 +586,6 @@
},
"inWorkflow": "在工作流中",
"infoPanelEmpty": "点击一个项目查看信息",
"installAllMissingNodes": "安装所有缺失节点",
"installSelected": "安装选定",
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
@@ -701,6 +695,7 @@
"Contact Support": "联系支持",
"Convert Selection to Subgraph": "将选中内容转换为子图",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
@@ -715,10 +710,6 @@
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Manage group nodes": "管理组节点",
"Move Selected Nodes Down": "下移所选节点",
"Move Selected Nodes Left": "左移所选节点",
"Move Selected Nodes Right": "右移所选节点",
"Move Selected Nodes Up": "上移所选节点",
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
@@ -754,13 +745,12 @@
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切换模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Progress Dialog": "切换进度对话框",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Workflow": "工作流",
@@ -1044,9 +1034,9 @@
"Extension": "扩展",
"General": "常规",
"Graph": "画面",
"Group": "组",
"Group": "组节点",
"Keybinding": "快捷键",
"Light": "光照",
"Light": "浅色",
"Link": "连线",
"LinkRelease": "释放链接",
"LiteGraph": "画面",
@@ -1149,7 +1139,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "所有模板",
"Area Composition": "区域组成",
"Audio": "音频",
"Basics": "基础",

View File

@@ -11,13 +11,10 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional()
type: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z

View File

@@ -46,26 +46,22 @@ export function transformNodeDefV1ToV2(
const outputs: OutputSpecV2[] = []
if (nodeDefV1.output) {
if (Array.isArray(nodeDefV1.output)) {
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
outputs.push(outputSpec)
})
} else {
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
}
outputs.push(outputSpec)
})
}
// Create the V2 node definition

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