Compare commits
28 Commits
v1.22.0-su
...
vue-widget
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e488b2abce | ||
|
|
20e4427602 | ||
|
|
33e99da325 | ||
|
|
a7461c49c7 | ||
|
|
102590c2c2 | ||
|
|
928dfc6b8e | ||
|
|
593ac576da | ||
|
|
0858356dcf | ||
|
|
471018a962 | ||
|
|
344c6f6244 | ||
|
|
b2918a4cf6 | ||
|
|
6d4eafb07a | ||
|
|
97edaade63 | ||
|
|
83af274339 | ||
|
|
f251af25cc | ||
|
|
e2024c1e79 | ||
|
|
e8236e1a85 | ||
|
|
79a63de70e | ||
|
|
3eee7cde0b | ||
|
|
6bbe46009b | ||
|
|
1ca71caf45 | ||
|
|
65289b1927 | ||
|
|
9e2180dcd8 | ||
|
|
defea56ba5 | ||
|
|
e6bca95a5f | ||
|
|
841e3f743a | ||
|
|
73be826956 | ||
|
|
398dc6d8a6 |
53
.claude/commands/create-widget.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Create a Vue Widget for ComfyUI
|
||||
|
||||
Your task is to create a new Vue widget for ComfyUI based on the widget specification: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Follow the comprehensive guide in `vue-widget-conversion/vue-widget-guide.md` to create the widget. This guide contains step-by-step instructions, examples from actual PRs, and best practices.
|
||||
|
||||
### Key Steps to Follow:
|
||||
|
||||
1. **Understand the Widget Type**
|
||||
- Analyze what type of widget is needed: $ARGUMENTS
|
||||
- Identify the data type (string, number, array, object, etc.)
|
||||
- Determine if it needs special behaviors (execution state awareness, dynamic management, etc.)
|
||||
|
||||
2. **Component Creation**
|
||||
- Create Vue component in `src/components/graph/widgets/`
|
||||
- REQUIRED: Use PrimeVue components (reference `vue-widget-conversion/primevue-components.md`)
|
||||
- Use Composition API with `<script setup>`
|
||||
- Implement proper v-model binding with `defineModel`
|
||||
|
||||
3. **Composable Pattern**
|
||||
- Always create widget constructor composable in `src/composables/widgets/`
|
||||
- Only create node-level composable in `src/composables/node/` if the widget needs dynamic management
|
||||
- Follow the dual composable pattern explained in the guide
|
||||
|
||||
4. **Registration**
|
||||
- Register in `src/scripts/widgets.ts`
|
||||
- Use appropriate widget type name
|
||||
|
||||
5. **Testing**
|
||||
- Create unit tests for composables
|
||||
- Test with actual nodes that use the widget
|
||||
|
||||
### Important Requirements:
|
||||
|
||||
- **Always use PrimeVue components** - Check `vue-widget-conversion/primevue-components.md` for available components
|
||||
- Use TypeScript with proper types
|
||||
- Follow Vue 3 Composition API patterns
|
||||
- Use Tailwind CSS for styling (no custom CSS unless absolutely necessary)
|
||||
- Implement proper error handling and validation
|
||||
- Consider performance (use v-show vs v-if appropriately)
|
||||
|
||||
### Before Starting:
|
||||
|
||||
1. First read through the entire guide at `vue-widget-conversion/vue-widget-guide.md`
|
||||
2. Check existing widget implementations for similar patterns
|
||||
3. Identify which PrimeVue component(s) best fit the widget requirements
|
||||
|
||||
### Widget Specification to Implement:
|
||||
$ARGUMENTS
|
||||
|
||||
Begin by analyzing the widget requirements and proposing an implementation plan based on the guide.
|
||||
59
.cursorrules
@@ -1,26 +1,25 @@
|
||||
// Vue 3 Composition API .cursorrules
|
||||
# Vue 3 Composition API Project Rules
|
||||
|
||||
// Vue 3 Composition API best practices
|
||||
const vue3CompositionApiBestPractices = [
|
||||
"Use setup() function for component logic",
|
||||
"Utilize ref and reactive for reactive state",
|
||||
"Implement computed properties with computed()",
|
||||
"Use watch and watchEffect for side effects",
|
||||
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
|
||||
"Utilize provide/inject for dependency injection",
|
||||
"Use vue 3.5 style of default prop declaration. Example:
|
||||
## Vue 3 Composition API Best Practices
|
||||
- Use setup() function for component logic
|
||||
- Utilize ref and reactive for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Use vue 3.5 style of default prop declaration. Example:
|
||||
|
||||
```typescript
|
||||
const { nodes, showTotal = true } = defineProps<{
|
||||
nodes: ApiNodeCost[]
|
||||
showTotal?: boolean
|
||||
}>()
|
||||
```
|
||||
|
||||
",
|
||||
"Organize vue component in <template> <script> <style> order",
|
||||
]
|
||||
- Organize vue component in <template> <script> <style> order
|
||||
|
||||
// Folder structure
|
||||
const folderStructure = `
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
components/
|
||||
constants/
|
||||
@@ -30,16 +29,25 @@ src/
|
||||
services/
|
||||
App.vue
|
||||
main.ts
|
||||
`;
|
||||
```
|
||||
|
||||
// Tailwind CSS best practices
|
||||
const tailwindCssBestPractices = [
|
||||
"Use Tailwind CSS for styling",
|
||||
"Implement responsive design with Tailwind CSS",
|
||||
]
|
||||
## Styling Guidelines
|
||||
- Use Tailwind CSS for styling
|
||||
- Implement responsive design with Tailwind CSS
|
||||
|
||||
// Additional instructions
|
||||
const additionalInstructions = `
|
||||
## PrimeVue Component Guidelines
|
||||
DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
- Dropdown → Use Select (import from 'primevue/select')
|
||||
- OverlayPanel → Use Popover (import from 'primevue/popover')
|
||||
- Calendar → Use DatePicker (import from 'primevue/datepicker')
|
||||
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
|
||||
- Sidebar → Use Drawer (import from 'primevue/drawer')
|
||||
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
|
||||
- TabMenu → Use Tabs without panels
|
||||
- Steps → Use Stepper without panels
|
||||
- InlineMessage → Use Message component
|
||||
|
||||
## Development Guidelines
|
||||
1. Leverage VueUse functions for performance-enhancing styles
|
||||
2. Use lodash for utility functions
|
||||
3. Use TypeScript for type safety
|
||||
@@ -49,6 +57,5 @@ const additionalInstructions = `
|
||||
7. Implement proper error handling
|
||||
8. Follow Vue 3 style guide and naming conventions
|
||||
9. Use Vite for fast development and building
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation
|
||||
entries in src/locales/en/main.json.
|
||||
`;
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Never use deprecated PrimeVue components listed above
|
||||
|
||||
@@ -29,3 +29,7 @@ DISABLE_TEMPLATES_PROXY=false
|
||||
# If playwright tests are being run via vite dev server, Vue plugins will
|
||||
# invalidate screenshots. When `true`, vite plugins will not be loaded.
|
||||
DISABLE_VUE_PLUGINS=false
|
||||
|
||||
# Algolia credentials required for developing with the new custom node manager.
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
2
.github/workflows/dev-release.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
2
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -136,7 +136,7 @@ jobs:
|
||||
git commit -m "Update locales"
|
||||
|
||||
- name: Install SSH key For PUSH
|
||||
uses: shimataro/ssh-key-action@v2
|
||||
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
|
||||
with:
|
||||
# PR private key from action server
|
||||
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
|
||||
|
||||
2
.github/workflows/i18n-node-defs.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: "Update locales for node definitions"
|
||||
|
||||
4
.github/workflows/release.yaml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
name: dist-files
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
2
.github/workflows/update-electron-types.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'
|
||||
|
||||
2
.github/workflows/update-litegraph.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'
|
||||
|
||||
92
.github/workflows/update-manager-types.yaml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
name: Update ComfyUI-Manager API Types
|
||||
|
||||
on:
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-manager-types:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Checkout ComfyUI-Manager repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI-Manager
|
||||
path: ComfyUI-Manager
|
||||
clean: true
|
||||
|
||||
- name: Get Manager commit information
|
||||
id: manager-info
|
||||
run: |
|
||||
cd ComfyUI-Manager
|
||||
MANAGER_COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "commit=${MANAGER_COMMIT}" >> $GITHUB_OUTPUT
|
||||
cd ..
|
||||
|
||||
- name: Generate Manager API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
|
||||
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
if [ ! -f ./src/types/generatedManagerTypes.ts ]; then
|
||||
echo "Error: Types file was not generated."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if file is not empty
|
||||
if [ ! -s ./src/types/generatedManagerTypes.ts ]; then
|
||||
echo "Error: Generated types file is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if [[ -z $(git status --porcelain ./src/types/generatedManagerTypes.ts) ]]; then
|
||||
echo "No changes to ComfyUI-Manager API types detected."
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected in ComfyUI-Manager API types."
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
title: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
body: |
|
||||
## Automated API Type Update
|
||||
|
||||
This PR updates the ComfyUI-Manager API types from the latest ComfyUI-Manager OpenAPI specification.
|
||||
|
||||
- Manager commit: ${{ steps.manager-info.outputs.commit }}
|
||||
- Generated on: ${{ github.event.repository.updated_at }}
|
||||
|
||||
These types are automatically generated using openapi-typescript.
|
||||
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
|
||||
base: main
|
||||
labels: Manager
|
||||
delete-branch: true
|
||||
add-paths: |
|
||||
src/types/generatedManagerTypes.ts
|
||||
2
.github/workflows/update-registry-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
|
||||
|
||||
2
.github/workflows/version-bump.yaml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'
|
||||
|
||||
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@executeautomation/playwright-mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
CLAUDE.md
@@ -36,3 +36,13 @@
|
||||
- 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
|
||||
|
||||
62
README.md
@@ -609,6 +609,68 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d
|
||||
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Recommended MCP Integrations
|
||||
|
||||
#### Playwright Browser Automation
|
||||
|
||||
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
|
||||
|
||||
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
|
||||
|
||||
##### Setup for Claude Code
|
||||
|
||||
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
|
||||
|
||||
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
|
||||
|
||||
|
||||
```
|
||||
> navigate to localhost:5173 and take a screenshot.
|
||||
tell me the items in the menubar
|
||||
|
||||
● I'll navigate to localhost:5173 and take a
|
||||
screenshot to see the menubar items.
|
||||
|
||||
● playwright:playwright_navigate (MCP)(url: …
|
||||
"http://local
|
||||
host:5173")
|
||||
⎿ Navigated to http://localhost:5173
|
||||
|
||||
● playwright:playwright_screenshot (MCP)(name: "lo…
|
||||
alhost_5173
|
||||
_menubar")
|
||||
|
||||
⎿ Screenshot saved to: ../../../Downloads/localho
|
||||
st_5173_menubar-2025-05-30T17-23-00-942Z.png
|
||||
⎿ Screenshot also stored in memory with name:
|
||||
'localhost_5173_menubar'
|
||||
|
||||
● Read(../../../Downloads/localhost_5173_menubar-2…
|
||||
25-05-30T17-23-00-942Z.png)
|
||||
⎿ Read image (94.3KB)
|
||||
|
||||
● Based on the screenshot, I can see the menubar at
|
||||
the top of the ComfyUI interface contains the
|
||||
following items:
|
||||
|
||||
Main Menubar Items:
|
||||
- Workflow - Menu for workflow-related actions
|
||||
- Edit - Menu for editing operations
|
||||
- Help - Menu for help and documentation
|
||||
|
||||
Additional UI Elements:
|
||||
- ComfyUI Logo - On the far left
|
||||
- Workflow Tab - Shows "Unsaved Workflow" with a
|
||||
dropdown and close button
|
||||
- Layout Controls - On the far right (grid view
|
||||
and hamburger menu icons)
|
||||
|
||||
The interface shows a typical ComfyUI workflow
|
||||
graph with nodes like "Load Checkpoint", "CLIP
|
||||
Text Encode (Prompt)", "KSampler", and "Empty
|
||||
Latent Image" connected with colored cables.
|
||||
```
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `npm i` to install all dependencies
|
||||
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 78 KiB |
89
copy-widget-resources.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to copy vue-widget-conversion folder and .claude/commands/create-widget.md
|
||||
# to another local copy of the same repository
|
||||
|
||||
# Check if destination directory was provided
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <destination-repo-path>"
|
||||
echo "Example: $0 /home/c_byrne/projects/comfyui-frontend-testing/ComfyUI_frontend-clone-8"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the destination directory from first argument
|
||||
DEST_DIR="$1"
|
||||
|
||||
# Source files/directories (relative to script location)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_WIDGET_DIR="$SCRIPT_DIR/vue-widget-conversion"
|
||||
SOURCE_COMMAND_FILE="$SCRIPT_DIR/.claude/commands/create-widget.md"
|
||||
|
||||
# Destination paths
|
||||
DEST_WIDGET_DIR="$DEST_DIR/vue-widget-conversion"
|
||||
DEST_COMMAND_DIR="$DEST_DIR/.claude/commands"
|
||||
DEST_COMMAND_FILE="$DEST_COMMAND_DIR/create-widget.md"
|
||||
|
||||
# Check if destination directory exists
|
||||
if [ ! -d "$DEST_DIR" ]; then
|
||||
echo "Error: Destination directory does not exist: $DEST_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source vue-widget-conversion directory exists
|
||||
if [ ! -d "$SOURCE_WIDGET_DIR" ]; then
|
||||
echo "Error: Source vue-widget-conversion directory not found: $SOURCE_WIDGET_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source command file exists
|
||||
if [ ! -f "$SOURCE_COMMAND_FILE" ]; then
|
||||
echo "Error: Source command file not found: $SOURCE_COMMAND_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Copying widget resources to: $DEST_DIR"
|
||||
|
||||
# Copy vue-widget-conversion directory
|
||||
echo "Copying vue-widget-conversion directory..."
|
||||
if [ -d "$DEST_WIDGET_DIR" ]; then
|
||||
echo " Warning: Destination vue-widget-conversion already exists. Overwriting..."
|
||||
rm -rf "$DEST_WIDGET_DIR"
|
||||
fi
|
||||
cp -r "$SOURCE_WIDGET_DIR" "$DEST_WIDGET_DIR"
|
||||
echo " ✓ Copied vue-widget-conversion directory"
|
||||
|
||||
# Create .claude/commands directory if it doesn't exist
|
||||
echo "Creating .claude/commands directory structure..."
|
||||
mkdir -p "$DEST_COMMAND_DIR"
|
||||
echo " ✓ Created .claude/commands directory"
|
||||
|
||||
# Copy create-widget.md command
|
||||
echo "Copying create-widget.md command..."
|
||||
cp "$SOURCE_COMMAND_FILE" "$DEST_COMMAND_FILE"
|
||||
echo " ✓ Copied create-widget.md command"
|
||||
|
||||
# Verify the copy was successful
|
||||
echo ""
|
||||
echo "Verification:"
|
||||
if [ -d "$DEST_WIDGET_DIR" ] && [ -f "$DEST_WIDGET_DIR/vue-widget-guide.md" ] && [ -f "$DEST_WIDGET_DIR/primevue-components.md" ]; then
|
||||
echo " ✓ vue-widget-conversion directory copied successfully"
|
||||
echo " - vue-widget-guide.md exists"
|
||||
echo " - primevue-components.md exists"
|
||||
if [ -f "$DEST_WIDGET_DIR/primevue-components.json" ]; then
|
||||
echo " - primevue-components.json exists"
|
||||
fi
|
||||
else
|
||||
echo " ✗ Error: vue-widget-conversion directory copy may have failed"
|
||||
fi
|
||||
|
||||
if [ -f "$DEST_COMMAND_FILE" ]; then
|
||||
echo " ✓ create-widget.md command copied successfully"
|
||||
else
|
||||
echo " ✗ Error: create-widget.md command copy may have failed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Copy complete! Widget resources are now available in: $DEST_DIR"
|
||||
echo ""
|
||||
echo "You can now use the widget creation command in the destination repo:"
|
||||
echo " /project:create-widget <widget specification>"
|
||||
1856
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.22.0-sub.6",
|
||||
"version": "1.22.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.5",
|
||||
"@iconify/json": "^2.2.245",
|
||||
"@lobehub/i18n-cli": "^1.20.0",
|
||||
"@pinia/testing": "^0.1.5",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
@@ -75,7 +76,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.12",
|
||||
"@comfyorg/litegraph": "^0.15.15",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<div
|
||||
v-if="workflowStore.isSubgraphActive"
|
||||
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
|
||||
>
|
||||
<Breadcrumb
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
@@ -11,30 +14,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
|
||||
const items = computed(() => {
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
if (!workflowStore.subgraphNamePath.length) return []
|
||||
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
|
||||
label: name,
|
||||
command: async () => {
|
||||
const workflow = workflowStore.getWorkflowByPath(name)
|
||||
if (workflow) await workflowService.openWorkflow(workflow)
|
||||
}
|
||||
}))
|
||||
})
|
||||
@@ -42,7 +43,7 @@ const items = computed(() => {
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
command: () => {
|
||||
command: async () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -54,17 +55,14 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-for="(item, index) in dialogStore.dialogStack"
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:auto-z-index="false"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -35,25 +33,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { usePrimeVue } from '@primevue/core'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const primevue = usePrimeVue()
|
||||
|
||||
const baseZIndex = computed(() => {
|
||||
return primevue?.config?.zIndex?.modal ?? 1100
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const mask = document.createElement('div')
|
||||
ZIndex.set('model', mask, baseZIndex.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -408,19 +408,30 @@ const handleGridContainerClick = (event: MouseEvent) => {
|
||||
|
||||
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
|
||||
|
||||
// Track the last pack ID for which we've fetched full registry data
|
||||
const lastFetchedPackId = ref<string | null>(null)
|
||||
|
||||
// Whenever a single pack is selected, fetch its full info once
|
||||
whenever(selectedNodePack, async () => {
|
||||
// Cancel any in-flight requests from previously selected node pack
|
||||
getPackById.cancel()
|
||||
|
||||
if (!selectedNodePack.value?.id) return
|
||||
|
||||
// If only a single node pack is selected, fetch full node pack info from registry
|
||||
const pack = selectedNodePack.value
|
||||
if (!pack?.id) return
|
||||
if (hasMultipleSelections.value) return
|
||||
const data = await getPackById.call(selectedNodePack.value.id)
|
||||
|
||||
if (data?.id === selectedNodePack.value?.id) {
|
||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
||||
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
|
||||
// Only fetch if we haven't already for this pack
|
||||
if (lastFetchedPackId.value === pack.id) return
|
||||
const data = await getPackById.call(pack.id)
|
||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
||||
if (data?.id === pack.id) {
|
||||
lastFetchedPackId.value = pack.id
|
||||
const mergedPack = merge({}, pack, data)
|
||||
selectedNodePacks.value = [mergedPack]
|
||||
// Replace pack in displayPacks so that children receive a fresh prop reference
|
||||
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
||||
if (idx !== -1) {
|
||||
displayPacks.value.splice(idx, 1, mergedPack)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
class="object-cover"
|
||||
:style="{ width: cssWidth, height: cssHeight }"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
width = '100%',
|
||||
height = '12rem'
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
const shouldShowFallback = computed(
|
||||
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
|
||||
)
|
||||
const imgSrc = computed(() =>
|
||||
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
|
||||
)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
</script>
|
||||
@@ -7,19 +7,15 @@
|
||||
}"
|
||||
:pt="{
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl' },
|
||||
title: {
|
||||
class:
|
||||
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
|
||||
},
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: { class: 'p-0 m-0' }
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
<PackCardHeader :node-pack="nodePack" />
|
||||
<PackBanner :node-pack="nodePack" />
|
||||
</template>
|
||||
<template #content>
|
||||
<ContentDivider />
|
||||
<template v-if="isInstalling">
|
||||
<div
|
||||
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
|
||||
@@ -34,46 +30,63 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
|
||||
class="self-stretch inline-flex flex-col justify-start items-start"
|
||||
>
|
||||
<PackIcon :node-pack="nodePack" />
|
||||
<div
|
||||
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
|
||||
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-center items-center gap-2.5"
|
||||
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
|
||||
>
|
||||
<span
|
||||
class="text-base font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div class="text-center justify-center font-medium leading-3">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
class="self-stretch inline-flex justify-start items-center gap-1"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="text-center justify-center font-medium leading-3"
|
||||
>
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
</div>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,11 +105,12 @@ import { whenever } from '@vueuse/core'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
|
||||
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
|
||||
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { IsInstallingKey } from '@/types/comfyManagerTypes'
|
||||
@@ -107,6 +121,8 @@ const { nodePack, isSelected = false } = defineProps<{
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
@@ -122,4 +138,19 @@ whenever(isInstalled, () => (isInstalling.value = false))
|
||||
|
||||
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
|
||||
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
|
||||
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
|
||||
const formattedLatestVersionDate = computed(() => {
|
||||
if (!nodePack.latest_version?.createdAt) return null
|
||||
|
||||
return d(new Date(nodePack.latest_version.createdAt), {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
|
||||
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 cursor-pointer">
|
||||
<span v-if="publisherName" class="max-w-40 truncate">
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="nodePack.latest_version?.createdAt"
|
||||
class="flex items-center gap-2 truncate"
|
||||
>
|
||||
{{ $t('g.updated') }}
|
||||
{{
|
||||
$d(new Date(nodePack.latest_version.createdAt), {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
}}
|
||||
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
<span>{{ formattedDownloads }}</span>
|
||||
</div>
|
||||
<PackInstallButton :node-packs="[nodePack]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
const { n } = useI18n()
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
const formattedDownloads = computed(() =>
|
||||
nodePack.downloads ? n(nodePack.downloads) : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -54,4 +54,21 @@ describe('SettingItem', () => {
|
||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles tooltips with @ symbols without errors', () => {
|
||||
const wrapper = mountComponent({
|
||||
setting: {
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
}
|
||||
})
|
||||
|
||||
// Should not throw an error and tooltip should be preserved as-is
|
||||
expect(wrapper.vm.formItem.tooltip).toBe(
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { SettingOption, SettingParams } from '@/types/settingTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
@@ -64,7 +65,7 @@ const formItem = computed(() => {
|
||||
...props.setting,
|
||||
name: t(`settings.${normalizedId}.name`, props.setting.name),
|
||||
tooltip: props.setting.tooltip
|
||||
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
|
||||
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
|
||||
: undefined,
|
||||
options: props.setting.options
|
||||
? translateOptions(props.setting.options)
|
||||
|
||||
@@ -21,14 +21,16 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
const widgetStates = computed(() =>
|
||||
Array.from(domWidgetStore.widgetStates.values())
|
||||
)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
for (const widgetState of widgetStates.value) {
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
|
||||
@@ -12,12 +12,10 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
@@ -41,11 +39,12 @@
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
@@ -85,7 +84,6 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -194,10 +192,10 @@ watch(
|
||||
// Update the progress of the executing node
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
executionStore.executingNodeId,
|
||||
executionStore.executingNodeProgress
|
||||
] satisfies [NodeId | null, number | null],
|
||||
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
|
||||
NodeId | null,
|
||||
number | null
|
||||
],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
@@ -336,16 +334,6 @@ onMounted(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshButton />
|
||||
<ExtensionCommandButton
|
||||
@@ -29,7 +28,6 @@ import { computed } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-box"
|
||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
canvasStore.nodeSelected
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isDeletable"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
@@ -14,17 +13,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isDeletable = computed(() =>
|
||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -25,9 +25,8 @@ const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isSingleImageNode = computed(() => {
|
||||
const { selectedItems } = canvasStore
|
||||
const item = selectedItems[0]
|
||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
||||
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
|
||||
return nodes.length === 1 && nodes.some(isImageNode)
|
||||
})
|
||||
|
||||
const openMaskEditor = () => {
|
||||
|
||||
204
src/components/graph/widgets/BadgedNumberInput.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="badged-number-input relative w-full">
|
||||
<InputGroup class="w-full rounded-lg border-none px-0.5">
|
||||
<!-- State badge prefix -->
|
||||
<InputGroupAddon
|
||||
v-if="badgeState !== 'normal'"
|
||||
class="rounded-l-lg bg-[#222222] border-[#222222] shadow-none border-r-[#A0A1A2] rounded-r-none"
|
||||
>
|
||||
<i
|
||||
:class="badgeIcon + ' text-xs'"
|
||||
:title="badgeTooltip"
|
||||
:style="{ color: badgeColor }"
|
||||
></i>
|
||||
</InputGroupAddon>
|
||||
|
||||
<!-- Number input for non-slider mode -->
|
||||
<InputNumber
|
||||
v-if="!isSliderMode"
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'bg-[#222222] text-xs shadow-none rounded-none !border-0'
|
||||
}
|
||||
},
|
||||
incrementButton: {
|
||||
class: 'text-xs shadow-none bg-[#222222] rounded-l-none !border-0'
|
||||
},
|
||||
decrementButton: {
|
||||
class: {
|
||||
'text-xs shadow-none bg-[#222222] rounded-r-none !border-0':
|
||||
badgeState === 'normal',
|
||||
'text-xs shadow-none bg-[#222222] rounded-none !border-0':
|
||||
badgeState !== 'normal'
|
||||
}
|
||||
}
|
||||
}"
|
||||
class="flex-1 rounded-none"
|
||||
show-buttons
|
||||
button-layout="horizontal"
|
||||
:increment-button-icon="'pi pi-plus'"
|
||||
:decrement-button-icon="'pi pi-minus'"
|
||||
/>
|
||||
|
||||
<!-- Slider mode -->
|
||||
<div
|
||||
v-else
|
||||
:class="{
|
||||
'rounded-r-lg': badgeState !== 'normal',
|
||||
'rounded-lg': badgeState === 'normal'
|
||||
}"
|
||||
class="flex-1 flex items-center gap-2 px-1 bg-surface-0 border border-surface-300"
|
||||
>
|
||||
<Slider
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
class="flex-1"
|
||||
/>
|
||||
<InputNumber
|
||||
v-model="numericValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
class="w-16 rounded-md"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'bg-[#222222] text-xs shadow-none border-[#222222]'
|
||||
}
|
||||
}
|
||||
}"
|
||||
:show-buttons="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputGroup from 'primevue/inputgroup'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const {
|
||||
widget,
|
||||
badgeState = 'normal',
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
badgeState?: BadgeState
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
// Convert string model value to/from number for the InputNumber component
|
||||
const numericValue = computed({
|
||||
get: () => parseFloat(modelValue.value) || 0,
|
||||
set: (value: number) => {
|
||||
modelValue.value = value.toString()
|
||||
}
|
||||
})
|
||||
|
||||
// Extract options from input spec
|
||||
const inputSpec = widget.inputSpec
|
||||
const min = (inputSpec as any).min ?? 0
|
||||
const max = (inputSpec as any).max ?? 100
|
||||
const step = (inputSpec as any).step ?? 1
|
||||
const placeholder = (inputSpec as any).placeholder ?? 'Enter number'
|
||||
|
||||
// Check if slider mode should be enabled
|
||||
const isSliderMode = computed(() => {
|
||||
console.log('inputSpec', inputSpec)
|
||||
return (inputSpec as any).slider === true
|
||||
})
|
||||
|
||||
// Badge configuration
|
||||
const badgeIcon = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'pi pi-refresh'
|
||||
case 'lock':
|
||||
return 'pi pi-lock'
|
||||
case 'increment':
|
||||
return 'pi pi-arrow-up'
|
||||
case 'decrement':
|
||||
return 'pi pi-arrow-down'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const badgeColor = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'var(--p-primary-color)'
|
||||
case 'lock':
|
||||
return 'var(--p-orange-500)'
|
||||
case 'increment':
|
||||
return 'var(--p-green-500)'
|
||||
case 'decrement':
|
||||
return 'var(--p-red-500)'
|
||||
default:
|
||||
return 'var(--p-text-color)'
|
||||
}
|
||||
})
|
||||
|
||||
const badgeTooltip = computed(() => {
|
||||
switch (badgeState) {
|
||||
case 'random':
|
||||
return 'Random mode: Value randomizes after each run'
|
||||
case 'lock':
|
||||
return 'Locked: Value never changes'
|
||||
case 'increment':
|
||||
return 'Auto-increment: Value increases after each run'
|
||||
case 'decrement':
|
||||
return 'Auto-decrement: Value decreases after each run'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.badged-number-input {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Ensure proper styling for the input group */
|
||||
:deep(.p-inputgroup) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber-input) {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
:deep(.p-badge) {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
545
src/components/graph/widgets/ColorPickerWidget.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="color-picker-widget">
|
||||
<div
|
||||
:style="{ width: widgetWidth }"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border border-surface-300 bg-surface-0 w-full"
|
||||
>
|
||||
<!-- Color picker preview and popup trigger -->
|
||||
<div class="relative">
|
||||
<div
|
||||
:style="{ backgroundColor: parsedColor.hex }"
|
||||
class="w-4 h-4 rounded border-2 border-surface-400 cursor-pointer hover:border-surface-500 transition-colors"
|
||||
title="Click to edit color"
|
||||
@click="toggleColorPicker"
|
||||
/>
|
||||
|
||||
<!-- Color picker popover -->
|
||||
<Popover ref="colorPickerPopover" class="!p-0">
|
||||
<ColorPicker
|
||||
v-model="colorValue"
|
||||
format="hex"
|
||||
class="border-none"
|
||||
@update:model-value="updateColorFromPicker"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Color component inputs -->
|
||||
<div class="flex gap-5">
|
||||
<InputNumber
|
||||
v-for="component in colorComponents"
|
||||
:key="component.name"
|
||||
v-model="component.value"
|
||||
:min="component.min"
|
||||
:max="component.max"
|
||||
:step="component.step"
|
||||
:placeholder="component.name"
|
||||
class="flex-1 text-xs max-w-8"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class:
|
||||
'max-w-12 bg-[#222222] text-xs shadow-none border-[#222222]'
|
||||
}
|
||||
}
|
||||
}"
|
||||
:show-buttons="false"
|
||||
size="small"
|
||||
@update:model-value="updateColorFromComponents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Format dropdown -->
|
||||
<Select
|
||||
v-model="currentFormat"
|
||||
:options="colorFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-24 ml-3 bg-[#222222] text-xs shadow-none border-none p-0"
|
||||
size="small"
|
||||
@update:model-value="handleFormatChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Popover from 'primevue/popover'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
interface ColorComponent {
|
||||
name: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}
|
||||
|
||||
interface ParsedColor {
|
||||
hex: string
|
||||
rgb: { r: number; g: number; b: number; a: number }
|
||||
hsl: { h: number; s: number; l: number; a: number }
|
||||
hsv: { h: number; s: number; v: number; a: number }
|
||||
}
|
||||
|
||||
type ColorFormat = 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
// Color format options
|
||||
const colorFormats = [
|
||||
{ label: 'RGBA', value: 'rgba' },
|
||||
{ label: 'HSLA', value: 'hsla' },
|
||||
{ label: 'HSVA', value: 'hsva' },
|
||||
{ label: 'HEX', value: 'hex' }
|
||||
]
|
||||
|
||||
// Current format state
|
||||
const currentFormat = ref<ColorFormat>('rgba')
|
||||
|
||||
// Color picker popover reference
|
||||
const colorPickerPopover = ref()
|
||||
|
||||
// Internal color value for the PrimeVue ColorPicker
|
||||
const colorValue = ref<string>('#ff0000')
|
||||
|
||||
// Calculate widget width based on node size with padding
|
||||
const widgetWidth = computed(() => {
|
||||
if (!widget?.node?.size) return 'auto'
|
||||
|
||||
const nodeWidth = widget.node.size[0]
|
||||
const WIDGET_PADDING = 16 // Account for padding around the widget
|
||||
const maxWidth = Math.max(200, nodeWidth - WIDGET_PADDING) // Minimum 200px, but scale with node
|
||||
|
||||
return `${maxWidth}px`
|
||||
})
|
||||
|
||||
// Parse color string to various formats
|
||||
const parsedColor = computed<ParsedColor>(() => {
|
||||
const value = modelValue.value || '#ff0000'
|
||||
|
||||
// Handle different input formats
|
||||
if (value.startsWith('#')) {
|
||||
return parseHexColor(value)
|
||||
} else if (value.startsWith('rgb')) {
|
||||
return parseRgbaColor(value)
|
||||
} else if (value.startsWith('hsl')) {
|
||||
return parseHslaColor(value)
|
||||
} else if (value.startsWith('hsv')) {
|
||||
return parseHsvaColor(value)
|
||||
}
|
||||
|
||||
return parseHexColor('#ff0000') // Default fallback
|
||||
})
|
||||
|
||||
// Get color components based on current format
|
||||
const colorComponents = computed<ColorComponent[]>(() => {
|
||||
const { rgb, hsl, hsv } = parsedColor.value
|
||||
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
return [
|
||||
{ name: 'R', value: rgb.r, min: 0, max: 255, step: 1 },
|
||||
{ name: 'G', value: rgb.g, min: 0, max: 255, step: 1 },
|
||||
{ name: 'B', value: rgb.b, min: 0, max: 255, step: 1 },
|
||||
{ name: 'A', value: rgb.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hsla':
|
||||
return [
|
||||
{ name: 'H', value: hsl.h, min: 0, max: 360, step: 1 },
|
||||
{ name: 'S', value: hsl.s, min: 0, max: 100, step: 1 },
|
||||
{ name: 'L', value: hsl.l, min: 0, max: 100, step: 1 },
|
||||
{ name: 'A', value: hsl.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hsva':
|
||||
return [
|
||||
{ name: 'H', value: hsv.h, min: 0, max: 360, step: 1 },
|
||||
{ name: 'S', value: hsv.s, min: 0, max: 100, step: 1 },
|
||||
{ name: 'V', value: hsv.v, min: 0, max: 100, step: 1 },
|
||||
{ name: 'A', value: hsv.a, min: 0, max: 1, step: 0.01 }
|
||||
]
|
||||
case 'hex':
|
||||
return [] // No components for hex format
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for changes in modelValue to update colorValue
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(newValue) => {
|
||||
if (newValue && newValue !== colorValue.value) {
|
||||
colorValue.value = parsedColor.value.hex
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Toggle color picker popover
|
||||
function toggleColorPicker(event: Event) {
|
||||
colorPickerPopover.value.toggle(event)
|
||||
}
|
||||
|
||||
// Update color from picker
|
||||
function updateColorFromPicker(value: string) {
|
||||
colorValue.value = value
|
||||
updateModelValue(parseHexColor(value))
|
||||
}
|
||||
|
||||
// Update color from component inputs
|
||||
function updateColorFromComponents() {
|
||||
const components = colorComponents.value
|
||||
if (components.length === 0) return
|
||||
|
||||
let newColor: ParsedColor
|
||||
const rgbFromHsl = hslToRgb(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
const rgbFromHsv = hsvToRgb(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
newColor = {
|
||||
hex: rgbToHex(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value
|
||||
),
|
||||
rgb: {
|
||||
r: components[0].value,
|
||||
g: components[1].value,
|
||||
b: components[2].value,
|
||||
a: components[3].value
|
||||
},
|
||||
hsl: rgbToHsl(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
),
|
||||
hsv: rgbToHsv(
|
||||
components[0].value,
|
||||
components[1].value,
|
||||
components[2].value,
|
||||
components[3].value
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'hsla':
|
||||
newColor = {
|
||||
hex: rgbToHex(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b),
|
||||
rgb: rgbFromHsl,
|
||||
hsl: {
|
||||
h: components[0].value,
|
||||
s: components[1].value,
|
||||
l: components[2].value,
|
||||
a: components[3].value
|
||||
},
|
||||
hsv: rgbToHsv(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b, rgbFromHsl.a)
|
||||
}
|
||||
break
|
||||
case 'hsva':
|
||||
newColor = {
|
||||
hex: rgbToHex(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b),
|
||||
rgb: rgbFromHsv,
|
||||
hsl: rgbToHsl(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b, rgbFromHsv.a),
|
||||
hsv: {
|
||||
h: components[0].value,
|
||||
s: components[1].value,
|
||||
v: components[2].value,
|
||||
a: components[3].value
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
updateModelValue(newColor)
|
||||
}
|
||||
|
||||
// Handle format change
|
||||
function handleFormatChange() {
|
||||
updateModelValue(parsedColor.value)
|
||||
}
|
||||
|
||||
// Update the model value based on current format
|
||||
function updateModelValue(color: ParsedColor) {
|
||||
switch (currentFormat.value) {
|
||||
case 'rgba':
|
||||
modelValue.value = `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`
|
||||
break
|
||||
case 'hsla':
|
||||
modelValue.value = `hsla(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%, ${color.hsl.a})`
|
||||
break
|
||||
case 'hsva':
|
||||
modelValue.value = `hsva(${color.hsv.h}, ${color.hsv.s}%, ${color.hsv.v}%, ${color.hsv.a})`
|
||||
break
|
||||
case 'hex':
|
||||
modelValue.value = color.hex
|
||||
break
|
||||
}
|
||||
|
||||
colorValue.value = color.hex
|
||||
}
|
||||
|
||||
// Color parsing functions
|
||||
function parseHexColor(hex: string): ParsedColor {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
const a = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1
|
||||
|
||||
return {
|
||||
hex,
|
||||
rgb: { r, g, b, a },
|
||||
hsl: rgbToHsl(r, g, b, a),
|
||||
hsv: rgbToHsv(r, g, b, a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseRgbaColor(rgba: string): ParsedColor {
|
||||
const match = rgba.match(/rgba?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [r, g, b, a = 1] = match[1].split(',').map((v) => parseFloat(v.trim()))
|
||||
|
||||
return {
|
||||
hex: rgbToHex(r, g, b),
|
||||
rgb: { r, g, b, a },
|
||||
hsl: rgbToHsl(r, g, b, a),
|
||||
hsv: rgbToHsv(r, g, b, a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseHslaColor(hsla: string): ParsedColor {
|
||||
const match = hsla.match(/hsla?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [h, s, l, a = 1] = match[1]
|
||||
.split(',')
|
||||
.map((v) => parseFloat(v.trim().replace('%', '')))
|
||||
const rgb = hslToRgb(h, s, l, a)
|
||||
|
||||
return {
|
||||
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||
rgb,
|
||||
hsl: { h, s, l, a },
|
||||
hsv: rgbToHsv(rgb.r, rgb.g, rgb.b, rgb.a)
|
||||
}
|
||||
}
|
||||
|
||||
function parseHsvaColor(hsva: string): ParsedColor {
|
||||
const match = hsva.match(/hsva?\(([^)]+)\)/)
|
||||
if (!match) return parseHexColor('#ff0000')
|
||||
|
||||
const [h, s, v, a = 1] = match[1]
|
||||
.split(',')
|
||||
.map((val) => parseFloat(val.trim().replace('%', '')))
|
||||
const rgb = hsvToRgb(h, s, v, a)
|
||||
|
||||
return {
|
||||
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||
rgb,
|
||||
hsl: rgbToHsl(rgb.r, rgb.g, rgb.b, rgb.a),
|
||||
hsv: { h, s, v, a }
|
||||
}
|
||||
}
|
||||
|
||||
// Color conversion utility functions
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
|
||||
)
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number, a: number) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h: number, s: number
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
default:
|
||||
h = 0
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHsv(r: number, g: number, b: number, a: number) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h: number
|
||||
const v = max
|
||||
const s = max === 0 ? 0 : (max - min) / max
|
||||
|
||||
if (max === min) {
|
||||
h = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
default:
|
||||
h = 0
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
v: Math.round(v * 100),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number, a: number) {
|
||||
h /= 360
|
||||
s /= 100
|
||||
l /= 100
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||
if (t < 1 / 2) return q
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||
return p
|
||||
}
|
||||
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1 / 3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1 / 3)
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number, a: number) {
|
||||
h /= 360
|
||||
s /= 100
|
||||
v /= 100
|
||||
|
||||
const c = v * s
|
||||
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
|
||||
const m = v - c
|
||||
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (h < 1 / 6) {
|
||||
;[r, g, b] = [c, x, 0]
|
||||
} else if (h < 2 / 6) {
|
||||
;[r, g, b] = [x, c, 0]
|
||||
} else if (h < 3 / 6) {
|
||||
;[r, g, b] = [0, c, x]
|
||||
} else if (h < 4 / 6) {
|
||||
;[r, g, b] = [0, x, c]
|
||||
} else if (h < 5 / 6) {
|
||||
;[r, g, b] = [x, 0, c]
|
||||
} else {
|
||||
;[r, g, b] = [c, 0, x]
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255),
|
||||
a
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-picker-widget {
|
||||
min-height: 40px;
|
||||
overflow: hidden; /* Prevent overflow outside node bounds */
|
||||
}
|
||||
|
||||
/* Ensure proper styling for small inputs */
|
||||
:deep(.p-inputnumber-input) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-select) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-select .p-select-label) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-colorpicker) {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
40
src/components/graph/widgets/DropdownComboWidget.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="px-2">
|
||||
<Select
|
||||
v-model="selectedValue"
|
||||
:options="computedOptions"
|
||||
:placeholder="placeholder"
|
||||
class="w-full rounded-lg bg-[#222222] text-xs border-[#222222] shadow-none"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const selectedValue = defineModel<string>()
|
||||
const { widget } = defineProps<{
|
||||
widget?: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
const inputSpec = (widget?.inputSpec ?? {}) as ComboInputSpec
|
||||
const placeholder = 'Select option'
|
||||
const isLoading = computed(() => selectedValue.value === 'Loading...')
|
||||
|
||||
// For remote widgets, we need to dynamically get options
|
||||
const computedOptions = computed(() => {
|
||||
if (inputSpec.remote) {
|
||||
// For remote widgets, the options may be dynamically updated
|
||||
// The useRemoteWidget will update the inputSpec.options
|
||||
return inputSpec.options ?? []
|
||||
}
|
||||
return inputSpec.options ?? []
|
||||
})
|
||||
|
||||
// Tooltip support is available via inputSpec.tooltip if needed in the future
|
||||
</script>
|
||||
210
src/components/graph/widgets/ImagePreviewWidget.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="image-preview-widget relative w-full">
|
||||
<!-- Single image or grid view -->
|
||||
<div
|
||||
v-if="images.length > 0"
|
||||
class="relative rounded-lg overflow-hidden bg-gray-100 dark-theme:bg-gray-800"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<!-- Single image view -->
|
||||
<div
|
||||
v-if="selectedImageIndex !== null && images[selectedImageIndex]"
|
||||
class="relative flex items-center justify-center w-full h-full"
|
||||
>
|
||||
<img
|
||||
:src="images[selectedImageIndex].src"
|
||||
:alt="`Preview ${selectedImageIndex + 1}`"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Action buttons overlay -->
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
<Button
|
||||
v-if="images.length > 1"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="showGrid"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-sun"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleBrightness"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation for multiple images -->
|
||||
<div
|
||||
v-if="images.length > 1"
|
||||
class="absolute bottom-2 right-2 bg-black/60 text-white px-2 py-1 rounded text-sm cursor-pointer hover:bg-black/80"
|
||||
@click="nextImage"
|
||||
>
|
||||
{{ selectedImageIndex + 1 }}/{{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid view for multiple images -->
|
||||
<div
|
||||
v-else-if="allowBatch && images.length > 1"
|
||||
class="grid gap-1 p-2"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="index"
|
||||
class="relative aspect-square bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden hover:ring-2 hover:ring-blue-500"
|
||||
@click="selectImage(index)"
|
||||
>
|
||||
<img
|
||||
:src="image.src"
|
||||
:alt="`Thumbnail ${index + 1}`"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single image in grid mode -->
|
||||
<div v-else-if="images.length === 1" class="p-2">
|
||||
<div
|
||||
class="relative bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden"
|
||||
@click="selectImage(0)"
|
||||
>
|
||||
<img
|
||||
:src="images[0].src"
|
||||
:alt="'Preview'"
|
||||
class="w-full h-auto object-contain"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full bg-gray-100 dark-theme:bg-gray-800 rounded-lg"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<div class="text-gray-500 text-sm">No images to preview</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
interface ImageData {
|
||||
src: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const modelValue = defineModel<string | string[]>({ required: true })
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string | string[]>
|
||||
}>()
|
||||
|
||||
// Widget configuration
|
||||
const inputSpec = widget.inputSpec
|
||||
const allowBatch = computed(() => Boolean(inputSpec.allow_batch))
|
||||
const imageFolder = computed(() => inputSpec.image_folder || 'input')
|
||||
|
||||
// State
|
||||
const selectedImageIndex = ref<number | null>(null)
|
||||
const minHeight = 320
|
||||
|
||||
// Convert model value to image data
|
||||
const images = computed<ImageData[]>(() => {
|
||||
const value = modelValue.value
|
||||
if (!value) return []
|
||||
|
||||
const paths = Array.isArray(value) ? value : [value]
|
||||
return paths.map((path) => ({
|
||||
src: path.startsWith('http')
|
||||
? path
|
||||
: `api/view?filename=${encodeURIComponent(path)}&type=${imageFolder.value}`, // TODO: add subfolder
|
||||
width: undefined,
|
||||
height: undefined
|
||||
}))
|
||||
})
|
||||
|
||||
// Grid layout for batch images
|
||||
const gridStyle = computed(() => {
|
||||
const count = images.value.length
|
||||
if (count <= 1) return {}
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(count))
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectImage = (index: number) => {
|
||||
selectedImageIndex.value = index
|
||||
}
|
||||
|
||||
const showGrid = () => {
|
||||
selectedImageIndex.value = null
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
if (images.value.length === 0) return
|
||||
|
||||
const current = selectedImageIndex.value ?? -1
|
||||
const next = (current + 1) % images.value.length
|
||||
selectedImageIndex.value = next
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
console.warn('Failed to load image:', img.src)
|
||||
}
|
||||
|
||||
// Stub button handlers for now
|
||||
const handleEdit = () => {
|
||||
console.log('Edit button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
const handleBrightness = () => {
|
||||
console.log('Brightness button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('Save button clicked - functionality to be implemented')
|
||||
}
|
||||
|
||||
// Initialize to show first image if available
|
||||
if (images.value.length === 1) {
|
||||
selectedImageIndex.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-preview-widget {
|
||||
/* Ensure proper dark theme styling */
|
||||
}
|
||||
</style>
|
||||
150
src/components/graph/widgets/MediaLoaderWidget.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="media-loader-widget w-full px-2 max-h-44">
|
||||
<div
|
||||
class="upload-area border-2 border-dashed border-surface-300 dark-theme:border-surface-600 rounded-lg p-6 text-center bg-surface-50 dark-theme:bg-surface-800 hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors cursor-pointer"
|
||||
:class="{
|
||||
'border-primary-500 bg-primary-50 dark-theme:bg-primary-950': isDragOver
|
||||
}"
|
||||
@click="triggerFileUpload"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<i
|
||||
class="pi pi-cloud-upload text-2xl text-surface-500 dark-theme:text-surface-400"
|
||||
></i>
|
||||
<div class="text-sm text-surface-600 dark-theme:text-surface-300">
|
||||
<span>Drop your file here or </span>
|
||||
<span
|
||||
class="text-primary-600 dark-theme:text-primary-400 hover:text-primary-700 dark-theme:hover:text-primary-300 underline cursor-pointer"
|
||||
@click.stop="triggerFileUpload"
|
||||
>
|
||||
browse files
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="accept"
|
||||
class="text-xs text-surface-500 dark-theme:text-surface-400"
|
||||
>
|
||||
Accepted formats: {{ formatAcceptTypes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
// Props and model
|
||||
const modelValue = defineModel<string[]>({ required: true, default: () => [] })
|
||||
const { widget, accept } = defineProps<{
|
||||
widget: ComponentWidget<string[]>
|
||||
accept?: string
|
||||
}>()
|
||||
|
||||
// Reactive state
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const formatAcceptTypes = computed(() => {
|
||||
if (!accept) return ''
|
||||
return accept
|
||||
.split(',')
|
||||
.map((type) =>
|
||||
type
|
||||
.trim()
|
||||
.replace('image/', '')
|
||||
.replace('video/', '')
|
||||
.replace('audio/', '')
|
||||
)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
// Methods
|
||||
const triggerFileUpload = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const onFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
handleFiles(Array.from(target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
|
||||
if (event.dataTransfer?.files) {
|
||||
handleFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFiles = (files: File[]) => {
|
||||
// Filter files based on accept prop if provided
|
||||
let validFiles = files
|
||||
if (accept) {
|
||||
const acceptTypes = accept
|
||||
.split(',')
|
||||
.map((type) => type.trim().toLowerCase())
|
||||
validFiles = files.filter((file) => {
|
||||
return acceptTypes.some((acceptType) => {
|
||||
if (acceptType.includes('*')) {
|
||||
// Handle wildcard types like "image/*"
|
||||
const baseType = acceptType.split('/')[0]
|
||||
return file.type.startsWith(baseType + '/')
|
||||
}
|
||||
return file.type.toLowerCase() === acceptType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
// Emit files to parent component for handling upload
|
||||
const fileNames = validFiles.map((file) => file.name)
|
||||
modelValue.value = fileNames
|
||||
|
||||
// Trigger the widget's upload handler if available
|
||||
if ((widget.options as any)?.onFilesSelected) {
|
||||
;(widget.options as any).onFilesSelected(validFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-area {
|
||||
min-height: 80px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--p-primary-500);
|
||||
}
|
||||
</style>
|
||||
45
src/components/graph/widgets/StringWidget.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="w-full px-2">
|
||||
<!-- Single line text input -->
|
||||
<InputText
|
||||
v-if="!isMultiline"
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
class="w-full rounded-lg px-3 py-2 text-sm bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
|
||||
/>
|
||||
|
||||
<!-- Multi-line textarea -->
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:auto-resize="true"
|
||||
:rows="3"
|
||||
class="w-full rounded-lg px-3 py-2 text-sm resize-none bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { StringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string>
|
||||
}>()
|
||||
|
||||
const inputSpec = widget.inputSpec as StringInputSpec
|
||||
const isMultiline = computed(() => inputSpec.multiline === true)
|
||||
const placeholder = computed(
|
||||
() =>
|
||||
inputSpec.placeholder ??
|
||||
inputSpec.default ??
|
||||
inputSpec.defaultVal ??
|
||||
inputSpec.name
|
||||
)
|
||||
</script>
|
||||
@@ -95,14 +95,12 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
return
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
|
||||
if (disconnectOnReset) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
}
|
||||
disconnectOnReset = false
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
|
||||
@@ -46,10 +46,68 @@ vi.mock('@vueuse/core', () => ({
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => `/fileURL${path}`,
|
||||
apiURL: (path: string) => `/apiURL${path}`
|
||||
apiURL: (path: string) => `/apiURL${path}`,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflowTemplatesStore', () => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
isLoaded: true,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
groupedTemplates: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback: string) => fallback || key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTemplateWorkflows', () => ({
|
||||
useTemplateWorkflows: () => ({
|
||||
getTemplateThumbnailUrl: (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? `/fileURL/templates/${template.name}`
|
||||
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
},
|
||||
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
},
|
||||
getTemplateDescription: (template: TemplateInfo, sourceModule: string) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
},
|
||||
loadWorkflowTemplate: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowCard', () => {
|
||||
const createTemplate = (overrides = {}): TemplateInfo => ({
|
||||
name: 'test-template',
|
||||
|
||||
@@ -86,7 +86,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
|
||||
@@ -102,36 +102,36 @@ const { sourceModule, loading, template } = defineProps<{
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = useElementHover(cardRef)
|
||||
|
||||
const getThumbnailUrl = (index = '') => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
|
||||
useTemplateWorkflows()
|
||||
|
||||
// For templates from custom nodes, multiple images is not yet supported
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
// Determine the effective source module to use (from template or prop)
|
||||
const effectiveSourceModule = computed(
|
||||
() => template.sourceModule || sourceModule
|
||||
)
|
||||
|
||||
const baseThumbnailSrc = computed(() =>
|
||||
getThumbnailUrl(sourceModule === 'default' ? '1' : '')
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '1' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const overlayThumbnailSrc = computed(() =>
|
||||
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '2' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const description = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? ''
|
||||
: template.name
|
||||
})
|
||||
const description = computed(() =>
|
||||
getTemplateDescription(template, effectiveSourceModule.value)
|
||||
)
|
||||
const title = computed(() =>
|
||||
getTemplateTitle(template, effectiveSourceModule.value)
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<template>
|
||||
<DataTable
|
||||
v-model:selection="selectedTemplate"
|
||||
:value="templates"
|
||||
:value="enrichedTemplates"
|
||||
striped-rows
|
||||
selection-mode="single"
|
||||
>
|
||||
<Column field="title" :header="$t('g.title')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateTitle(slotProps.data)">{{
|
||||
getTemplateTitle(slotProps.data)
|
||||
}}</span>
|
||||
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" :header="$t('g.description')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateDescription(slotProps.data)">
|
||||
{{ getTemplateDescription(slotProps.data) }}
|
||||
<span :title="slotProps.data.description">
|
||||
{{ slotProps.data.description }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -38,8 +36,9 @@
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { sourceModule, loading, templates } = defineProps<{
|
||||
@@ -50,21 +49,20 @@ const { sourceModule, loading, templates } = defineProps<{
|
||||
}>()
|
||||
|
||||
const selectedTemplate = ref(null)
|
||||
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
|
||||
|
||||
const enrichedTemplates = computed(() => {
|
||||
return templates.map((template) => {
|
||||
const actualSourceModule = template.sourceModule || sourceModule
|
||||
return {
|
||||
...template,
|
||||
title: getTemplateTitle(template, actualSourceModule),
|
||||
description: getTemplateDescription(template, actualSourceModule)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
const getTemplateTitle = (template: TemplateInfo) => {
|
||||
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="!workflowTemplatesStore.isLoaded || !isReady"
|
||||
v-if="!isTemplatesLoaded || !isReady"
|
||||
class="absolute w-8 h-full inset-0"
|
||||
/>
|
||||
<TemplateWorkflowsSideNav
|
||||
:tabs="tabs"
|
||||
:selected-tab="selectedTab"
|
||||
:tabs="allTemplateGroups"
|
||||
:selected-tab="selectedTemplate"
|
||||
@update:selected-tab="handleTabSelection"
|
||||
/>
|
||||
</aside>
|
||||
@@ -37,14 +37,14 @@
|
||||
}"
|
||||
>
|
||||
<TemplateWorkflowView
|
||||
v-if="isReady && selectedTab"
|
||||
v-if="isReady && selectedTemplate"
|
||||
class="px-12 py-4"
|
||||
:title="selectedTab.title"
|
||||
:source-module="selectedTab.moduleName"
|
||||
:templates="selectedTab.templates"
|
||||
:loading="workflowLoading"
|
||||
:category-title="selectedTab.title"
|
||||
@load-workflow="loadWorkflow"
|
||||
:title="selectedTemplate.title"
|
||||
:source-module="selectedTemplate.moduleName"
|
||||
:templates="selectedTemplate.templates"
|
||||
:loading="loadingTemplateId"
|
||||
:category-title="selectedTemplate.title"
|
||||
@load-workflow="handleLoadWorkflow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,47 +56,46 @@ import { useAsyncState } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
isSmallScreen,
|
||||
isOpen: isSideNavOpen,
|
||||
toggle: toggleSideNav
|
||||
} = useResponsiveCollapse()
|
||||
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const { isReady } = useAsyncState(
|
||||
workflowTemplatesStore.loadWorkflowTemplates,
|
||||
null
|
||||
const {
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
loadWorkflowTemplate
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const { isReady } = useAsyncState(loadTemplates, null)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
() => {
|
||||
if (isReady.value) {
|
||||
selectFirstTemplateCategory()
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
const selectedTab = ref<WorkflowTemplates | null>(null)
|
||||
const selectFirstTab = () => {
|
||||
const firstTab = workflowTemplatesStore.groupedTemplates[0].modules[0]
|
||||
handleTabSelection(firstTab)
|
||||
}
|
||||
watch(isReady, selectFirstTab, { once: true })
|
||||
|
||||
const workflowLoading = ref<string | null>(null)
|
||||
|
||||
const tabs = computed(() => workflowTemplatesStore.groupedTemplates)
|
||||
|
||||
const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
//Listbox allows deselecting so this special case is ignored here
|
||||
if (selection !== selectedTab.value && selection !== null) {
|
||||
selectedTab.value = selection
|
||||
if (selection !== null) {
|
||||
selectTemplateCategory(selection)
|
||||
|
||||
// On small screens, close the sidebar when a category is selected
|
||||
if (isSmallScreen.value) {
|
||||
@@ -105,30 +104,9 @@ const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkflow = async (id: string) => {
|
||||
if (!isReady.value) return
|
||||
const handleLoadWorkflow = async (id: string) => {
|
||||
if (!isReady.value || !selectedTemplate.value) return false
|
||||
|
||||
workflowLoading.value = id
|
||||
let json
|
||||
if (selectedTab.value?.moduleName === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
json = await fetch(api.fileURL(`/templates/${id}.json`)).then((r) =>
|
||||
r.json()
|
||||
)
|
||||
} else {
|
||||
json = await fetch(
|
||||
api.apiURL(
|
||||
`/workflow_templates/${selectedTab.value?.moduleName}/${id}.json`
|
||||
)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
useDialogStore().closeDialog()
|
||||
const workflowName =
|
||||
selectedTab.value?.moduleName === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return false
|
||||
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-auto max-w-full">
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full">
|
||||
<WorkflowTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -101,6 +101,7 @@ Composables for sidebar functionality:
|
||||
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
|
||||
- `useQueueSidebarTab` - Manages the queue sidebar tab
|
||||
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
|
||||
- `useTemplateWorkflows` - Manages template workflow loading, selection, and display
|
||||
|
||||
### Widgets
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -111,10 +112,15 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
// Always add the badge for API nodes, with or without price text
|
||||
const creditsBadge = computed(() => {
|
||||
// Use dynamic background color based on the theme
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
|
||||
const isLightTheme =
|
||||
colorPaletteStore.completedActivePalette.light_theme
|
||||
return new LGraphBadge({
|
||||
@@ -137,7 +143,24 @@ export const useNodeBadge = () => {
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
node.constructor.nodeData?.name
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
widgetNames: relevantWidgetNames,
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
}
|
||||
|
||||
77
src/composables/node/useNodeImagePreview.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const IMAGE_PREVIEW_WIDGET_NAME = '$$node-image-preview'
|
||||
|
||||
/**
|
||||
* Composable for handling node-level operations for ImagePreview widget
|
||||
*/
|
||||
export function useNodeImagePreview() {
|
||||
const imagePreviewWidget = useImagePreviewWidget()
|
||||
|
||||
const findImagePreviewWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === IMAGE_PREVIEW_WIDGET_NAME)
|
||||
|
||||
const addImagePreviewWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec?: Partial<InputSpec>
|
||||
) =>
|
||||
imagePreviewWidget(node, {
|
||||
name: IMAGE_PREVIEW_WIDGET_NAME,
|
||||
type: 'IMAGEPREVIEW',
|
||||
allow_batch: true,
|
||||
image_folder: 'input',
|
||||
...inputSpec
|
||||
} as InputSpec)
|
||||
|
||||
/**
|
||||
* Shows image preview widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param images The images to display (can be single image or array)
|
||||
* @param options Configuration options
|
||||
*/
|
||||
function showImagePreview(
|
||||
node: LGraphNode,
|
||||
images: string | string[],
|
||||
options: {
|
||||
allow_batch?: boolean
|
||||
image_folder?: string
|
||||
imageInputName?: string
|
||||
} = {}
|
||||
) {
|
||||
const widget =
|
||||
findImagePreviewWidget(node) ??
|
||||
addImagePreviewWidget(node, {
|
||||
allow_batch: options.allow_batch,
|
||||
image_folder: options.image_folder || 'input'
|
||||
})
|
||||
|
||||
// Set the widget value
|
||||
widget.value = images
|
||||
node.setDirtyCanvas?.(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes image preview widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeImagePreview(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === IMAGE_PREVIEW_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showImagePreview,
|
||||
removeImagePreview
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,10 @@ export const useNodeImageUpload = (
|
||||
return validPaths
|
||||
}
|
||||
|
||||
// Handle drag & drop
|
||||
// Note: MediaLoader widget functionality is handled directly by
|
||||
// useImageUploadMediaWidget.ts to avoid circular dependencies
|
||||
|
||||
// Traditional approach: Handle drag & drop
|
||||
useNodeDragAndDrop(node, {
|
||||
fileFilter,
|
||||
onDrop: handleUploadBatch
|
||||
|
||||
122
src/composables/node/useNodeMediaUpload.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
interface MediaUploadOptions {
|
||||
fileFilter?: (file: File) => boolean
|
||||
onUploadComplete: (paths: string[]) => void
|
||||
allow_batch?: boolean
|
||||
accept?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling media upload with Vue MediaLoader widget
|
||||
*/
|
||||
export function useNodeMediaUpload() {
|
||||
const mediaLoaderWidget = useMediaLoaderWidget()
|
||||
|
||||
const findMediaLoaderWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === MEDIA_LOADER_WIDGET_NAME)
|
||||
|
||||
const addMediaLoaderWidget = (
|
||||
node: LGraphNode,
|
||||
options: MediaUploadOptions
|
||||
) => {
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the MediaLoader widget
|
||||
const widget = mediaLoaderWidget(node, {
|
||||
name: MEDIA_LOADER_WIDGET_NAME,
|
||||
type: 'MEDIA_LOADER'
|
||||
} as InputSpec)
|
||||
|
||||
// Connect the widget to the upload handler
|
||||
if (widget.options) {
|
||||
;(widget.options as any).onFilesSelected = async (files: File[]) => {
|
||||
const filteredFiles = options.fileFilter
|
||||
? files.filter(options.fileFilter)
|
||||
: files
|
||||
|
||||
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) {
|
||||
options.onUploadComplete(validPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows media loader widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param options Upload configuration options
|
||||
*/
|
||||
function showMediaLoader(node: LGraphNode, options: MediaUploadOptions) {
|
||||
const widget =
|
||||
findMediaLoaderWidget(node) ?? addMediaLoaderWidget(node, options)
|
||||
node.setDirtyCanvas?.(true)
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes media loader widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeMediaLoader(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === MEDIA_LOADER_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showMediaLoader,
|
||||
removeMediaLoader,
|
||||
addMediaLoaderWidget
|
||||
}
|
||||
}
|
||||
85
src/composables/node/useWatchWidget.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computedWithControl } from '@vueuse/core'
|
||||
import { type ComputedRef, ref } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
|
||||
export interface UseComputedWithWidgetWatchOptions {
|
||||
/**
|
||||
* Names of widgets to observe for changes.
|
||||
* If not provided, all widgets will be observed.
|
||||
*/
|
||||
widgetNames?: string[]
|
||||
|
||||
/**
|
||||
* Whether to trigger a canvas redraw when widget values change.
|
||||
* @default false
|
||||
*/
|
||||
triggerCanvasRedraw?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that creates a computed that has a node's widget values as a dependencies.
|
||||
* Essentially `computedWithControl` (https://vueuse.org/shared/computedWithControl/) where
|
||||
* the explicitly defined extra dependencies are LGraphNode widgets.
|
||||
*
|
||||
* @param node - The LGraphNode whose widget values are to be watched
|
||||
* @param options - Configuration options for the watcher
|
||||
* @returns A function to create computed that responds to widget changes
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
* widgetNames: ['width', 'height'],
|
||||
* triggerCanvasRedraw: true
|
||||
* })
|
||||
*
|
||||
* const dynamicPrice = computedWithWidgetWatch(() => {
|
||||
* return calculatePrice(node)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useComputedWithWidgetWatch = (
|
||||
node: LGraphNode,
|
||||
options: UseComputedWithWidgetWatchOptions = {}
|
||||
) => {
|
||||
const { widgetNames, triggerCanvasRedraw = false } = options
|
||||
|
||||
// Create a reactive trigger based on widget values
|
||||
const widgetValues = ref<Record<string, any>>({})
|
||||
|
||||
// Initialize widget observers
|
||||
if (node.widgets) {
|
||||
const widgetsToObserve = widgetNames
|
||||
? node.widgets.filter((widget) => widgetNames.includes(widget.name))
|
||||
: node.widgets
|
||||
|
||||
// Initialize current values
|
||||
const currentValues: Record<string, any> = {}
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
currentValues[widget.name] = widget.value
|
||||
})
|
||||
widgetValues.value = currentValues
|
||||
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
widget.callback = useChainCallback(widget.callback, () => {
|
||||
// Update the reactive widget values
|
||||
widgetValues.value = {
|
||||
...widgetValues.value,
|
||||
[widget.name]: widget.value
|
||||
}
|
||||
|
||||
// Optionally trigger a canvas redraw
|
||||
if (triggerCanvasRedraw) {
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Returns a function that creates a computed that responds to widget changes.
|
||||
// The computed will be re-evaluated whenever any observed widget changes.
|
||||
return <T>(computeFn: () => T): ComputedRef<T> => {
|
||||
return computedWithControl(widgetValues, computeFn)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -34,7 +34,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
@@ -674,30 +673,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
function: async () => {
|
||||
await firebaseAuthActions.logout()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ConvertToSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
190
src/composables/useTemplateWorkflows.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/types/workflowTemplateTypes'
|
||||
|
||||
export function useTemplateWorkflows() {
|
||||
const { t } = useI18n()
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// State
|
||||
const selectedTemplate = ref<WorkflowTemplates | null>(null)
|
||||
const loadingTemplateId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
|
||||
const allTemplateGroups = computed<TemplateGroup[]>(
|
||||
() => workflowTemplatesStore.groupedTemplates
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads all template workflows from the API
|
||||
*/
|
||||
const loadTemplates = async () => {
|
||||
if (!workflowTemplatesStore.isLoaded) {
|
||||
await workflowTemplatesStore.loadWorkflowTemplates()
|
||||
}
|
||||
return workflowTemplatesStore.isLoaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first template category as default
|
||||
*/
|
||||
const selectFirstTemplateCategory = () => {
|
||||
if (allTemplateGroups.value.length > 0) {
|
||||
const firstCategory = allTemplateGroups.value[0].modules[0]
|
||||
selectTemplateCategory(firstCategory)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a template category
|
||||
*/
|
||||
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
|
||||
selectedTemplate.value = category
|
||||
return category !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets template thumbnail URL
|
||||
*/
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template title
|
||||
*/
|
||||
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a workflow template
|
||||
*/
|
||||
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
|
||||
if (!isTemplatesLoaded.value) return false
|
||||
|
||||
loadingTemplateId.value = id
|
||||
let json
|
||||
|
||||
try {
|
||||
// Handle "All" category as a special case
|
||||
if (sourceModule === 'all') {
|
||||
// Find "All" category in the ComfyUI Examples group
|
||||
const comfyExamplesGroup = allTemplateGroups.value.find(
|
||||
(g) =>
|
||||
g.label ===
|
||||
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
)
|
||||
const allCategory = comfyExamplesGroup?.modules.find(
|
||||
(m) => m.moduleName === 'all'
|
||||
)
|
||||
const template = allCategory?.templates.find((t) => t.name === id)
|
||||
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
// Use the stored source module for loading
|
||||
const actualSourceModule = template.sourceModule
|
||||
json = await fetchTemplateJson(id, actualSourceModule)
|
||||
|
||||
// Use source module for name
|
||||
const workflowName =
|
||||
actualSourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
const workflowName =
|
||||
sourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
return false
|
||||
} finally {
|
||||
loadingTemplateId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
} else {
|
||||
return fetch(
|
||||
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
|
||||
// Computed
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
|
||||
// Methods
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription,
|
||||
loadWorkflowTemplate
|
||||
}
|
||||
}
|
||||
173
src/composables/widgets/useBadgedNumberInput.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import BadgedNumberInput from '@/components/graph/widgets/BadgedNumberInput.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
|
||||
|
||||
type NumberWidgetMode = 'int' | 'float'
|
||||
|
||||
interface BadgedNumberInputOptions {
|
||||
defaultValue?: number
|
||||
badgeState?: BadgeState
|
||||
disabled?: boolean
|
||||
minHeight?: number
|
||||
serialize?: boolean
|
||||
mode?: NumberWidgetMode
|
||||
}
|
||||
|
||||
// Helper function to map control widget values to badge states
|
||||
const mapControlValueToBadgeState = (controlValue: string): BadgeState => {
|
||||
switch (controlValue) {
|
||||
case 'fixed':
|
||||
return 'lock'
|
||||
case 'increment':
|
||||
return 'increment'
|
||||
case 'decrement':
|
||||
return 'decrement'
|
||||
case 'randomize':
|
||||
return 'random'
|
||||
default:
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
export const useBadgedNumberInput = (
|
||||
options: BadgedNumberInputOptions = {}
|
||||
) => {
|
||||
const {
|
||||
defaultValue = 0,
|
||||
disabled = false,
|
||||
minHeight = 32,
|
||||
serialize = true,
|
||||
mode = 'int'
|
||||
} = options
|
||||
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value as string to conform to ComponentWidgetImpl requirements
|
||||
const widgetValue = ref<string>(defaultValue.toString())
|
||||
|
||||
// Determine if we should show control widget and badge
|
||||
const shouldShowControlWidget =
|
||||
inputSpec.control_after_generate ??
|
||||
// Legacy compatibility: seed inputs get control widgets
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
// Create reactive props object for the component
|
||||
const componentProps = reactive({
|
||||
badgeState:
|
||||
options.badgeState ??
|
||||
(shouldShowControlWidget ? 'random' : ('normal' as BadgeState)),
|
||||
disabled
|
||||
})
|
||||
|
||||
const controlWidget: any = null
|
||||
|
||||
// Create the main widget instance
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
Omit<
|
||||
InstanceType<typeof BadgedNumberInput>['$props'],
|
||||
'widget' | 'modelValue'
|
||||
>
|
||||
>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: BadgedNumberInput,
|
||||
inputSpec,
|
||||
props: componentProps,
|
||||
options: {
|
||||
// Required: getter for widget value - return as string
|
||||
getValue: () => widgetValue.value as string | object,
|
||||
|
||||
// Required: setter for widget value - accept number, string or object
|
||||
setValue: (value: string | object | number) => {
|
||||
let numValue: number
|
||||
if (typeof value === 'object') {
|
||||
numValue = parseFloat(JSON.stringify(value))
|
||||
} else {
|
||||
numValue =
|
||||
typeof value === 'number' ? value : parseFloat(String(value))
|
||||
}
|
||||
|
||||
if (!isNaN(numValue)) {
|
||||
// Apply int/float specific value processing
|
||||
if (mode === 'int') {
|
||||
const step = (inputSpec as any).step ?? 1
|
||||
if (step === 1) {
|
||||
numValue = Math.round(numValue)
|
||||
} else {
|
||||
const min = (inputSpec as any).min ?? 0
|
||||
const offset = min % step
|
||||
numValue =
|
||||
Math.round((numValue - offset) / step) * step + offset
|
||||
}
|
||||
}
|
||||
widgetValue.value = numValue.toString()
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => minHeight + PADDING,
|
||||
// Lock maximum height to prevent oversizing
|
||||
getMaxHeight: () => 48,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
}
|
||||
})
|
||||
|
||||
// Add control widget if needed - temporarily disabled to fix circular dependency
|
||||
if (shouldShowControlWidget) {
|
||||
// TODO: Re-implement control widget functionality without circular dependency
|
||||
console.warn(
|
||||
'Control widget functionality temporarily disabled due to circular dependency'
|
||||
)
|
||||
// controlWidget = addValueControlWidget(
|
||||
// node,
|
||||
// widget as any, // Cast to satisfy the interface
|
||||
// 'randomize',
|
||||
// undefined,
|
||||
// undefined,
|
||||
// transformInputSpecV2ToV1(inputSpec)
|
||||
// )
|
||||
|
||||
// Set up reactivity to update badge state when control widget changes
|
||||
if (controlWidget) {
|
||||
const originalCallback = controlWidget.callback
|
||||
controlWidget.callback = function (value: string) {
|
||||
componentProps.badgeState = mapControlValueToBadgeState(value)
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize badge state
|
||||
componentProps.badgeState = mapControlValueToBadgeState(
|
||||
controlWidget.value || 'randomize'
|
||||
)
|
||||
|
||||
// Link the widgets
|
||||
;(widget as any).linkedWidgets = [controlWidget]
|
||||
}
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { BadgeState, BadgedNumberInputOptions, NumberWidgetMode }
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type InputSpec,
|
||||
isBooleanInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
export const useBooleanWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref } from 'vue'
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
|
||||
207
src/composables/widgets/useColorPickerWidget.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ColorPickerWidget from '@/components/graph/widgets/ColorPickerWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
interface ColorPickerWidgetOptions {
|
||||
defaultValue?: string
|
||||
defaultFormat?: 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||
minHeight?: number
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
export const useColorPickerWidget = (
|
||||
options: ColorPickerWidgetOptions = {}
|
||||
) => {
|
||||
const {
|
||||
defaultValue = 'rgba(255, 0, 0, 1)',
|
||||
minHeight = 48,
|
||||
serialize = true
|
||||
} = options
|
||||
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value as string
|
||||
const widgetValue = ref<string>(defaultValue)
|
||||
|
||||
// Create the main widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ColorPickerWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string | any) => {
|
||||
// Handle different input types
|
||||
if (typeof value === 'string') {
|
||||
// Validate and normalize color string
|
||||
const normalizedValue = normalizeColorString(value)
|
||||
if (normalizedValue) {
|
||||
widgetValue.value = normalizedValue
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Handle object input (e.g., from PrimeVue ColorPicker)
|
||||
if (value.hex) {
|
||||
widgetValue.value = value.hex
|
||||
} else {
|
||||
// Try to convert object to string
|
||||
widgetValue.value = String(value)
|
||||
}
|
||||
} else {
|
||||
// Fallback to string conversion
|
||||
widgetValue.value = String(value)
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => minHeight + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
}
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes color string inputs to ensure consistent format
|
||||
* @param colorString - The input color string
|
||||
* @returns Normalized color string or null if invalid
|
||||
*/
|
||||
function normalizeColorString(colorString: string): string | null {
|
||||
if (!colorString || typeof colorString !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = colorString.trim()
|
||||
|
||||
// Handle hex colors
|
||||
if (trimmed.startsWith('#')) {
|
||||
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) {
|
||||
// Convert 3-digit hex to 6-digit
|
||||
if (trimmed.length === 4) {
|
||||
return (
|
||||
'#' +
|
||||
trimmed[1] +
|
||||
trimmed[1] +
|
||||
trimmed[2] +
|
||||
trimmed[2] +
|
||||
trimmed[3] +
|
||||
trimmed[3]
|
||||
)
|
||||
}
|
||||
return trimmed.toLowerCase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle rgb/rgba colors
|
||||
if (trimmed.startsWith('rgb')) {
|
||||
const rgbaMatch = trimmed.match(
|
||||
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (rgbaMatch) {
|
||||
const [, r, g, b, a] = rgbaMatch
|
||||
const red = Math.max(0, Math.min(255, parseInt(r)))
|
||||
const green = Math.max(0, Math.min(255, parseInt(g)))
|
||||
const blue = Math.max(0, Math.min(255, parseInt(b)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `rgb(${red}, ${green}, ${blue})`
|
||||
} else {
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle hsl/hsla colors
|
||||
if (trimmed.startsWith('hsl')) {
|
||||
const hslaMatch = trimmed.match(
|
||||
/hsla?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (hslaMatch) {
|
||||
const [, h, s, l, a] = hslaMatch
|
||||
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||
const lightness = Math.max(0, Math.min(100, parseInt(l)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
|
||||
} else {
|
||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle hsv/hsva colors (custom format)
|
||||
if (trimmed.startsWith('hsv')) {
|
||||
const hsvaMatch = trimmed.match(
|
||||
/hsva?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||
)
|
||||
if (hsvaMatch) {
|
||||
const [, h, s, v, a] = hsvaMatch
|
||||
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||
const value = Math.max(0, Math.min(100, parseInt(v)))
|
||||
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||
|
||||
if (alpha === 1) {
|
||||
return `hsv(${hue}, ${saturation}%, ${value}%)`
|
||||
} else {
|
||||
return `hsva(${hue}, ${saturation}%, ${value}%, ${alpha})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle named colors by converting to hex (basic set)
|
||||
const namedColors: Record<string, string> = {
|
||||
red: '#ff0000',
|
||||
green: '#008000',
|
||||
blue: '#0000ff',
|
||||
white: '#ffffff',
|
||||
black: '#000000',
|
||||
yellow: '#ffff00',
|
||||
cyan: '#00ffff',
|
||||
magenta: '#ff00ff',
|
||||
orange: '#ffa500',
|
||||
purple: '#800080',
|
||||
pink: '#ffc0cb',
|
||||
brown: '#a52a2a',
|
||||
gray: '#808080',
|
||||
grey: '#808080'
|
||||
}
|
||||
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
if (namedColors[lowerTrimmed]) {
|
||||
return namedColors[lowerTrimmed]
|
||||
}
|
||||
|
||||
// If we can't parse it, return null
|
||||
return null
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { ColorPickerWidgetOptions }
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
ComboInputSpec,
|
||||
type InputSpec,
|
||||
@@ -14,19 +12,11 @@ import {
|
||||
ComponentWidgetImpl,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidgets
|
||||
} from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
import { useDropdownComboWidget } from './useDropdownComboWidget'
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
return undefined
|
||||
}
|
||||
// Default value logic is now handled in useDropdownComboWidget
|
||||
|
||||
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const widgetValue = ref<string[]>([])
|
||||
@@ -39,7 +29,13 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = value
|
||||
}
|
||||
},
|
||||
// Optional: minimum height for the widget (multiselect needs minimal height)
|
||||
getMinHeight: () => 24,
|
||||
// Lock maximum height to prevent oversizing
|
||||
getMaxHeight: () => 32,
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||
@@ -49,49 +45,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
}
|
||||
|
||||
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
const comboOptions = inputSpec.options ?? []
|
||||
const widget = node.addWidget(
|
||||
'combo',
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
() => {},
|
||||
{
|
||||
values: comboOptions
|
||||
}
|
||||
) as IComboWidget
|
||||
|
||||
if (inputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: inputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
// Assertion: Proxy handler passthrough
|
||||
return prop !== 'values'
|
||||
? target[prop as keyof typeof target]
|
||||
: remoteWidget.getValue()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
return widget
|
||||
// Use the new dropdown combo widget for single-selection combo widgets
|
||||
const dropdownWidget = useDropdownComboWidget()
|
||||
return dropdownWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
export const useComboWidget = () => {
|
||||
|
||||
98
src/composables/widgets/useDropdownComboWidget.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DropdownComboWidget from '@/components/graph/widgets/DropdownComboWidget.vue'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
return ''
|
||||
}
|
||||
|
||||
export const useDropdownComboWidget = (
|
||||
options: { defaultValue?: string } = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Type assertion to ComboInputSpec since this is specifically for combo widgets
|
||||
const comboInputSpec = inputSpec as ComboInputSpec
|
||||
|
||||
// Initialize widget value
|
||||
const defaultValue = options.defaultValue ?? getDefaultValue(comboInputSpec)
|
||||
const widgetValue = ref<string>(defaultValue)
|
||||
|
||||
// Create the widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: DropdownComboWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget (dropdown needs minimal height)
|
||||
getMinHeight: () => 32,
|
||||
// Lock maximum height to prevent oversizing
|
||||
getMaxHeight: () => 48,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle remote widget functionality
|
||||
if (comboInputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: comboInputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget: widget as any // Cast to be compatible with the remote widget interface
|
||||
})
|
||||
if (comboInputSpec.remote.refresh_button) {
|
||||
remoteWidget.addRefreshButton()
|
||||
}
|
||||
|
||||
// Update the widget to use remote data
|
||||
// Note: The remote widget will handle updating the options through the inputSpec
|
||||
}
|
||||
|
||||
// Handle control_after_generate widgets
|
||||
if (comboInputSpec.control_after_generate) {
|
||||
const linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget as any, // Cast to be compatible with legacy widget interface
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(comboInputSpec)
|
||||
)
|
||||
// Store reference to linked widgets (mimicking original behavior)
|
||||
;(widget as any).linkedWidgets = linkedWidgets
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type InputSpec,
|
||||
isFloatInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
|
||||
@@ -1,317 +1,53 @@
|
||||
import {
|
||||
BaseWidget,
|
||||
type CanvasPointer,
|
||||
type LGraphNode,
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ImagePreviewWidget from '@/components/graph/widgets/ImagePreviewWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
||||
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const renderPreview = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
shiftY: number
|
||||
const PADDING = 8
|
||||
|
||||
export const useImagePreviewWidget = (
|
||||
options: { defaultValue?: string | string[] } = {}
|
||||
) => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const mouse = canvas.graph_mouse
|
||||
|
||||
if (!canvas.pointer_is_down && node.pointerDown) {
|
||||
if (
|
||||
mouse[0] === node.pointerDown.pos[0] &&
|
||||
mouse[1] === node.pointerDown.pos[1]
|
||||
) {
|
||||
node.imageIndex = node.pointerDown.index
|
||||
}
|
||||
node.pointerDown = null
|
||||
}
|
||||
|
||||
const imgs = node.imgs ?? []
|
||||
let { imageIndex } = node
|
||||
const numImages = imgs.length
|
||||
if (numImages === 1 && !imageIndex) {
|
||||
// This skips the thumbnail render section below
|
||||
node.imageIndex = imageIndex = 0
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
|
||||
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
|
||||
const dw = node.size[0]
|
||||
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
|
||||
|
||||
if (imageIndex == null) {
|
||||
// No image selected; draw thumbnails of all
|
||||
let cellWidth: number
|
||||
let cellHeight: number
|
||||
let shiftX: number
|
||||
let cell_padding: number
|
||||
let cols: number
|
||||
|
||||
const compact_mode = is_all_same_aspect_ratio(imgs)
|
||||
if (!compact_mode) {
|
||||
// use rectangle cell style and border line
|
||||
cell_padding = 2
|
||||
// Prevent infinite canvas2d scale-up
|
||||
const largestDimension = imgs.reduce(
|
||||
(acc, current) =>
|
||||
Math.max(acc, current.naturalWidth, current.naturalHeight),
|
||||
0
|
||||
)
|
||||
const fakeImgs = []
|
||||
fakeImgs.length = imgs.length
|
||||
fakeImgs[0] = {
|
||||
naturalWidth: largestDimension,
|
||||
naturalHeight: largestDimension
|
||||
}
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
fakeImgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
} else {
|
||||
cell_padding = 0
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
imgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
}
|
||||
|
||||
let anyHovered = false
|
||||
node.imageRects = []
|
||||
for (let i = 0; i < numImages; i++) {
|
||||
const img = imgs[i]
|
||||
const row = Math.floor(i / cols)
|
||||
const col = i % cols
|
||||
const x = col * cellWidth + shiftX
|
||||
const y = row * cellHeight + shiftY
|
||||
if (!anyHovered) {
|
||||
anyHovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
cellWidth,
|
||||
cellHeight
|
||||
)
|
||||
if (anyHovered) {
|
||||
node.overIndex = i
|
||||
let value = 110
|
||||
if (canvas.pointer_is_down) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
value = 125
|
||||
}
|
||||
ctx.filter = `contrast(${value}%) brightness(${value}%)`
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
}
|
||||
}
|
||||
node.imageRects.push([x, y, cellWidth, cellHeight])
|
||||
|
||||
const wratio = cellWidth / img.width
|
||||
const hratio = cellHeight / img.height
|
||||
const ratio = Math.min(wratio, hratio)
|
||||
|
||||
const imgHeight = ratio * img.height
|
||||
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
|
||||
const imgWidth = ratio * img.width
|
||||
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
imgX + cell_padding,
|
||||
imgY + cell_padding,
|
||||
imgWidth - cell_padding * 2,
|
||||
imgHeight - cell_padding * 2
|
||||
)
|
||||
if (!compact_mode) {
|
||||
// rectangle cell and border line style
|
||||
ctx.strokeStyle = '#8F8F8F'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(
|
||||
x + cell_padding,
|
||||
y + cell_padding,
|
||||
cellWidth - cell_padding * 2,
|
||||
cellHeight - cell_padding * 2
|
||||
)
|
||||
}
|
||||
|
||||
ctx.filter = 'none'
|
||||
}
|
||||
|
||||
if (!anyHovered) {
|
||||
node.pointerDown = null
|
||||
node.overIndex = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
// Draw individual
|
||||
const img = imgs[imageIndex]
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
|
||||
const scaleX = dw / w
|
||||
const scaleY = dh / h
|
||||
const scale = Math.min(scaleX, scaleY, 1)
|
||||
|
||||
w *= scale
|
||||
h *= scale
|
||||
|
||||
const x = (dw - w) / 2
|
||||
const y = (dh - h) / 2 + shiftY
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
|
||||
// Draw image size text below the image
|
||||
if (allowImageSizeDraw) {
|
||||
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.font = '10px sans-serif'
|
||||
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
|
||||
const textY = y + h + 10
|
||||
ctx.fillText(sizeText, x + w / 2, textY)
|
||||
}
|
||||
|
||||
const drawButton = (
|
||||
x: number,
|
||||
y: number,
|
||||
sz: number,
|
||||
text: string
|
||||
): boolean => {
|
||||
const hovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
sz,
|
||||
sz
|
||||
)
|
||||
let fill = '#333'
|
||||
let textFill = '#fff'
|
||||
let isClicking = false
|
||||
if (hovered) {
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
if (canvas.pointer_is_down) {
|
||||
fill = '#1e90ff'
|
||||
isClicking = true
|
||||
} else {
|
||||
fill = '#eee'
|
||||
textFill = '#000'
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
|
||||
return isClicking
|
||||
}
|
||||
|
||||
if (!(numImages > 1)) return
|
||||
|
||||
const imageNum = (node.imageIndex ?? 0) + 1
|
||||
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
|
||||
const i = imageNum >= numImages ? 0 : imageNum
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
|
||||
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== null) {
|
||||
node.pointerDown = { index: null, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewWidget extends BaseWidget {
|
||||
constructor(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
options: IWidgetOptions<string | object>
|
||||
) {
|
||||
const widget: IBaseWidget = {
|
||||
name,
|
||||
options,
|
||||
type: 'custom',
|
||||
/** Dummy value to satisfy type requirements. */
|
||||
value: '',
|
||||
y: 0
|
||||
}
|
||||
super(widget, node)
|
||||
|
||||
// Don't serialize the widget value
|
||||
this.serialize = false
|
||||
}
|
||||
|
||||
override drawWidget(ctx: CanvasRenderingContext2D): void {
|
||||
renderPreview(ctx, this.node, this.y)
|
||||
}
|
||||
|
||||
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
|
||||
pointer.onDragStart = () => {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
canvas.emitBeforeChange()
|
||||
graph?.beforeChange()
|
||||
// Ensure that dragging is properly cleaned up, on success or failure.
|
||||
pointer.finally = () => {
|
||||
canvas.isDragging = false
|
||||
graph?.afterChange()
|
||||
canvas.emitAfterChange()
|
||||
}
|
||||
|
||||
canvas.processSelect(node, pointer.eDown)
|
||||
canvas.isDragging = true
|
||||
}
|
||||
|
||||
pointer.onDragEnd = (e) => {
|
||||
const { canvas } = app
|
||||
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
|
||||
canvas.graph?.snapToGrid(canvas.selectedItems)
|
||||
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override onClick(): void {}
|
||||
|
||||
override computeLayoutSize() {
|
||||
return {
|
||||
minHeight: 220,
|
||||
minWidth: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useImagePreviewWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false
|
||||
})
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string | string[]>(
|
||||
options.defaultValue ?? (inputSpec.allow_batch ? [] : '')
|
||||
)
|
||||
|
||||
// Create the Vue-based widget instance
|
||||
const widget = new ComponentWidgetImpl<string | string[]>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ImagePreviewWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string | string[]) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => 320 + PADDING,
|
||||
getMaxHeight: () => 512 + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: false
|
||||
}
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
|
||||
242
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
type InternalFile = string | ResultItem
|
||||
type InternalValue = InternalFile | InternalFile[]
|
||||
type ExposedValue = string | string[]
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||
|
||||
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||
value: ExposedValue
|
||||
}
|
||||
|
||||
export const useImageUploadMediaWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
const isVideo = !!inputOptions.video_upload
|
||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
if (!internalValue) return initialFile
|
||||
if (Array.isArray(internalValue))
|
||||
return allow_batch
|
||||
? internalValue.map(formatPath)
|
||||
: formatPath(internalValue[0])
|
||||
return formatPath(internalValue)
|
||||
}
|
||||
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
|
||||
// Convert the V1 input spec to V2 format for the MediaLoader widget
|
||||
const inputSpecV2 = transformInputSpecV1ToV2(inputData, { name: inputName })
|
||||
|
||||
// Handle widget dimensions based on input options
|
||||
const getMinHeight = () => {
|
||||
// Use smaller height for MediaLoader upload widget
|
||||
let baseHeight = 176
|
||||
|
||||
// Handle multiline attribute for expanded height
|
||||
if (inputOptions.multiline) {
|
||||
baseHeight = Math.max(
|
||||
baseHeight,
|
||||
inputOptions.multiline === true
|
||||
? 120
|
||||
: Number(inputOptions.multiline) || 120
|
||||
)
|
||||
}
|
||||
|
||||
// Handle other height-related attributes
|
||||
if (inputOptions.min_height) {
|
||||
baseHeight = Math.max(baseHeight, Number(inputOptions.min_height))
|
||||
}
|
||||
|
||||
return baseHeight + 8 // Add padding
|
||||
}
|
||||
|
||||
const getMaxHeight = () => {
|
||||
// Lock maximum height to prevent oversizing of upload widget
|
||||
if (inputOptions.multiline || inputOptions.min_height) {
|
||||
// Allow more height for special cases
|
||||
return Math.max(200, getMinHeight())
|
||||
}
|
||||
// Lock standard upload widget to ~80px max
|
||||
return 80
|
||||
}
|
||||
|
||||
// State for MediaLoader widget
|
||||
const uploadedFiles = ref<string[]>([])
|
||||
|
||||
// Create the MediaLoader widget directly
|
||||
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
|
||||
{
|
||||
node,
|
||||
name: inputName,
|
||||
component: MediaLoaderWidget,
|
||||
inputSpec: inputSpecV2,
|
||||
props: {
|
||||
accept
|
||||
},
|
||||
options: {
|
||||
getValue: () => uploadedFiles.value,
|
||||
setValue: (value: string[]) => {
|
||||
uploadedFiles.value = value
|
||||
},
|
||||
getMinHeight,
|
||||
getMaxHeight, // Lock maximum height to prevent oversizing
|
||||
serialize: false,
|
||||
onFilesSelected: async (files: File[]) => {
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and upload files
|
||||
const filteredFiles = files.filter(fileFilter)
|
||||
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
|
||||
if (validPaths.length) {
|
||||
validPaths.forEach((path) =>
|
||||
addToComboValues(fileComboWidget, path)
|
||||
)
|
||||
|
||||
const output = allow_batch ? validPaths : validPaths[0]
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = output
|
||||
|
||||
// Update widget value to show file names
|
||||
uploadedFiles.value = Array.isArray(output) ? output : [output]
|
||||
|
||||
// Trigger the combo widget callback to update all dependent widgets
|
||||
fileComboWidget.callback?.(output)
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
)
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, uploadWidget as any)
|
||||
|
||||
// Store the original callback if it exists
|
||||
const originalCallback = fileComboWidget.callback
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function (value?: any) {
|
||||
// Call original callback first if it exists
|
||||
originalCallback?.call(this, value)
|
||||
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||
if (!isVideo) {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
// On load if we have a value then render the image
|
||||
// The value isnt set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use appropriate preview method
|
||||
if (isVideo) {
|
||||
showPreview({ block: false })
|
||||
} else {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
@@ -41,6 +42,7 @@ export const useImageUploadWidget = () => {
|
||||
const isVideo = !!inputOptions.video_upload
|
||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
const { showImagePreview } = useNodeImagePreview()
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
@@ -96,6 +98,16 @@ export const useImageUploadWidget = () => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
|
||||
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||
if (!isVideo) {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
@@ -106,7 +118,17 @@ export const useImageUploadWidget = () => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
|
||||
// Use appropriate preview method
|
||||
if (isVideo) {
|
||||
showPreview({ block: false })
|
||||
} else {
|
||||
showImagePreview(node, fileComboWidget.value, {
|
||||
allow_batch: allow_batch as boolean,
|
||||
image_folder: image_folder as string,
|
||||
imageInputName: imageInputName as string
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
type InputSpec,
|
||||
isIntInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidget
|
||||
} from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
@@ -81,15 +77,19 @@ export const useIntWidget = () => {
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const seedControl = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
// TODO: Re-implement control widget functionality without circular dependency
|
||||
console.warn(
|
||||
'Control widget functionality temporarily disabled for int widgets due to circular dependency'
|
||||
)
|
||||
widget.linkedWidgets = [seedControl]
|
||||
// const seedControl = addValueControlWidget(
|
||||
// node,
|
||||
// widget,
|
||||
// 'randomize',
|
||||
// undefined,
|
||||
// undefined,
|
||||
// transformInputSpecV2ToV1(inputSpec)
|
||||
// )
|
||||
// widget.linkedWidgets = [seedControl]
|
||||
}
|
||||
|
||||
return widget
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
function addMarkdownWidget(
|
||||
node: LGraphNode,
|
||||
|
||||
72
src/composables/widgets/useMediaLoaderWidget.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
ComponentWidgetImpl,
|
||||
type DOMWidgetOptions,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
interface MediaLoaderOptions {
|
||||
defaultValue?: string[]
|
||||
minHeight?: number
|
||||
accept?: string
|
||||
onFilesSelected?: (files: File[]) => void
|
||||
}
|
||||
|
||||
interface MediaLoaderWidgetOptions extends DOMWidgetOptions<string[]> {
|
||||
onFilesSelected?: (files: File[]) => void
|
||||
}
|
||||
|
||||
export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string[]>(options.defaultValue ?? [])
|
||||
|
||||
// Create the widget instance
|
||||
const widget = new ComponentWidgetImpl<string[], { accept?: string }>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: MediaLoaderWidget,
|
||||
inputSpec,
|
||||
props: {
|
||||
accept: options.accept
|
||||
},
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = Array.isArray(value) ? value : []
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
// getMinHeight: () => (options.minHeight ?? 64) + PADDING,
|
||||
getMaxHeight: () => 225 + PADDING,
|
||||
getMinHeight: () => 176 + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true,
|
||||
|
||||
// Custom option for file selection callback
|
||||
onFilesSelected: options.onFilesSelected
|
||||
} as MediaLoaderWidgetOptions
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ref } from 'vue'
|
||||
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isStringInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function addMultilineWidget(
|
||||
@@ -91,6 +91,55 @@ function addMultilineWidget(
|
||||
return widget
|
||||
}
|
||||
|
||||
function addSingleLineWidget(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
opts: { defaultVal: string; placeholder?: string }
|
||||
) {
|
||||
const inputEl = document.createElement('input')
|
||||
inputEl.className = 'comfy-text-input'
|
||||
inputEl.type = 'text'
|
||||
inputEl.value = opts.defaultVal
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
|
||||
const widget = node.addDOMWidget(name, 'text', inputEl, {
|
||||
getValue(): string {
|
||||
return inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.inputEl = inputEl
|
||||
widget.options.minNodeSize = [200, 40]
|
||||
|
||||
inputEl.addEventListener('input', () => {
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
// Allow middle mouse button panning
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
export const useStringWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
@@ -108,7 +157,10 @@ export const useStringWidget = () => {
|
||||
defaultVal,
|
||||
placeholder: inputSpec.placeholder
|
||||
})
|
||||
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
|
||||
: addSingleLineWidget(node, inputSpec.name, {
|
||||
defaultVal,
|
||||
placeholder: inputSpec.placeholder
|
||||
})
|
||||
|
||||
if (typeof inputSpec.dynamicPrompts === 'boolean') {
|
||||
widget.dynamicPrompts = inputSpec.dynamicPrompts
|
||||
|
||||
65
src/composables/widgets/useStringWidgetVue.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import StringWidget from '@/components/graph/widgets/StringWidget.vue'
|
||||
import {
|
||||
type InputSpec,
|
||||
isStringInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
export const useStringWidgetVue = (options: { defaultValue?: string } = {}) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
if (!isStringInputSpec(inputSpec)) {
|
||||
throw new Error(`Invalid input data: ${inputSpec}`)
|
||||
}
|
||||
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string>(
|
||||
inputSpec.default ?? options.defaultValue ?? ''
|
||||
)
|
||||
|
||||
// Create the Vue-based widget instance
|
||||
const widget = new ComponentWidgetImpl<string>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: StringWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => {
|
||||
return inputSpec.multiline ? 80 + PADDING : 40 + PADDING
|
||||
},
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
})
|
||||
|
||||
// Add dynamic prompts support if specified
|
||||
if (typeof inputSpec.dynamicPrompts === 'boolean') {
|
||||
widget.dynamicPrompts = inputSpec.dynamicPrompts
|
||||
}
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -173,13 +173,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: 'f'
|
||||
},
|
||||
commandId: 'Workspace.ToggleFocusMode'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'e',
|
||||
ctrl: true,
|
||||
shift: true
|
||||
},
|
||||
commandId: 'Comfy.Graph.ConvertToSubgraph'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
@@ -1583,6 +1583,57 @@ export class GroupNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
function addConvertToGroupOptions() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addConvertOption(options, index) {
|
||||
const selected = Object.values(app.canvas.selected_nodes ?? {})
|
||||
const disabled =
|
||||
selected.length < 2 ||
|
||||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
|
||||
options.splice(index, null, {
|
||||
content: `Convert to Group Node`,
|
||||
disabled,
|
||||
callback: convertSelectedNodesToGroupNode
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addManageOption(options, index) {
|
||||
const groups = app.graph.extra?.groupNodes
|
||||
const disabled = !groups || !Object.keys(groups).length
|
||||
options.splice(index, null, {
|
||||
content: `Manage Group Nodes`,
|
||||
disabled,
|
||||
callback: () => manageGroupNodes()
|
||||
})
|
||||
}
|
||||
|
||||
// Add to canvas
|
||||
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getCanvasMenuOptions.apply(this, arguments)
|
||||
const index = options.findIndex((o) => o?.content === 'Add Group')
|
||||
const insertAt = index === -1 ? options.length - 1 : index + 2
|
||||
addConvertOption(options, insertAt)
|
||||
addManageOption(options, insertAt + 1)
|
||||
return options
|
||||
}
|
||||
|
||||
// Add to nodes
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getNodeMenuOptions.apply(this, arguments)
|
||||
if (!GroupNodeHandler.isGroupNode(node)) {
|
||||
const index = options.findIndex((o) => o?.content === 'Properties')
|
||||
const insertAt = index === -1 ? options.length - 1 : index
|
||||
addConvertOption(options, insertAt)
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
||||
@@ -1672,6 +1723,9 @@ const ext: ComfyExtension = {
|
||||
}
|
||||
}
|
||||
],
|
||||
setup() {
|
||||
addConvertToGroupOptions()
|
||||
},
|
||||
async beforeConfigureGraph(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: string[]
|
||||
|
||||
@@ -160,24 +160,45 @@ class Load3d {
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
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.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
|
||||
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
private getActiveCamera(): THREE.Camera {
|
||||
return this.cameraManager.activeCamera
|
||||
}
|
||||
@@ -198,20 +219,17 @@ class Load3d {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
@@ -298,17 +316,18 @@ 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)
|
||||
|
||||
if (this.previewManager.previewRenderer) {
|
||||
this.previewManager.updateBackgroundTexture(
|
||||
this.sceneManager.backgroundTexture
|
||||
)
|
||||
}
|
||||
this.previewManager.updateBackgroundTexture(
|
||||
this.sceneManager.backgroundTexture
|
||||
)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -316,12 +335,9 @@ class Load3d {
|
||||
removeBackgroundImage(): void {
|
||||
this.sceneManager.removeBackgroundImage()
|
||||
|
||||
if (
|
||||
this.previewManager.previewRenderer &&
|
||||
this.previewManager.previewCamera
|
||||
) {
|
||||
this.previewManager.updateBackgroundTexture(null)
|
||||
}
|
||||
this.previewManager.setPreviewBackgroundColor(
|
||||
this.sceneManager.currentBackgroundColor
|
||||
)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -348,10 +364,6 @@ class Load3d {
|
||||
setCameraState(state: CameraState): void {
|
||||
this.cameraManager.setCameraState(state)
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.syncWithMainCamera()
|
||||
}
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,6 @@ class Load3dAnimation extends Load3d {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
|
||||
this.animationManager.update(delta)
|
||||
@@ -54,12 +50,13 @@ class Load3dAnimation extends Load3d {
|
||||
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 = {} as HTMLDivElement
|
||||
showPreview: boolean = true
|
||||
@@ -17,7 +16,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
// @ts-expect-error unused variable
|
||||
private getRenderer: () => THREE.WebGLRenderer
|
||||
|
||||
private previewBackgroundScene: THREE.Scene
|
||||
@@ -25,6 +23,9 @@ 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,
|
||||
@@ -45,15 +46,24 @@ 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)
|
||||
const planeMaterial = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
|
||||
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
|
||||
color: this.currentBackgroundColor.clone(),
|
||||
transparent: false,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
this.previewBackgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
|
||||
this.previewBackgroundMesh = new THREE.Mesh(
|
||||
planeGeometry,
|
||||
this.previewBackgroundColorMaterial
|
||||
)
|
||||
this.previewBackgroundMesh.position.set(0, 0, 0)
|
||||
this.previewBackgroundScene.add(this.previewBackgroundMesh)
|
||||
}
|
||||
@@ -61,40 +71,23 @@ 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()
|
||||
;(this.previewBackgroundMesh.material as THREE.Material).dispose()
|
||||
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
|
||||
this.previewBackgroundMesh.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;
|
||||
@@ -104,7 +97,6 @@ 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
|
||||
@@ -131,7 +123,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
}
|
||||
|
||||
this.updatePreviewSize()
|
||||
this.updatePreviewRender()
|
||||
})
|
||||
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
@@ -159,57 +150,54 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
const previewHeight =
|
||||
(this.previewWidth * this.targetHeight) / this.targetWidth
|
||||
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
|
||||
|
||||
this.previewContainer.style.width = `${this.previewWidth}px`
|
||||
this.previewContainer.style.height = `${previewHeight}px`
|
||||
}
|
||||
|
||||
syncWithMainCamera(): void {
|
||||
if (!this.previewRenderer || !this.previewContainer || !this.showPreview) {
|
||||
return
|
||||
getPreviewViewport(): {
|
||||
left: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
} | null {
|
||||
if (!this.showPreview || !this.previewContainer) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.previewCamera = this.getActiveCamera().clone()
|
||||
const renderer = this.getRenderer()
|
||||
const canvas = renderer.domElement
|
||||
|
||||
this.previewCamera.position.copy(this.getActiveCamera().position)
|
||||
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
|
||||
const containerRect = this.previewContainer.getBoundingClientRect()
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
|
||||
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()
|
||||
if (
|
||||
containerRect.bottom < canvasRect.top ||
|
||||
containerRect.top > canvasRect.bottom ||
|
||||
containerRect.right < canvasRect.left ||
|
||||
containerRect.left > canvasRect.right
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.previewCamera.lookAt(this.getControls().target)
|
||||
const width = parseFloat(this.previewContainer.style.width)
|
||||
const height = parseFloat(this.previewContainer.style.height)
|
||||
|
||||
this.updatePreviewRender()
|
||||
const left = this.getRenderer().domElement.clientWidth - width
|
||||
|
||||
const bottom = 0
|
||||
|
||||
return { left, bottom, width, height }
|
||||
}
|
||||
|
||||
updatePreviewRender(): void {
|
||||
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
|
||||
return
|
||||
renderPreview(): void {
|
||||
const viewport = this.getPreviewViewport()
|
||||
if (!viewport) return
|
||||
|
||||
const renderer = this.getRenderer()
|
||||
|
||||
const originalClearColor = renderer.getClearColor(new THREE.Color())
|
||||
const originalClearAlpha = renderer.getClearAlpha()
|
||||
|
||||
if (
|
||||
!this.previewCamera ||
|
||||
@@ -243,45 +231,77 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
previewOrtho.updateProjectionMatrix()
|
||||
} else {
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).fov = (
|
||||
const activePerspective =
|
||||
this.getActiveCamera() as THREE.PerspectiveCamera
|
||||
).fov
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
|
||||
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
|
||||
|
||||
previewPerspective.fov = activePerspective.fov
|
||||
previewPerspective.zoom = activePerspective.zoom
|
||||
previewPerspective.aspect = aspect
|
||||
|
||||
previewPerspective.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.previewCamera.lookAt(this.getControls().target)
|
||||
|
||||
const previewHeight =
|
||||
(this.previewWidth * this.targetHeight) / this.targetWidth
|
||||
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
|
||||
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.previewRenderer.clear()
|
||||
renderer.setViewport(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
renderer.setScissor(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
|
||||
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
|
||||
renderer.setClearColor(0x000000, 0)
|
||||
renderer.clear()
|
||||
|
||||
this.previewRenderer.toneMapping = THREE.NoToneMapping
|
||||
this.previewRenderer.render(
|
||||
this.previewBackgroundScene,
|
||||
this.previewBackgroundCamera
|
||||
)
|
||||
this.renderPreviewBackground(renderer)
|
||||
|
||||
this.previewRenderer.toneMapping = currentToneMapping
|
||||
this.previewRenderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
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.render(this.scene, this.previewCamera)
|
||||
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
|
||||
|
||||
if (this.previewBackgroundMesh) {
|
||||
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
|
||||
}
|
||||
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
this.previewBackgroundTexture = null
|
||||
}
|
||||
}
|
||||
|
||||
togglePreview(showPreview: boolean): void {
|
||||
if (this.previewRenderer) {
|
||||
this.showPreview = showPreview
|
||||
this.showPreview = showPreview
|
||||
if (this.previewContainer) {
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
}
|
||||
|
||||
@@ -306,7 +326,7 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
)
|
||||
}
|
||||
|
||||
if (this.previewRenderer && this.previewCamera) {
|
||||
if (this.previewCamera) {
|
||||
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.previewCamera.aspect = width / height
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
@@ -322,30 +342,45 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
handleResize(): void {
|
||||
this.updatePreviewSize()
|
||||
this.updatePreviewRender()
|
||||
}
|
||||
|
||||
updateBackgroundTexture(texture: THREE.Texture | null): void {
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
if (texture) {
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
|
||||
this.previewBackgroundTexture = texture
|
||||
this.previewBackgroundTexture = texture
|
||||
|
||||
if (texture && this.previewBackgroundMesh) {
|
||||
const material2 = this.previewBackgroundMesh
|
||||
.material as THREE.MeshBasicMaterial
|
||||
material2.map = texture
|
||||
material2.needsUpdate = true
|
||||
if (this.previewBackgroundMesh) {
|
||||
const imageMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
this.previewBackgroundMesh.position.set(0, 0, 0)
|
||||
if (
|
||||
this.previewBackgroundMesh.material instanceof THREE.Material &&
|
||||
this.previewBackgroundMesh.material !==
|
||||
this.previewBackgroundColorMaterial
|
||||
) {
|
||||
this.previewBackgroundMesh.material.dispose()
|
||||
}
|
||||
|
||||
this.updateBackgroundSize(
|
||||
this.previewBackgroundTexture,
|
||||
this.previewBackgroundMesh,
|
||||
this.targetWidth,
|
||||
this.targetHeight
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ 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
|
||||
|
||||
@@ -40,17 +44,28 @@ 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)
|
||||
const planeMaterial = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
|
||||
this.backgroundColorMaterial = new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(this.currentBackgroundColor),
|
||||
transparent: false,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
|
||||
this.backgroundMesh = new THREE.Mesh(
|
||||
planeGeometry,
|
||||
this.backgroundColorMaterial
|
||||
)
|
||||
this.backgroundMesh.position.set(0, 0, 0)
|
||||
this.backgroundScene.add(this.backgroundMesh)
|
||||
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -60,9 +75,15 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.backgroundTexture.dispose()
|
||||
}
|
||||
|
||||
if (this.backgroundColorMaterial) {
|
||||
this.backgroundColorMaterial.dispose()
|
||||
}
|
||||
|
||||
if (this.backgroundMesh) {
|
||||
this.backgroundMesh.geometry.dispose()
|
||||
;(this.backgroundMesh.material as THREE.Material).dispose()
|
||||
if (this.backgroundMesh.material instanceof THREE.Material) {
|
||||
this.backgroundMesh.material.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
this.scene.clear()
|
||||
@@ -77,18 +98,39 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.renderer.setClearColor(new THREE.Color(color))
|
||||
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.eventManager.emitEvent('backgroundColorChange', color)
|
||||
}
|
||||
|
||||
async setBackgroundImage(uploadPath: string): Promise<void> {
|
||||
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
|
||||
|
||||
if (uploadPath === '') {
|
||||
this.removeBackgroundImage()
|
||||
this.setBackgroundColor(this.currentBackgroundColor)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
|
||||
|
||||
let imageUrl = Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(uploadPath)
|
||||
)
|
||||
@@ -110,12 +152,31 @@ export class SceneManager implements SceneManagerInterface {
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
|
||||
this.backgroundTexture = texture
|
||||
this.currentBackgroundType = 'image'
|
||||
|
||||
const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial
|
||||
material.map = texture
|
||||
material.needsUpdate = true
|
||||
if (!this.backgroundMesh) {
|
||||
this.initBackgroundScene()
|
||||
}
|
||||
|
||||
this.backgroundMesh?.position.set(0, 0, 0)
|
||||
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.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
@@ -129,20 +190,12 @@ 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 {
|
||||
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.setBackgroundColor(this.currentBackgroundColor)
|
||||
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
||||
}
|
||||
|
||||
@@ -172,7 +225,11 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
if (this.backgroundTexture && this.backgroundMesh) {
|
||||
if (
|
||||
this.backgroundTexture &&
|
||||
this.backgroundMesh &&
|
||||
this.currentBackgroundType === 'image'
|
||||
) {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
@@ -183,18 +240,25 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
|
||||
renderBackground(): void {
|
||||
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
|
||||
if (this.backgroundMesh) {
|
||||
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
|
||||
}
|
||||
this.renderer.toneMapping = currentToneMapping
|
||||
this.renderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentBackgroundInfo(): { type: 'color' | 'image'; value: string } {
|
||||
return {
|
||||
type: this.currentBackgroundType,
|
||||
value:
|
||||
this.currentBackgroundType === 'color'
|
||||
? this.currentBackgroundColor
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +274,6 @@ 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)
|
||||
@@ -237,7 +299,11 @@ export class SceneManager implements SceneManagerInterface {
|
||||
orthographicCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
if (this.backgroundTexture && this.backgroundMesh) {
|
||||
if (
|
||||
this.backgroundTexture &&
|
||||
this.backgroundMesh &&
|
||||
this.currentBackgroundType === 'image'
|
||||
) {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
@@ -252,19 +318,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
>()
|
||||
|
||||
this.renderer.clear()
|
||||
|
||||
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.renderBackground()
|
||||
this.renderer.render(this.scene, this.getActiveCamera())
|
||||
const sceneData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
|
||||
@@ -100,18 +100,23 @@ 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 {
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Give Feedback"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convert Selection to Subgraph"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Fit Group To Contents"
|
||||
},
|
||||
|
||||
@@ -230,6 +230,16 @@
|
||||
"black": "Black",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Click to edit color",
|
||||
"selectColor": "Select a color",
|
||||
"formatRGBA": "RGBA",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatHEX": "HEX"
|
||||
}
|
||||
},
|
||||
"contextMenu": {
|
||||
"Inputs": "Inputs",
|
||||
"Outputs": "Outputs",
|
||||
@@ -514,7 +524,8 @@
|
||||
"3D": "3D",
|
||||
"Audio": "Audio",
|
||||
"Image API": "Image API",
|
||||
"Video API": "Video API"
|
||||
"Video API": "Video API",
|
||||
"All": "All Templates"
|
||||
},
|
||||
"templateDescription": {
|
||||
"Basics": {
|
||||
@@ -811,7 +822,6 @@
|
||||
"Export": "Export",
|
||||
"Export (API)": "Export (API)",
|
||||
"Give Feedback": "Give Feedback",
|
||||
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
||||
"Fit Group To Contents": "Fit Group To Contents",
|
||||
"Group Selected Nodes": "Group Selected Nodes",
|
||||
"Convert selected nodes to group node": "Convert selected nodes to group node",
|
||||
@@ -1292,9 +1302,7 @@
|
||||
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
|
||||
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
|
||||
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
|
||||
"nothingSelected": "Nothing selected",
|
||||
"cannotCreateSubgraph": "Cannot create subgraph",
|
||||
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
|
||||
"nothingSelected": "Nothing selected"
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Dar retroalimentación"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir selección en subgrafo"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajustar grupo al contenido"
|
||||
},
|
||||
|
||||
@@ -693,7 +693,6 @@
|
||||
"ComfyUI Forum": "Foro de ComfyUI",
|
||||
"ComfyUI Issues": "Problemas de ComfyUI",
|
||||
"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",
|
||||
@@ -1139,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "Todas las plantillas",
|
||||
"Area Composition": "Composición de Área",
|
||||
"Audio": "Audio",
|
||||
"Basics": "Básicos",
|
||||
@@ -1357,7 +1357,6 @@
|
||||
"title": "Comienza con una Plantilla"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "No se puede crear el subgrafo",
|
||||
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
|
||||
"dropFileError": "No se puede procesar el elemento soltado: {error}",
|
||||
"emptyCanvas": "Lienzo vacío",
|
||||
@@ -1366,7 +1365,6 @@
|
||||
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
|
||||
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
|
||||
"failedToApplyTexture": "Error al aplicar textura",
|
||||
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
|
||||
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
|
||||
"failedToDownloadFile": "Error al descargar el archivo",
|
||||
"failedToExportModel": "Error al exportar modelo como {format}",
|
||||
@@ -1431,6 +1429,16 @@
|
||||
"getStarted": "Empezar",
|
||||
"title": "Bienvenido a ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Haz clic para editar el color",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "Selecciona un color"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Introduzca el nombre del archivo",
|
||||
"exportWorkflow": "Exportar flujo de trabajo",
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Retour d'information"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir la sélection en sous-graphe"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajuster le groupe au contenu"
|
||||
},
|
||||
|
||||
@@ -693,7 +693,6 @@
|
||||
"ComfyUI Forum": "Forum ComfyUI",
|
||||
"ComfyUI Issues": "Problèmes de ComfyUI",
|
||||
"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",
|
||||
@@ -1139,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "Tous les modèles",
|
||||
"Area Composition": "Composition de zone",
|
||||
"Audio": "Audio",
|
||||
"Basics": "Basiques",
|
||||
@@ -1357,7 +1357,6 @@
|
||||
"title": "Commencez avec un modèle"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Impossible de créer le sous-graphe",
|
||||
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",
|
||||
"dropFileError": "Impossible de traiter l'élément déposé : {error}",
|
||||
"emptyCanvas": "Toile vide",
|
||||
@@ -1366,7 +1365,6 @@
|
||||
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Échec de l'accès au portail de facturation : {error}",
|
||||
"failedToApplyTexture": "Échec de l'application de la texture",
|
||||
"failedToConvertToSubgraph": "Échec de la conversion des éléments en sous-graphe",
|
||||
"failedToCreateCustomer": "Échec de la création du client : {error}",
|
||||
"failedToDownloadFile": "Échec du téléchargement du fichier",
|
||||
"failedToExportModel": "Échec de l'exportation du modèle en {format}",
|
||||
@@ -1431,6 +1429,16 @@
|
||||
"getStarted": "Commencer",
|
||||
"title": "Bienvenue sur ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Cliquez pour modifier la couleur",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "Sélectionnez une couleur"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Entrez le nom du fichier",
|
||||
"exportWorkflow": "Exporter le flux de travail",
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "フィードバック"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "選択範囲をサブグラフに変換"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "グループを内容に合わせて調整"
|
||||
},
|
||||
|
||||
@@ -693,7 +693,6 @@
|
||||
"ComfyUI Forum": "ComfyUI フォーラム",
|
||||
"ComfyUI Issues": "ComfyUIの問題",
|
||||
"Contact Support": "サポートに連絡",
|
||||
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
|
||||
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
|
||||
"Custom Nodes Manager": "カスタムノードマネージャ",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
@@ -1139,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "すべてのテンプレート",
|
||||
"Area Composition": "エリア構成",
|
||||
"Audio": "オーディオ",
|
||||
"Basics": "基本",
|
||||
@@ -1357,7 +1357,6 @@
|
||||
"title": "テンプレートを利用して開始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "サブグラフを作成できません",
|
||||
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",
|
||||
"dropFileError": "ドロップされたアイテムを処理できません: {error}",
|
||||
"emptyCanvas": "キャンバスが空です",
|
||||
@@ -1366,7 +1365,6 @@
|
||||
"errorSaveSetting": "設定{id}の保存エラー: {err}",
|
||||
"failedToAccessBillingPortal": "請求ポータルへのアクセスに失敗しました: {error}",
|
||||
"failedToApplyTexture": "テクスチャの適用に失敗しました",
|
||||
"failedToConvertToSubgraph": "アイテムをサブグラフに変換できませんでした",
|
||||
"failedToCreateCustomer": "顧客の作成に失敗しました: {error}",
|
||||
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
|
||||
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",
|
||||
@@ -1431,6 +1429,16 @@
|
||||
"getStarted": "はじめる",
|
||||
"title": "ComfyUIへようこそ"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "色を編集するにはクリックしてください",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "色を選択"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "ファイル名を入力",
|
||||
"exportWorkflow": "ワークフローをエクスポート",
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "피드백"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "선택 영역을 서브그래프로 변환"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "그룹을 내용에 맞게 맞추기"
|
||||
},
|
||||
|
||||
@@ -693,7 +693,6 @@
|
||||
"ComfyUI Forum": "ComfyUI 포럼",
|
||||
"ComfyUI Issues": "ComfyUI 이슈 페이지",
|
||||
"Contact Support": "고객 지원 문의",
|
||||
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
|
||||
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
|
||||
"Custom Nodes Manager": "사용자 정의 노드 관리자",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
@@ -1139,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "모든 템플릿",
|
||||
"Area Composition": "영역 구성",
|
||||
"Audio": "오디오",
|
||||
"Basics": "기본",
|
||||
@@ -1357,7 +1357,6 @@
|
||||
"title": "템플릿으로 시작하기"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "서브그래프를 생성할 수 없습니다",
|
||||
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",
|
||||
"dropFileError": "드롭된 항목을 처리할 수 없습니다: {error}",
|
||||
"emptyCanvas": "빈 캔버스",
|
||||
@@ -1366,7 +1365,6 @@
|
||||
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
|
||||
"failedToAccessBillingPortal": "결제 포털에 접근하지 못했습니다: {error}",
|
||||
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
|
||||
"failedToConvertToSubgraph": "항목을 서브그래프로 변환하지 못했습니다",
|
||||
"failedToCreateCustomer": "고객 생성에 실패했습니다: {error}",
|
||||
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
|
||||
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",
|
||||
@@ -1431,6 +1429,16 @@
|
||||
"getStarted": "시작하기",
|
||||
"title": "ComfyUI에 오신 것을 환영합니다"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "색상 편집하려면 클릭하세요",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "색상 선택"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "파일 이름 입력",
|
||||
"exportWorkflow": "워크플로 내보내기",
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Обратная связь"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Преобразовать выделенное в подграф"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Подогнать группу к содержимому"
|
||||
},
|
||||
|
||||
@@ -693,7 +693,6 @@
|
||||
"ComfyUI Forum": "Форум ComfyUI",
|
||||
"ComfyUI Issues": "Проблемы ComfyUI",
|
||||
"Contact Support": "Связаться с поддержкой",
|
||||
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
|
||||
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
|
||||
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
|
||||
"Delete Selected Items": "Удалить выбранные элементы",
|
||||
@@ -1139,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "Все шаблоны",
|
||||
"Area Composition": "Композиция области",
|
||||
"Audio": "Аудио",
|
||||
"Basics": "Основы",
|
||||
@@ -1357,7 +1357,6 @@
|
||||
"title": "Начните с шаблона"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Невозможно создать подграф",
|
||||
"couldNotDetermineFileType": "Не удалось определить тип файла",
|
||||
"dropFileError": "Не удалось обработать перетаскиваемый элемент: {error}",
|
||||
"emptyCanvas": "Пустой холст",
|
||||
@@ -1366,7 +1365,6 @@
|
||||
"errorSaveSetting": "Ошибка сохранения настройки {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Не удалось получить доступ к биллинговому порталу: {error}",
|
||||
"failedToApplyTexture": "Не удалось применить текстуру",
|
||||
"failedToConvertToSubgraph": "Не удалось преобразовать элементы в подграф",
|
||||
"failedToCreateCustomer": "Не удалось создать клиента: {error}",
|
||||
"failedToDownloadFile": "Не удалось скачать файл",
|
||||
"failedToExportModel": "Не удалось экспортировать модель как {format}",
|
||||
@@ -1431,6 +1429,16 @@
|
||||
"getStarted": "Начать",
|
||||
"title": "Добро пожаловать в ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "Нажмите, чтобы изменить цвет",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "Выберите цвет"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Введите название файла",
|
||||
"exportWorkflow": "Экспорт рабочего процесса",
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "反馈"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "将选区转换为子图"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "适应节点框到内容"
|
||||
},
|
||||
|
||||
@@ -693,7 +693,6 @@
|
||||
"ComfyUI Forum": "ComfyUI 论坛",
|
||||
"ComfyUI Issues": "ComfyUI 问题",
|
||||
"Contact Support": "联系支持",
|
||||
"Convert Selection to Subgraph": "将选中内容转换为子图",
|
||||
"Convert selected nodes to group node": "将选中节点转换为组节点",
|
||||
"Custom Nodes Manager": "自定义节点管理器",
|
||||
"Delete Selected Items": "删除选定的项目",
|
||||
@@ -1139,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "所有模板",
|
||||
"Area Composition": "区域组成",
|
||||
"Audio": "音频",
|
||||
"Basics": "基础",
|
||||
@@ -1357,7 +1357,6 @@
|
||||
"title": "从模板开始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "无法创建子图",
|
||||
"couldNotDetermineFileType": "无法确定文件类型",
|
||||
"dropFileError": "无法处理掉落的项目:{error}",
|
||||
"emptyCanvas": "画布为空",
|
||||
@@ -1366,7 +1365,6 @@
|
||||
"errorSaveSetting": "保存设置 {id} 出错:{err}",
|
||||
"failedToAccessBillingPortal": "访问账单门户失败:{error}",
|
||||
"failedToApplyTexture": "应用纹理失败",
|
||||
"failedToConvertToSubgraph": "无法将项目转换为子图",
|
||||
"failedToCreateCustomer": "创建客户失败:{error}",
|
||||
"failedToDownloadFile": "文件下载失败",
|
||||
"failedToExportModel": "无法将模型导出为 {format}",
|
||||
@@ -1431,6 +1429,16 @@
|
||||
"getStarted": "开始使用",
|
||||
"title": "欢迎使用 ComfyUI"
|
||||
},
|
||||
"widgets": {
|
||||
"colorPicker": {
|
||||
"clickToEdit": "点击编辑颜色",
|
||||
"formatHEX": "HEX",
|
||||
"formatHSLA": "HSLA",
|
||||
"formatHSVA": "HSVA",
|
||||
"formatRGBA": "RGBA",
|
||||
"selectColor": "选择颜色"
|
||||
}
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "输入文件名",
|
||||
"exportWorkflow": "导出工作流",
|
||||
|
||||
@@ -41,10 +41,10 @@ const zModelFile = z.object({
|
||||
|
||||
const zGraphState = z
|
||||
.object({
|
||||
lastGroupId: z.number(),
|
||||
lastNodeId: z.number(),
|
||||
lastLinkId: z.number(),
|
||||
lastRerouteId: z.number()
|
||||
lastGroupid: z.number().optional(),
|
||||
lastNodeId: z.number().optional(),
|
||||
lastLinkId: z.number().optional(),
|
||||
lastRerouteId: z.number().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -214,32 +214,6 @@ const zComfyNode = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zSubgraphIO = zNodeInput.extend({
|
||||
/** Slot ID (internal; never changes once instantiated). */
|
||||
id: z.string().uuid(),
|
||||
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
|
||||
type: z.string(),
|
||||
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
|
||||
linkIds: z.array(z.number()).optional()
|
||||
})
|
||||
|
||||
const zSubgraphInstance = z
|
||||
.object({
|
||||
id: zNodeId,
|
||||
type: z.string().uuid(),
|
||||
pos: zVector2,
|
||||
size: zVector2,
|
||||
flags: zFlags,
|
||||
order: z.number(),
|
||||
mode: z.number(),
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
widgets_values: zWidgetValues.optional(),
|
||||
color: z.string().optional(),
|
||||
bgcolor: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zGroup = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
@@ -274,22 +248,9 @@ const zExtra = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zGraphDefinitions = z.object({
|
||||
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
|
||||
})
|
||||
|
||||
export const zBaseExportableGraph = z.object({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
config: zConfig.optional().nullable(),
|
||||
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
|
||||
subgraphs: z.array(zSubgraphInstance).optional()
|
||||
})
|
||||
|
||||
/** Schema version 0.4 */
|
||||
export const zComfyWorkflow = zBaseExportableGraph
|
||||
.extend({
|
||||
export const zComfyWorkflow = z
|
||||
.object({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
last_node_id: zNodeId,
|
||||
@@ -301,47 +262,13 @@ export const zComfyWorkflow = zBaseExportableGraph
|
||||
config: zConfig.optional().nullable(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
version: z.number(),
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: zGraphDefinitions.optional()
|
||||
models: z.array(zModelFile).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/** Required for recursive definition of subgraphs. */
|
||||
interface ComfyWorkflow1BaseType {
|
||||
id?: string
|
||||
revision?: number
|
||||
version: 1
|
||||
models?: z.infer<typeof zModelFile>[]
|
||||
state: z.infer<typeof zGraphState>
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.input<typeof zGroup>[]
|
||||
nodes: z.input<typeof zComfyNode>[]
|
||||
links?: z.input<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.input<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.input<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.output<typeof zGroup>[]
|
||||
nodes: z.output<typeof zComfyNode>[]
|
||||
links?: z.output<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.output<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.output<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema version 1 */
|
||||
export const zComfyWorkflow1 = zBaseExportableGraph
|
||||
.extend({
|
||||
export const zComfyWorkflow1 = z
|
||||
.object({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
version: z.literal(1),
|
||||
@@ -353,96 +280,7 @@ export const zComfyWorkflow1 = zBaseExportableGraph
|
||||
floatingLinks: z.array(zComfyLinkObject).optional(),
|
||||
reroutes: z.array(zReroute).optional(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => z.array(zSubgraphDefinition)
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zExportedSubgraphIONode = z.object({
|
||||
id: zNodeId,
|
||||
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
||||
pinned: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zExposedWidget = z.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
interface SubgraphDefinitionBase<
|
||||
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
|
||||
> {
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
outputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExposedWidget>[]
|
||||
: z.output<typeof zExposedWidget>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<T>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** A subgraph definition `worfklow.definitions.subgraphs` */
|
||||
export const zSubgraphDefinition = zComfyWorkflow1
|
||||
.extend({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets: z.array(zExposedWidget).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => zSubgraphDefinition.array()
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
models: z.array(zModelFile).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
||||
@@ -30,14 +30,18 @@ import {
|
||||
isComboInputSpecV1,
|
||||
isComboInputSpecV2
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
|
||||
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
|
||||
import { getMp3Metadata } from '@/scripts/metadata/mp3'
|
||||
import { getOggMetadata } from '@/scripts/metadata/ogg'
|
||||
import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -54,7 +58,6 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import { ExtensionManager } from '@/types/extensionTypes'
|
||||
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { getFileHandler } from '@/utils/fileHandlers'
|
||||
import {
|
||||
executeWidgetsCallback,
|
||||
fixLinkInputSlots,
|
||||
@@ -69,7 +72,14 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
|
||||
import { type ComfyApi, PromptExecutionError, api } from './api'
|
||||
import { defaultGraph } from './defaultGraph'
|
||||
import { importA1111 } from './pnginfo'
|
||||
import { pruneWidgets } from './domWidget'
|
||||
import {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata,
|
||||
importA1111
|
||||
} from './pnginfo'
|
||||
import { $el, ComfyUI } from './ui'
|
||||
import { ComfyAppMenu } from './ui/menu/index'
|
||||
import { clone } from './utils'
|
||||
@@ -705,23 +715,25 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
#addAfterConfigureHandler() {
|
||||
const { graph } = this
|
||||
const { onConfigure } = graph
|
||||
graph.onConfigure = function (...args) {
|
||||
const app = this
|
||||
const onConfigure = app.graph.onConfigure
|
||||
app.graph.onConfigure = function (this: LGraph, ...args) {
|
||||
fixLinkInputSlots(this)
|
||||
|
||||
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
||||
for (const node of graph.nodes) {
|
||||
for (const node of app.graph.nodes) {
|
||||
node.onGraphConfigured?.()
|
||||
}
|
||||
|
||||
const r = onConfigure?.apply(this, args)
|
||||
|
||||
// Fire after onConfigure, used by primitives to generate widget using input nodes config
|
||||
for (const node of graph.nodes) {
|
||||
for (const node of app.graph.nodes) {
|
||||
node.onAfterGraphConfigured?.()
|
||||
}
|
||||
|
||||
pruneWidgets(this.nodes)
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -753,21 +765,6 @@ export class ComfyApp {
|
||||
|
||||
this.#graph = new LGraph()
|
||||
|
||||
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
|
||||
this.graph.events.addEventListener('subgraph-created', (e) => {
|
||||
try {
|
||||
const { subgraph, data } = e.detail
|
||||
useSubgraphService().registerNewSubgraph(subgraph, data)
|
||||
} catch (err) {
|
||||
console.error('Failed to register subgraph', err)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: 'Failed to register subgraph',
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.#addAfterConfigureHandler()
|
||||
|
||||
this.canvas = new LGraphCanvas(canvasEl, this.graph)
|
||||
@@ -780,30 +777,6 @@ export class ComfyApp {
|
||||
LiteGraph.alt_drag_do_clone_nodes = true
|
||||
LiteGraph.macGesturesRequireMac = false
|
||||
|
||||
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
|
||||
'litegraph:set-graph',
|
||||
(e) => {
|
||||
// Assertion: Not yet defined in litegraph.
|
||||
const { newGraph } = e.detail
|
||||
|
||||
const nodeSet = new Set(newGraph.nodes)
|
||||
const widgetStore = useDomWidgetStore()
|
||||
|
||||
// Assertions: UnwrapRef
|
||||
for (const { widget } of widgetStore.activeWidgetStates) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
widgetStore.deactivateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { widget } of widgetStore.inactiveWidgetStates) {
|
||||
if (nodeSet.has(widget.node)) {
|
||||
widgetStore.activateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
this.graph.start()
|
||||
|
||||
// Ensure the canvas fills the window
|
||||
@@ -1040,7 +1013,6 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
useSubgraphService().loadSubgraphs(graphData)
|
||||
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
const missingModels: ModelFile[] = []
|
||||
@@ -1238,9 +1210,6 @@ export class ComfyApp {
|
||||
// Allow widgets to run callbacks before a prompt has been queued
|
||||
// e.g. random seed before every gen
|
||||
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
|
||||
}
|
||||
|
||||
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
|
||||
try {
|
||||
@@ -1283,13 +1252,9 @@ export class ComfyApp {
|
||||
executeWidgetsCallback(
|
||||
p.workflow.nodes
|
||||
.map((n) => this.graph.getNodeById(n.id))
|
||||
.filter((n) => !!n),
|
||||
.filter((n) => !!n) as LGraphNode[],
|
||||
'afterQueued'
|
||||
)
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
|
||||
}
|
||||
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
@@ -1319,44 +1284,161 @@ export class ComfyApp {
|
||||
return f.substring(0, p)
|
||||
}
|
||||
const fileName = removeExt(file.name)
|
||||
|
||||
// Get the appropriate file handler for this file type
|
||||
const fileHandler = getFileHandler(file)
|
||||
|
||||
if (!fileHandler) {
|
||||
// No handler found for this file type
|
||||
this.showErrorOnFileLoad(file)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Process the file using the handler
|
||||
const { workflow, prompt, parameters, jsonTemplateData } =
|
||||
await fileHandler(file)
|
||||
|
||||
if (workflow) {
|
||||
// We have a workflow, load it
|
||||
await this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
// We have a prompt in API format, load it
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else if (parameters) {
|
||||
// We have A1111 parameters, import them
|
||||
if (file.type === 'image/png') {
|
||||
const pngInfo = await getPngMetadata(file)
|
||||
if (pngInfo?.workflow) {
|
||||
await this.loadGraphData(
|
||||
JSON.parse(pngInfo.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
} else if (pngInfo?.prompt) {
|
||||
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
|
||||
} else if (pngInfo?.parameters) {
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
importA1111(this.graph, parameters)
|
||||
importA1111(this.graph, pngInfo.parameters)
|
||||
useWorkflowService().afterLoadNewGraph(
|
||||
fileName,
|
||||
this.graph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
} else if (jsonTemplateData) {
|
||||
// We have template data from JSON
|
||||
this.loadTemplateData(jsonTemplateData)
|
||||
} else {
|
||||
// No usable data found in the file
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error)
|
||||
} else if (file.type === 'image/webp') {
|
||||
const pngInfo = await getWebpMetadata(file)
|
||||
// Support loading workflows from that webp custom node.
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/mpeg') {
|
||||
const { workflow, prompt } = await getMp3Metadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/ogg') {
|
||||
const { workflow, prompt } = await getOggMetadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') {
|
||||
const pngInfo = await getFlacMetadata(file)
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'video/webm') {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
if (webmInfo.workflow) {
|
||||
this.loadGraphData(webmInfo.workflow, true, true, fileName)
|
||||
} else if (webmInfo.prompt) {
|
||||
this.loadApiJson(webmInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'video/mp4' ||
|
||||
file.name?.endsWith('.mp4') ||
|
||||
file.name?.endsWith('.mov') ||
|
||||
file.name?.endsWith('.m4v') ||
|
||||
file.type === 'video/quicktime' ||
|
||||
file.type === 'video/x-m4v'
|
||||
) {
|
||||
const mp4Info = await getFromIsobmffFile(file)
|
||||
if (mp4Info.workflow) {
|
||||
this.loadGraphData(mp4Info.workflow, true, true, fileName)
|
||||
} else if (mp4Info.prompt) {
|
||||
this.loadApiJson(mp4Info.prompt, fileName)
|
||||
}
|
||||
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
|
||||
const svgInfo = await getSvgMetadata(file)
|
||||
if (svgInfo.workflow) {
|
||||
this.loadGraphData(svgInfo.workflow, true, true, fileName)
|
||||
} else if (svgInfo.prompt) {
|
||||
this.loadApiJson(svgInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'model/gltf-binary' ||
|
||||
file.name?.endsWith('.glb')
|
||||
) {
|
||||
const gltfInfo = await getGltfBinaryMetadata(file)
|
||||
if (gltfInfo.workflow) {
|
||||
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
|
||||
} else if (gltfInfo.prompt) {
|
||||
this.loadApiJson(gltfInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'application/json' ||
|
||||
file.name?.endsWith('.json')
|
||||
) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
if (jsonContent?.templates) {
|
||||
this.loadTemplateData(jsonContent)
|
||||
} else if (this.isApiJson(jsonContent)) {
|
||||
this.loadApiJson(jsonContent, fileName)
|
||||
} else {
|
||||
await this.loadGraphData(
|
||||
JSON.parse(readerResult),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
} else if (
|
||||
file.name?.endsWith('.latent') ||
|
||||
file.name?.endsWith('.safetensors')
|
||||
) {
|
||||
const info = await getLatentMetadata(file)
|
||||
// TODO define schema to LatentMetadata
|
||||
// @ts-expect-error
|
||||
if (info.workflow) {
|
||||
await this.loadGraphData(
|
||||
// @ts-expect-error
|
||||
JSON.parse(info.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
// @ts-expect-error
|
||||
} else if (info.prompt) {
|
||||
// @ts-expect-error
|
||||
this.loadApiJson(JSON.parse(info.prompt))
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
}
|
||||
@@ -1473,7 +1555,6 @@ export class ComfyApp {
|
||||
/**
|
||||
* Registers a Comfy web extension with the app
|
||||
* @param {ComfyExtension} extension
|
||||
* @deprecated Use useExtensionService().registerExtension instead
|
||||
*/
|
||||
registerExtension(extension: ComfyExtension) {
|
||||
useExtensionService().registerExtension(extension)
|
||||
@@ -1569,8 +1650,6 @@ export class ComfyApp {
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
|
||||
useDomWidgetStore().clear()
|
||||
}
|
||||
|
||||
clientPosToCanvasPos(pos: Vector2): Vector2 {
|
||||
|
||||