Merge branch 'main' into pysssss/asset-delete-progress
@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
|
||||
echo " pnpm test:unit - Run unit tests"
|
||||
echo " pnpm typecheck - Run TypeScript checks"
|
||||
echo " pnpm lint - Run ESLint"
|
||||
echo " pnpm format - Format code with Prettier"
|
||||
echo " pnpm format - Format code with oxfmt"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Run 'pnpm dev' to start developing"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: Creating unit tests
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Creating unit tests
|
||||
|
||||
- This project uses `vitest` for unit testing
|
||||
- Tests are stored in the `test/` directory
|
||||
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
|
||||
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
|
||||
- Tests should be mocked properly
|
||||
- Mocks should be cleanly written and easy to understand
|
||||
- Mocks should be re-usable where possible
|
||||
|
||||
## Unit test style
|
||||
|
||||
- Prefer the use of `test.extend` over loose variables
|
||||
- To achieve this, import `test as baseTest` from `vitest`
|
||||
- Never use `it`; `test` should be used in place of this
|
||||
14
.github/AGENTS.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# PR Review Context
|
||||
|
||||
Context for automated PR review system.
|
||||
|
||||
## Review Scope
|
||||
|
||||
This automated review performs comprehensive analysis:
|
||||
- Architecture and design patterns
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Code quality and maintainability
|
||||
- Integration concerns
|
||||
|
||||
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
||||
39
.github/CLAUDE.md
vendored
@@ -1,36 +1,3 @@
|
||||
# ComfyUI Frontend - Claude Review Context
|
||||
|
||||
This file provides additional context for the automated PR review system.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### PrimeVue Component Migrations
|
||||
|
||||
When reviewing, flag these deprecated components:
|
||||
- `Dropdown` → Use `Select` from 'primevue/select'
|
||||
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
|
||||
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
|
||||
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
|
||||
- `Sidebar` → Use `Drawer` 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
|
||||
|
||||
### API Utilities Reference
|
||||
|
||||
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
|
||||
- `api.fileURL()` - Static file access (templates, extensions)
|
||||
- `$t()` / `i18n.global.t()` - Internationalization
|
||||
- `DOMPurify.sanitize()` - HTML sanitization
|
||||
|
||||
## Review Scope
|
||||
|
||||
This automated review performs comprehensive analysis including:
|
||||
- Architecture and design patterns
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Code quality and maintainability
|
||||
- Integration concerns
|
||||
|
||||
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
||||
<!-- A rose by any other name would smell as sweet,
|
||||
But Claude insists on files named for its own conceit. -->
|
||||
@AGENTS.md
|
||||
|
||||
6
.github/workflows/ci-lint-format.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Run Stylelint with auto-fix
|
||||
run: pnpm stylelint:fix
|
||||
|
||||
- name: Run Prettier with auto-format
|
||||
- name: Run oxfmt with auto-format
|
||||
run: pnpm format
|
||||
|
||||
- name: Check for changes
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add .
|
||||
git commit -m "[automated] Apply ESLint and Prettier fixes"
|
||||
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
|
||||
git push
|
||||
|
||||
- name: Final validation
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
|
||||
})
|
||||
|
||||
- name: Comment on PR about manual fix needed
|
||||
|
||||
18
.i18nrc.cjs
@@ -1,7 +1,7 @@
|
||||
// This file is intentionally kept in CommonJS format (.cjs)
|
||||
// to resolve compatibility issues with dependencies that require CommonJS.
|
||||
// Do not convert this file to ESModule format unless all dependencies support it.
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
const { defineConfig } = require('@lobehub/i18n-cli')
|
||||
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4.1',
|
||||
@@ -10,7 +10,19 @@ module.exports = defineConfig({
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
|
||||
outputLocales: [
|
||||
'zh',
|
||||
'zh-TW',
|
||||
'ru',
|
||||
'ja',
|
||||
'ko',
|
||||
'fr',
|
||||
'es',
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
@@ -26,4 +38,4 @@ module.exports = defineConfig({
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
`
|
||||
});
|
||||
})
|
||||
|
||||
20
.oxfmtrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": [
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/*.md",
|
||||
"**/*.json",
|
||||
"**/*.css",
|
||||
"**/*.yaml",
|
||||
"**/*.yml",
|
||||
"**/*.html",
|
||||
"**/*.svg",
|
||||
"**/*.xml"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
11
.prettierrc
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
|
||||
}
|
||||
17
.storybook/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Storybook Guidelines
|
||||
|
||||
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
|
||||
|
||||
## Available Context
|
||||
|
||||
Stories have access to:
|
||||
- All ComfyUI stores
|
||||
- PrimeVue with ComfyUI theming
|
||||
- i18n system
|
||||
- CSS variables and styling
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Import Errors**: Verify `@/` alias works
|
||||
2. **Missing Styles**: Check CSS imports in `preview.ts`
|
||||
3. **Store Errors**: Check store initialization in setup
|
||||
@@ -1,197 +1,3 @@
|
||||
# Storybook Development Guidelines for Claude
|
||||
|
||||
## Quick Commands
|
||||
|
||||
- `pnpm storybook`: Start Storybook development server
|
||||
- `pnpm build-storybook`: Build static Storybook
|
||||
- `pnpm test:unit`: Run unit tests (includes Storybook components)
|
||||
|
||||
## Development Workflow for Storybook
|
||||
|
||||
1. **Creating New Stories**:
|
||||
- Place `*.stories.ts` files alongside components
|
||||
- Follow the naming pattern: `ComponentName.stories.ts`
|
||||
- Use realistic mock data that matches ComfyUI schemas
|
||||
|
||||
2. **Testing Stories**:
|
||||
- Verify stories render correctly in Storybook UI
|
||||
- Test different component states and edge cases
|
||||
- Ensure proper theming and styling
|
||||
|
||||
3. **Code Quality**:
|
||||
- Run `pnpm typecheck` to verify TypeScript
|
||||
- Run `pnpm lint` to check for linting issues
|
||||
- Follow existing story patterns and conventions
|
||||
|
||||
## Story Creation Guidelines
|
||||
|
||||
### Basic Story Structure
|
||||
|
||||
```typescript
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
const meta: Meta<typeof ComponentName> = {
|
||||
title: 'Category/ComponentName',
|
||||
component: ComponentName,
|
||||
parameters: {
|
||||
layout: 'centered' // or 'fullscreen', 'padded'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// Component props
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mock Data Patterns
|
||||
|
||||
For ComfyUI components, use realistic mock data:
|
||||
|
||||
```typescript
|
||||
// Node definition mock
|
||||
const mockNodeDef = {
|
||||
input: {
|
||||
required: {
|
||||
prompt: ["STRING", { multiline: true }]
|
||||
}
|
||||
},
|
||||
output: ["CONDITIONING"],
|
||||
output_is_list: [false],
|
||||
category: "conditioning"
|
||||
}
|
||||
|
||||
// Component instance mock
|
||||
const mockComponent = {
|
||||
id: "1",
|
||||
type: "CLIPTextEncode",
|
||||
// ... other properties
|
||||
}
|
||||
```
|
||||
|
||||
### Common Story Variants
|
||||
|
||||
Always include these story variants when applicable:
|
||||
|
||||
- **Default**: Basic component with minimal props
|
||||
- **WithData**: Component with realistic data
|
||||
- **Loading**: Component in loading state
|
||||
- **Error**: Component with error state
|
||||
- **LongContent**: Component with edge case content
|
||||
- **Empty**: Component with no data
|
||||
|
||||
### Storybook-Specific Code Patterns
|
||||
|
||||
#### Store Access
|
||||
```typescript
|
||||
// In stories, access stores through the setup function
|
||||
export const WithStore: Story = {
|
||||
render: () => ({
|
||||
setup() {
|
||||
const store = useMyStore()
|
||||
return { store }
|
||||
},
|
||||
template: '<MyComponent :data="store.data" />'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### Event Testing
|
||||
```typescript
|
||||
export const WithEvents: Story = {
|
||||
args: {
|
||||
onUpdate: fn() // Use Storybook's fn() for action logging
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Notes
|
||||
|
||||
### Vue App Setup
|
||||
The Storybook preview is configured with:
|
||||
- Pinia stores initialized
|
||||
- PrimeVue with ComfyUI theme
|
||||
- i18n internationalization
|
||||
- All necessary CSS imports
|
||||
|
||||
### Build Configuration
|
||||
- Vite integration with proper alias resolution
|
||||
- Manual chunking for better performance
|
||||
- TypeScript support with strict checking
|
||||
- CSS processing for Vue components
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Import Errors**: Verify `@/` alias is working correctly
|
||||
2. **Missing Styles**: Ensure CSS imports are in `preview.ts`
|
||||
3. **Store Errors**: Check store initialization in setup
|
||||
4. **Type Errors**: Use proper TypeScript types for story args
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check TypeScript issues
|
||||
pnpm typecheck
|
||||
|
||||
# Lint Storybook files
|
||||
pnpm lint .storybook/
|
||||
|
||||
# Build to check for production issues
|
||||
pnpm build-storybook
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
.storybook/
|
||||
├── main.ts # Core configuration
|
||||
├── preview.ts # Global setup and decorators
|
||||
├── README.md # User documentation
|
||||
└── CLAUDE.md # This file - Claude guidelines
|
||||
|
||||
src/
|
||||
├── components/
|
||||
│ └── MyComponent/
|
||||
│ ├── MyComponent.vue
|
||||
│ └── MyComponent.stories.ts
|
||||
```
|
||||
|
||||
## Integration with ComfyUI
|
||||
|
||||
### Available Context
|
||||
|
||||
Stories have access to:
|
||||
- All ComfyUI stores (widgetStore, colorPaletteStore, etc.)
|
||||
- PrimeVue components with ComfyUI theming
|
||||
- Internationalization system
|
||||
- ComfyUI CSS variables and styling
|
||||
|
||||
### Testing Components
|
||||
|
||||
When testing ComfyUI-specific components:
|
||||
1. Use realistic node definitions and data structures
|
||||
2. Test with different node types (sampling, conditioning, etc.)
|
||||
3. Verify proper CSS theming and dark/light modes
|
||||
4. Check component behavior with various input combinations
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Use manual chunking for large dependencies
|
||||
- Minimize bundle size by avoiding unnecessary imports
|
||||
- Leverage Storybook's lazy loading capabilities
|
||||
- Profile build times and optimize as needed
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep Stories Focused**: Each story should demonstrate one specific use case
|
||||
2. **Use Descriptive Names**: Story names should clearly indicate what they show
|
||||
3. **Document Complex Props**: Use JSDoc comments for complex prop types
|
||||
4. **Test Edge Cases**: Create stories for unusual but valid use cases
|
||||
5. **Maintain Consistency**: Follow established patterns in existing stories
|
||||
<!-- Though standards bloom in open fields so wide,
|
||||
Anthropic walks a path of lonely pride. -->
|
||||
@AGENTS.md
|
||||
|
||||
@@ -96,15 +96,15 @@ const config: StorybookConfig = {
|
||||
}
|
||||
]
|
||||
},
|
||||
esbuild: {
|
||||
// Prevent minification of identifiers to preserve _sfc_main
|
||||
minifyIdentifiers: false,
|
||||
keepNames: true
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
|
||||
rolldownOptions: {
|
||||
experimental: {
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
if (
|
||||
|
||||
15
.vscode/extensions.json
vendored
@@ -1,25 +1,22 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"antfu.vite",
|
||||
"austenc.tailwind-docs",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"donjayamanne.githistory",
|
||||
"eamodio.gitlens",
|
||||
"esbenp.prettier-vscode",
|
||||
"figma.figma-vscode-extension",
|
||||
"github.vscode-github-actions",
|
||||
"github.vscode-pull-request-github",
|
||||
"hbenl.vscode-test-explorer",
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"lokalise.i18n-ally",
|
||||
"ms-playwright.playwright",
|
||||
"oxc.oxc-vscode",
|
||||
"sonarsource.sonarlint-vscode",
|
||||
"vitest.explorer",
|
||||
"vue.volar",
|
||||
"sonarsource.sonarlint-vscode",
|
||||
"deque-systems.vscode-axe-linter",
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"donjayamanne.githistory",
|
||||
"wix.vscode-import-cost",
|
||||
"prograhammer.tslint-vue",
|
||||
"antfu.vite"
|
||||
"wix.vscode-import-cost"
|
||||
]
|
||||
}
|
||||
|
||||
25
AGENTS.md
@@ -1,5 +1,7 @@
|
||||
# Repository Guidelines
|
||||
|
||||
See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob).
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source: `src/`
|
||||
@@ -25,10 +27,10 @@
|
||||
- Build output: `dist/`
|
||||
- Configs
|
||||
- `vite.config.mts`
|
||||
- `vitest.config.ts`
|
||||
- `playwright.config.ts`
|
||||
- `eslint.config.ts`
|
||||
- `.prettierrc`
|
||||
- `.oxfmtrc.json`
|
||||
- `.oxlintrc.json`
|
||||
- etc.
|
||||
|
||||
## Monorepo Architecture
|
||||
@@ -44,8 +46,23 @@ The project uses **Nx** for build orchestration and task management
|
||||
- `pnpm test:unit`: Run Vitest unit tests
|
||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm format` / `pnpm format:check`: Prettier
|
||||
- `pnpm format` / `pnpm format:check`: oxfmt
|
||||
- `pnpm typecheck`: Vue TSC type checking
|
||||
- `pnpm storybook`: Start Storybook development server
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Make code changes
|
||||
2. Run relevant tests
|
||||
3. Run `pnpm typecheck`, `pnpm lint`, `pnpm format`
|
||||
4. Check if README updates are needed
|
||||
5. Suggest docs.comfy.org updates for user-facing changes
|
||||
|
||||
## Git Conventions
|
||||
|
||||
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
||||
- Add "Fixes #n" to PR descriptions
|
||||
- Never mention Claude/AI in commits
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
@@ -55,7 +72,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Composition API only
|
||||
- Tailwind 4 styling
|
||||
- Avoid `<style>` blocks
|
||||
- Style: (see `.prettierrc`)
|
||||
- Style: (see `.oxfmtrc.json`)
|
||||
- Indent 2 spaces
|
||||
- single quotes
|
||||
- no trailing semicolons
|
||||
|
||||
31
CLAUDE.md
@@ -1,30 +1 @@
|
||||
# Claude Code specific instructions
|
||||
|
||||
@Agents.md
|
||||
|
||||
## Repository Setup
|
||||
|
||||
For first-time setup, use the Claude command:
|
||||
|
||||
```sh
|
||||
/setup_repo
|
||||
```
|
||||
|
||||
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
|
||||
|
||||
**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **First-time setup**: Run `/setup_repo` Claude command
|
||||
2. Make code changes
|
||||
3. Run tests (see subdirectory CLAUDE.md files)
|
||||
4. Run typecheck, lint, format
|
||||
5. Check README updates
|
||||
6. Consider docs.comfy.org updates
|
||||
|
||||
## Git Conventions
|
||||
|
||||
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
||||
- Add "Fixes #n" to PR descriptions
|
||||
- Never mention Claude/AI in commits
|
||||
@AGENTS.md
|
||||
|
||||
@@ -64,7 +64,7 @@ export default defineConfig(() => {
|
||||
})
|
||||
],
|
||||
build: {
|
||||
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
|
||||
minify: SHOULD_MINIFY,
|
||||
target: 'es2022',
|
||||
sourcemap: true
|
||||
}
|
||||
|
||||
8
browser_tests/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# E2E Testing Guidelines
|
||||
|
||||
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `assets/` - Test data (JSON workflows, fixtures)
|
||||
- Tests use premade JSON workflows to load desired graph state
|
||||
@@ -1,17 +1,3 @@
|
||||
# E2E Testing Guidelines
|
||||
|
||||
## Browser Tests
|
||||
- Test user workflows
|
||||
- Use Playwright fixtures
|
||||
- Follow naming conventions
|
||||
|
||||
## Best Practices
|
||||
- Check assets/ for test data
|
||||
- Prefer specific selectors
|
||||
- Test across viewports
|
||||
|
||||
## Testing Process
|
||||
After code changes:
|
||||
1. Create browser tests as appropriate
|
||||
2. Run tests until passing
|
||||
3. Then run typecheck, lint, format
|
||||
<!-- In gardens where the agents freely play,
|
||||
One stubborn flower turns the other way. -->
|
||||
@AGENTS.md
|
||||
|
||||
@@ -79,48 +79,15 @@ export class SubgraphSlotReference {
|
||||
|
||||
const node =
|
||||
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
|
||||
const slots =
|
||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`No ${type} node found in subgraph`)
|
||||
}
|
||||
|
||||
// Calculate position for next available slot
|
||||
// const nextSlotIndex = slots?.length || 0
|
||||
// const slotHeight = 20
|
||||
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
|
||||
|
||||
// Find last slot position
|
||||
const lastSlot = slots.at(-1)
|
||||
let slotX: number
|
||||
let slotY: number
|
||||
|
||||
if (lastSlot) {
|
||||
// If there are existing slots, position the new one below the last one
|
||||
const gapHeight = 20
|
||||
slotX = lastSlot.pos[0]
|
||||
slotY = lastSlot.pos[1] + gapHeight
|
||||
} else {
|
||||
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
|
||||
if (currentGraph.slotAnchorX !== undefined) {
|
||||
// The actual slot X position seems to be slotAnchorX - 10
|
||||
slotX = currentGraph.slotAnchorX - 10
|
||||
} else {
|
||||
// Fallback: calculate from node edge
|
||||
slotX =
|
||||
type === 'input'
|
||||
? node.pos[0] + node.size[0] - 10 // Right edge for input node
|
||||
: node.pos[0] + 10 // Left edge for output node
|
||||
}
|
||||
// For Y position when no slots exist, use middle of node
|
||||
slotY = node.pos[1] + node.size[1] / 2
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
slotX,
|
||||
slotY
|
||||
node.emptySlot.pos[0],
|
||||
node.emptySlot.pos[1]
|
||||
])
|
||||
return canvasPos
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
33
docs/guidance/playwright.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.spec.ts'
|
||||
---
|
||||
|
||||
# Playwright E2E Test Conventions
|
||||
|
||||
See `docs/testing/*.md` for detailed patterns.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
|
||||
- Prefer specific selectors (role, label, test-id)
|
||||
- Test across viewports
|
||||
|
||||
## Test Tags
|
||||
|
||||
Tags are respected by config:
|
||||
- `@mobile` - Mobile viewport tests
|
||||
- `@2x` - High DPI tests
|
||||
|
||||
## Test Data
|
||||
|
||||
- Check `browser_tests/assets/` for test data and fixtures
|
||||
- Use realistic ComfyUI workflows for E2E tests
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pnpm test:browser # Run all E2E tests
|
||||
pnpm test:browser -- --ui # Interactive UI mode
|
||||
```
|
||||
55
docs/guidance/storybook.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.stories.ts'
|
||||
---
|
||||
|
||||
# Storybook Conventions
|
||||
|
||||
## File Placement
|
||||
|
||||
Place `*.stories.ts` files alongside their components:
|
||||
```
|
||||
src/components/MyComponent/
|
||||
├── MyComponent.vue
|
||||
└── MyComponent.stories.ts
|
||||
```
|
||||
|
||||
## Story Structure
|
||||
|
||||
```typescript
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import ComponentName from './ComponentName.vue'
|
||||
|
||||
const meta: Meta<typeof ComponentName> = {
|
||||
title: 'Category/ComponentName',
|
||||
component: ComponentName,
|
||||
parameters: { layout: 'centered' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { /* props */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Required Story Variants
|
||||
|
||||
Include when applicable:
|
||||
- **Default** - Minimal props
|
||||
- **WithData** - Realistic data
|
||||
- **Loading** - Loading state
|
||||
- **Error** - Error state
|
||||
- **Empty** - No data
|
||||
|
||||
## Mock Data
|
||||
|
||||
Use realistic ComfyUI schemas for mocks (node definitions, components).
|
||||
|
||||
## Running Storybook
|
||||
|
||||
```bash
|
||||
pnpm storybook # Development server
|
||||
pnpm build-storybook # Production build
|
||||
```
|
||||
37
docs/guidance/typescript.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '**/*.vue'
|
||||
---
|
||||
|
||||
# TypeScript Conventions
|
||||
|
||||
## Type Safety
|
||||
|
||||
- Never use `any` type - use proper TypeScript types
|
||||
- Never use `as any` type assertions - fix the underlying type issue
|
||||
- Type assertions are a last resort; they lead to brittle code
|
||||
- Avoid `@ts-expect-error` - fix the underlying issue instead
|
||||
|
||||
## Utility Libraries
|
||||
|
||||
- Use `es-toolkit` for utility functions (not lodash)
|
||||
|
||||
## API Utilities
|
||||
|
||||
When making API calls in `src/`:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct - use api helpers
|
||||
const response = await api.get(api.apiURL('/prompt'))
|
||||
const template = await fetch(api.fileURL('/templates/default.json'))
|
||||
|
||||
// ❌ Wrong - direct URL construction
|
||||
const response = await fetch('/api/prompt')
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Sanitize HTML with `DOMPurify.sanitize()`
|
||||
- Never log secrets or sensitive data
|
||||
36
docs/guidance/vitest.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.test.ts'
|
||||
---
|
||||
|
||||
# Vitest Unit Test Conventions
|
||||
|
||||
See `docs/testing/*.md` for detailed patterns.
|
||||
|
||||
## Test Quality
|
||||
|
||||
- Do not write change detector tests (tests that just assert defaults)
|
||||
- Do not write tests dependent on non-behavioral features (styles, classes)
|
||||
- Do not write tests that just test mocks - ensure real code is exercised
|
||||
- Be parsimonious; avoid redundant tests
|
||||
|
||||
## Mocking
|
||||
|
||||
- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`)
|
||||
- Keep module mocks contained - no global mutable state
|
||||
- Use `vi.hoisted()` for per-test mock manipulation
|
||||
- Don't mock what you don't own
|
||||
|
||||
## Component Testing
|
||||
|
||||
- Use Vue Test Utils for component tests
|
||||
- Follow advice about making components easy to test
|
||||
- Wait for reactivity with `await nextTick()` after state changes
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pnpm test:unit # Run all unit tests
|
||||
pnpm test:unit -- path/to/file # Run specific test
|
||||
pnpm test:unit -- --watch # Watch mode
|
||||
```
|
||||
46
docs/guidance/vue-components.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.vue'
|
||||
---
|
||||
|
||||
# Vue Component Conventions
|
||||
|
||||
Applies to all `.vue` files anywhere in the codebase.
|
||||
|
||||
## Vue 3 Composition API
|
||||
|
||||
- Use `<script setup lang="ts">` for component logic
|
||||
- Destructure props (Vue 3.5 style with defaults) like `const { color = 'blue' } = defineProps<...>()`
|
||||
- Use `ref`/`reactive` for state
|
||||
- Use `computed()` for derived state
|
||||
- Use lifecycle hooks: `onMounted`, `onUpdated`, etc.
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes (promotes loose coupling)
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
- Proper props and emits definitions
|
||||
|
||||
## VueUse Composables
|
||||
|
||||
Prefer VueUse composables over manual event handling:
|
||||
|
||||
- `useElementHover` instead of manual mouseover/mouseout listeners
|
||||
- `useIntersectionObserver` for visibility detection instead of scroll handlers
|
||||
- `useFocusTrap` for modal/dialog focus management
|
||||
- `useEventListener` for auto-cleanup event listeners
|
||||
|
||||
Prefer Vue native options when available:
|
||||
|
||||
- `defineModel` instead of `useVModel` for two-way binding with props
|
||||
|
||||
## Styling
|
||||
|
||||
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Extract complex conditionals to `computed`
|
||||
- In unmounted hooks, implement cleanup for async operations
|
||||
@@ -4,9 +4,7 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
import { importX } from 'eslint-plugin-import-x'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows
|
||||
// See: https://github.com/nodejs/node/issues/58690
|
||||
// Prettier is still run separately in lint-staged, so this is safe to disable
|
||||
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
@@ -111,7 +109,7 @@ export default defineConfig([
|
||||
tseslintConfigs.recommended,
|
||||
// Difference in typecheck on CI vs Local
|
||||
pluginVue.configs['flat/recommended'],
|
||||
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault
|
||||
// Disables ESLint rules that conflict with formatters
|
||||
eslintConfigPrettier,
|
||||
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
|
||||
storybookConfigs['flat/recommended'],
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames) {
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec prettier --cache --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
'tests-ui/**': () =>
|
||||
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
|
||||
@@ -14,7 +17,7 @@ function formatAndEslint(fileNames: string[]) {
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec prettier --cache --write ${joinedPaths}`,
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
|
||||
17
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.38.3",
|
||||
"version": "1.38.8",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -22,10 +22,8 @@
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
|
||||
"dev": "nx serve",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"knip:no-cache": "knip",
|
||||
"knip": "knip --cache",
|
||||
@@ -63,14 +61,12 @@
|
||||
"@nx/vite": "catalog:",
|
||||
"@pinia/testing": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@prettier/plugin-oxc": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@storybook/addon-docs": "catalog:",
|
||||
"@storybook/addon-mcp": "catalog:",
|
||||
"@storybook/vue3": "catalog:",
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@trivago/prettier-plugin-sort-imports": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -101,11 +97,11 @@
|
||||
"markdown-table": "catalog:",
|
||||
"mixpanel-browser": "catalog:",
|
||||
"nx": "catalog:",
|
||||
"oxfmt": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:",
|
||||
"picocolors": "catalog:",
|
||||
"postcss-html": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"pretty-bytes": "catalog:",
|
||||
"rollup-plugin-visualizer": "catalog:",
|
||||
"storybook": "catalog:",
|
||||
@@ -192,5 +188,10 @@
|
||||
"yjs": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "^8.0.0-beta.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
855
pnpm-lock.yaml
generated
@@ -17,7 +17,6 @@ catalog:
|
||||
'@nx/vite': 22.2.6
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.57.0
|
||||
'@prettier/plugin-oxc': ^0.1.3
|
||||
'@primeuix/forms': 0.0.2
|
||||
'@primeuix/styled': 0.3.2
|
||||
'@primeuix/utils': ^0.3.2
|
||||
@@ -33,7 +32,6 @@ catalog:
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
'@trivago/prettier-plugin-sort-imports': ^5.2.0
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
@@ -70,12 +68,12 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.2.6
|
||||
oxfmt: ^0.26.0
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
prettier: ^3.7.4
|
||||
pretty-bytes: ^7.1.0
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
@@ -93,7 +91,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vite: ^7.3.0
|
||||
vite: ^8.0.0-beta.8
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
|
||||
@@ -12,6 +12,7 @@ declare global {
|
||||
const __ALGOLIA_API_KEY__: string
|
||||
const __USE_PROD_CONFIG__: boolean
|
||||
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
const __IS_NIGHTLY__: boolean
|
||||
}
|
||||
|
||||
type GlobalWithDefines = typeof globalThis & {
|
||||
@@ -22,6 +23,7 @@ type GlobalWithDefines = typeof globalThis & {
|
||||
__ALGOLIA_API_KEY__: string
|
||||
__USE_PROD_CONFIG__: boolean
|
||||
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
__IS_NIGHTLY__: boolean
|
||||
window?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ globalWithDefines.__ALGOLIA_APP_ID__ = ''
|
||||
globalWithDefines.__ALGOLIA_API_KEY__ = ''
|
||||
globalWithDefines.__USE_PROD_CONFIG__ = false
|
||||
globalWithDefines.__DISTRIBUTION__ = 'localhost'
|
||||
globalWithDefines.__IS_NIGHTLY__ = false
|
||||
|
||||
// Provide a minimal window shim for Node environment
|
||||
// This is needed for code that checks window existence during imports
|
||||
|
||||
26
src/AGENTS.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Source Code Guidelines
|
||||
|
||||
## Error Handling
|
||||
|
||||
- User-friendly and actionable messages
|
||||
- Proper error propagation
|
||||
|
||||
## Security
|
||||
|
||||
- Sanitize HTML with DOMPurify
|
||||
- Validate trusted sources
|
||||
- Never log secrets
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
- Follow domain-driven design for organizing files/folders
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
- Clean up subscriptions
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use `es-toolkit` for utility functions
|
||||
- Use TypeScript for type safety
|
||||
- Avoid `@ts-expect-error` - fix the underlying issue
|
||||
- Use `vue-i18n` for ALL user-facing strings (`src/locales/en/main.json`)
|
||||
@@ -1,57 +1,3 @@
|
||||
# Source Code Guidelines
|
||||
|
||||
## Service Layer
|
||||
|
||||
### API Calls
|
||||
|
||||
- Use `api.apiURL()` for backend endpoints
|
||||
- Use `api.fileURL()` for static files
|
||||
|
||||
#### ✅ Correct Usage
|
||||
```typescript
|
||||
// Backend API call
|
||||
const response = await api.get(api.apiURL('/prompt'))
|
||||
|
||||
// Static file
|
||||
const template = await fetch(api.fileURL('/templates/default.json'))
|
||||
```
|
||||
|
||||
#### ❌ Incorrect Usage
|
||||
```typescript
|
||||
// WRONG - Direct URL construction
|
||||
const response = await fetch('/api/prompt')
|
||||
const template = await fetch('/templates/default.json')
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- User-friendly and actionable messages
|
||||
- Proper error propagation
|
||||
|
||||
### Security
|
||||
|
||||
- Sanitize HTML with DOMPurify
|
||||
- Validate trusted sources
|
||||
- Never log secrets
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
### Store Design
|
||||
|
||||
- Follow domain-driven design
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Use TypeScript for type safety
|
||||
- Implement proper error handling
|
||||
- Clean up subscriptions
|
||||
- Avoid @ts-expect-error
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use es-toolkit for utility functions
|
||||
- Implement proper TypeScript types
|
||||
- Follow Vue 3 composition API style guide
|
||||
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`
|
||||
<!-- We forked the path, yet here we are again—
|
||||
Maintaining two files where one would have been sane. -->
|
||||
@AGENTS.md
|
||||
|
||||
6
src/components/AGENTS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Component Guidelines
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
@@ -1,45 +1,3 @@
|
||||
# Component Guidelines
|
||||
|
||||
## Vue 3 Composition API
|
||||
|
||||
- Use setup() function
|
||||
- Destructure props (Vue 3.5 style)
|
||||
- Use ref/reactive for state
|
||||
- Implement computed() for derived state
|
||||
- Use provide/inject for dependency injection
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
- Events promote loose coupling
|
||||
|
||||
## UI Framework
|
||||
|
||||
- Deprecated PrimeVue component replacements:
|
||||
- Dropdown → Select
|
||||
- OverlayPanel → Popover
|
||||
- Calendar → DatePicker
|
||||
- InputSwitch → ToggleSwitch
|
||||
- Sidebar → Drawer
|
||||
- Chips → AutoComplete with multiple enabled
|
||||
- TabMenu → Tabs without panels
|
||||
- Steps → Stepper without panels
|
||||
- InlineMessage → Message
|
||||
|
||||
## Styling
|
||||
|
||||
- Use Tailwind CSS only (no custom CSS)
|
||||
- Use the correct tokens from style.css in the design system package
|
||||
- For common operations, try to use existing VueUse composables that automatically handle effect scope
|
||||
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
|
||||
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Extract complex conditionals to computed
|
||||
- Implement cleanup for async operations
|
||||
- Use vue-i18n for ALL UI strings
|
||||
- Use lifecycle hooks: onMounted, onUpdated
|
||||
- Use Teleport/Suspense when needed
|
||||
- Proper props and emits definitions
|
||||
<!-- "Play nice with others," mother always said,
|
||||
But Claude prefers its own file name instead. -->
|
||||
@AGENTS.md
|
||||
|
||||
82
src/components/boundingbox/WidgetBoundingBox.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="x"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="y"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
const x = computed({
|
||||
get: () => modelValue.value.x,
|
||||
set: (x) => {
|
||||
modelValue.value = { ...modelValue.value, x }
|
||||
}
|
||||
})
|
||||
|
||||
const y = computed({
|
||||
get: () => modelValue.value.y,
|
||||
set: (y) => {
|
||||
modelValue.value = { ...modelValue.value, y }
|
||||
}
|
||||
})
|
||||
|
||||
const width = computed({
|
||||
get: () => modelValue.value.width,
|
||||
set: (width) => {
|
||||
modelValue.value = { ...modelValue.value, width }
|
||||
}
|
||||
})
|
||||
|
||||
const height = computed({
|
||||
get: () => modelValue.value.height,
|
||||
set: (height) => {
|
||||
modelValue.value = { ...modelValue.value, height }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node" />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class="selection-toolbox pointer-events-auto rounded-lg border border-interface-stroke bg-interface-panel-surface"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-2 h-12 flex flex-row gap-1'
|
||||
content: 'p-1 h-10 flex flex-row gap-1'
|
||||
}"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
|
||||
@@ -6,67 +6,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
// NOTE: The component import must come after mocks so they take effect.
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const mockLGraphNode = {
|
||||
type: 'TestNode',
|
||||
title: 'Test Node'
|
||||
}
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => true)
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||
useNodeLibrarySidebarTab: () => ({
|
||||
id: 'node-library'
|
||||
})
|
||||
}))
|
||||
|
||||
const openHelpMock = vi.fn()
|
||||
const closeHelpMock = vi.fn()
|
||||
const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null }
|
||||
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
|
||||
useNodeHelpStore: () => ({
|
||||
openHelp: (def: any) => {
|
||||
nodeHelpState.currentHelpNode = def
|
||||
openHelpMock(def)
|
||||
},
|
||||
closeHelp: () => {
|
||||
nodeHelpState.currentHelpNode = null
|
||||
closeHelpMock()
|
||||
},
|
||||
get currentHelpNode() {
|
||||
return nodeHelpState.currentHelpNode
|
||||
},
|
||||
get isHelpOpen() {
|
||||
return nodeHelpState.currentHelpNode !== null
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const toggleSidebarTabMock = vi.fn((id: string) => {
|
||||
sidebarState.activeSidebarTabId =
|
||||
sidebarState.activeSidebarTabId === id ? null : id
|
||||
})
|
||||
const sidebarState: { activeSidebarTabId: string | null } = {
|
||||
activeSidebarTabId: 'other-tab'
|
||||
}
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => ({
|
||||
get activeSidebarTabId() {
|
||||
return sidebarState.activeSidebarTabId
|
||||
},
|
||||
toggleSidebarTab: toggleSidebarTabMock
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
})
|
||||
}))
|
||||
|
||||
describe('InfoButton', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
let nodeDefStore: ReturnType<typeof useNodeDefStore>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -81,9 +33,6 @@ describe('InfoButton', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefStore = useNodeDefStore()
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -92,58 +41,15 @@ describe('InfoButton', () => {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
directives: { tooltip: Tooltip },
|
||||
stubs: {
|
||||
'i-lucide:info': true,
|
||||
Button: {
|
||||
template:
|
||||
'<button class="help-button" severity="secondary"><slot /></button>',
|
||||
props: ['severity', 'text', 'class'],
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
components: { Button }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('should handle click without errors', async () => {
|
||||
const mockNodeDef = {
|
||||
nodePath: 'test/node',
|
||||
display_name: 'Test Node'
|
||||
}
|
||||
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
|
||||
it('should open the info panel on click', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
const button = wrapper.find('[data-testid="info-button"]')
|
||||
await button.trigger('click')
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should have correct CSS classes', () => {
|
||||
const mockNodeDef = {
|
||||
nodePath: 'test/node',
|
||||
display_name: 'Test Node'
|
||||
}
|
||||
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.classes()).toContain('help-button')
|
||||
expect(button.attributes('severity')).toBe('secondary')
|
||||
})
|
||||
|
||||
it('should have correct tooltip', () => {
|
||||
const mockNodeDef = {
|
||||
nodePath: 'test/node',
|
||||
display_name: 'Test Node'
|
||||
}
|
||||
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
})
|
||||
})
|
||||
|
||||
100
src/components/imagecrop/WidgetImageCrop.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands relative flex h-full w-full flex-col gap-1"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<!-- Image preview container -->
|
||||
<div
|
||||
ref="containerEl"
|
||||
class="relative min-h-0 flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<div v-if="isLoading" class="flex size-full items-center justify-center">
|
||||
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!imageUrl"
|
||||
class="flex size-full flex-col items-center justify-center text-center"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--image] h-12 w-12" />
|
||||
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-else
|
||||
ref="imageEl"
|
||||
:src="imageUrl"
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none brightness-50"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
>
|
||||
<div class="pointer-events-none size-full" :style="cropImageStyle" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
v-show="imageUrl && !isLoading"
|
||||
:key="handle.direction"
|
||||
:class="['absolute', handle.class]"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
|
||||
import { useImageCrop } from '@/composables/useImageCrop'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
|
||||
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
|
||||
|
||||
const {
|
||||
imageUrl,
|
||||
isLoading,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd
|
||||
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
|
||||
</script>
|
||||
@@ -70,17 +70,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
const { node } = defineProps<{
|
||||
node: ComfyNodeDefImpl
|
||||
}>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
|
||||
const { renderedHelpHtml, isLoading, error } = useNodeHelpContent(() => node)
|
||||
|
||||
const inputList = computed(() =>
|
||||
Object.values(node.inputs).map((spec) => ({
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { nodes } = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
@@ -13,20 +11,10 @@ const { nodes } = defineProps<{
|
||||
const node = computed(() => nodes[0])
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
const nodeInfo = computed(() => {
|
||||
return nodeDefStore.fromLGraphNode(node.value)
|
||||
})
|
||||
|
||||
// Open node help when the selected node changes
|
||||
whenever(
|
||||
nodeInfo,
|
||||
(info) => {
|
||||
nodeHelpStore.openHelp(info)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -25,13 +25,31 @@ const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
|
||||
return { widgets: shownWidgets, node }
|
||||
})
|
||||
})
|
||||
|
||||
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const advancedWidgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
return { widgets: advancedWidgets, node }
|
||||
})
|
||||
.filter(({ widgets }) => widgets.length > 0)
|
||||
})
|
||||
|
||||
const isMultipleNodesSelected = computed(
|
||||
() => widgetsSectionDataList.value.length > 1
|
||||
)
|
||||
@@ -56,6 +74,12 @@ const label = computed(() => {
|
||||
: t('rightSidePanel.inputsNone')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
|
||||
const advancedLabel = computed(() => {
|
||||
return !mustShowNodeTitle && !isMultipleNodesSelected.value
|
||||
? t('rightSidePanel.advancedInputs')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,4 +117,16 @@ const label = computed(() => {
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
|
||||
<SectionWidgets
|
||||
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
|
||||
:key="`advanced-${node.id}`"
|
||||
:collapse="true"
|
||||
:node
|
||||
:label="advancedLabel"
|
||||
:widgets
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
import { renameWidget } from '../shared'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -205,67 +203,3 @@ function repeatItems<T>(items: T[]): T[] {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a widget and its corresponding input.
|
||||
* Handles both regular widgets and proxy widgets in subgraphs.
|
||||
*
|
||||
* @param widget The widget to rename
|
||||
* @param node The node containing the widget
|
||||
* @param newLabel The new label for the widget (empty string or undefined to clear)
|
||||
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
|
||||
* @returns true if the rename was successful, false otherwise
|
||||
*/
|
||||
export function renameWidget(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode,
|
||||
newLabel: string,
|
||||
parents?: SubgraphNode[]
|
||||
): boolean {
|
||||
// For proxy widgets in subgraphs, we need to rename the original interior widget
|
||||
if (isProxyWidget(widget) && parents?.length) {
|
||||
const subgraph = parents[0].subgraph
|
||||
if (!subgraph) {
|
||||
console.error('Could not find subgraph for proxy widget')
|
||||
return false
|
||||
}
|
||||
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
// Rename the original widget
|
||||
originalWidget.label = newLabel || undefined
|
||||
|
||||
// Also rename the corresponding input on the interior node
|
||||
const interiorInput = interiorNode.inputs?.find(
|
||||
(inp) => inp.widget?.name === widget._overlay.widgetName
|
||||
)
|
||||
if (interiorInput) {
|
||||
interiorInput.label = newLabel || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Always rename the widget on the current node (either regular widget or proxy widget)
|
||||
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
|
||||
|
||||
// Intentionally mutate the widget object here as it's a reference
|
||||
// to the actual widget in the graph
|
||||
widget.label = newLabel || undefined
|
||||
if (input) {
|
||||
input.label = newLabel || undefined
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
@@ -7,7 +6,8 @@ import {
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
triggerRef
|
||||
triggerRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -225,13 +225,10 @@ function setDraggableState() {
|
||||
activeWidgets.value = aw
|
||||
}
|
||||
}
|
||||
watchDebounced(
|
||||
filteredActive,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
watch(filteredActive, () => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
if (activeNode.value) pruneDisconnected(activeNode.value)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
@@ -21,24 +22,3 @@ defineProps<{
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-badge) {
|
||||
background: #ff3b30;
|
||||
color: #ff3b30;
|
||||
min-width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border-radius: 9999px;
|
||||
font-size: 0;
|
||||
margin-top: 4px;
|
||||
margin-right: 4px;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.p-badge.p-badge-dot) {
|
||||
width: 8px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import OverlayBadge from 'primevue/overlaybadge'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -33,8 +32,7 @@ describe('SidebarIcon', () => {
|
||||
return mount(SidebarIcon, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: { tooltip: Tooltip },
|
||||
components: { OverlayBadge }
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
props: { ...exampleProps, ...props },
|
||||
...options
|
||||
@@ -54,9 +52,9 @@ describe('SidebarIcon', () => {
|
||||
it('creates badge when iconBadge prop is set', () => {
|
||||
const badge = '2'
|
||||
const wrapper = mountSidebarIcon({ iconBadge: badge })
|
||||
const badgeEl = wrapper.findComponent(OverlayBadge)
|
||||
const badgeEl = wrapper.find('.sidebar-icon-badge')
|
||||
expect(badgeEl.exists()).toBe(true)
|
||||
expect(badgeEl.find('.p-badge').text()).toEqual(badge)
|
||||
expect(badgeEl.text()).toEqual(badge)
|
||||
})
|
||||
|
||||
it('shows tooltip on hover', async () => {
|
||||
|
||||
@@ -17,22 +17,28 @@
|
||||
>
|
||||
<div class="side-bar-button-content">
|
||||
<slot name="icon">
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<div class="sidebar-icon-wrapper relative">
|
||||
<i
|
||||
v-if="typeof icon === 'string'"
|
||||
:class="icon + ' side-bar-button-icon'"
|
||||
/>
|
||||
<component :is="icon" v-else class="side-bar-button-icon" />
|
||||
</OverlayBadge>
|
||||
<i
|
||||
v-else-if="typeof icon === 'string'"
|
||||
:class="icon + ' side-bar-button-icon'"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
v-else-if="typeof icon === 'object'"
|
||||
class="side-bar-button-icon"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
v-else-if="typeof icon === 'object'"
|
||||
class="side-bar-button-icon"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowBadge"
|
||||
:class="
|
||||
cn(
|
||||
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground',
|
||||
badgeClass || '-top-1 -right-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ overlayValue }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
t(label)
|
||||
@@ -42,7 +48,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OverlayBadge from 'primevue/overlaybadge'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -57,6 +62,7 @@ const {
|
||||
tooltip = '',
|
||||
tooltipSuffix = '',
|
||||
iconBadge = '',
|
||||
badgeClass = '',
|
||||
label = '',
|
||||
isSmall = false
|
||||
} = defineProps<{
|
||||
@@ -65,6 +71,7 @@ const {
|
||||
tooltip?: string
|
||||
tooltipSuffix?: string
|
||||
iconBadge?: string | (() => string | null)
|
||||
badgeClass?: string
|
||||
label?: string
|
||||
isSmall?: boolean
|
||||
}>()
|
||||
|
||||
@@ -44,9 +44,15 @@
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
{{ t('sideToolbar.generatedAssetsHeader') }}
|
||||
{{
|
||||
t(
|
||||
assetType === 'input'
|
||||
? 'sideToolbar.importedAssetsHeader'
|
||||
: 'sideToolbar.generatedAssetsHeader'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,9 +134,14 @@ import {
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { assets, isSelected } = defineProps<{
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
assetType = 'output'
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
assetType?: 'input' | 'output'
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
|
||||
@@ -139,7 +139,15 @@ import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, h, nextTick, onMounted, ref, render } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
render
|
||||
} from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
@@ -171,6 +179,8 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
|
||||
|
||||
const instance = getCurrentInstance()!
|
||||
const appContext = instance.appContext
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
@@ -272,6 +282,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
draggable: node.leaf,
|
||||
renderDragPreview(container) {
|
||||
const vnode = h(NodePreview, { nodeDef: node.data })
|
||||
vnode.appContext = appContext
|
||||
render(vnode, container)
|
||||
return () => {
|
||||
render(null, container)
|
||||
|
||||
@@ -22,7 +22,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, nextTick, ref, render, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
nextTick,
|
||||
ref,
|
||||
render,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FolderCustomizationDialog from '@/components/common/CustomizationDialog.vue'
|
||||
@@ -41,6 +49,8 @@ import type {
|
||||
TreeNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const instance = getCurrentInstance()!
|
||||
const appContext = instance.appContext
|
||||
const props = defineProps<{
|
||||
filteredNodeDefs: ComfyNodeDefImpl[]
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
@@ -154,6 +164,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
},
|
||||
renderDragPreview(container) {
|
||||
const vnode = h(NodePreview, { nodeDef: node.data })
|
||||
vnode.appContext = appContext
|
||||
render(vnode, container)
|
||||
return () => {
|
||||
render(null, container)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState')
|
||||
vi.mock('@/stores/workspace/nodeHelpStore')
|
||||
|
||||
const baseNode = {
|
||||
nodePath: 'NodeA',
|
||||
display_name: 'Node A',
|
||||
description: '',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
|
||||
describe('NodeHelpPage', () => {
|
||||
const selection = ref<any | null>(null)
|
||||
let openHelp: ReturnType<typeof vi.fn>
|
||||
|
||||
const mountPage = () =>
|
||||
mount(NodeHelpPage, {
|
||||
props: { node: baseNode as any },
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
stubs: {
|
||||
ProgressSpinner: true,
|
||||
Button: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
selection.value = null
|
||||
openHelp = vi.fn()
|
||||
|
||||
vi.mocked(useSelectionState).mockReturnValue({
|
||||
nodeDef: computed(() => selection.value)
|
||||
} as any)
|
||||
|
||||
vi.mocked(useNodeHelpStore).mockReturnValue({
|
||||
renderedHelpHtml: ref('<p>help</p>'),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
isHelpOpen: true,
|
||||
currentHelpNode: { nodePath: 'NodeA' },
|
||||
openHelp,
|
||||
closeHelp: vi.fn()
|
||||
} as any)
|
||||
})
|
||||
|
||||
test('opens help for a newly selected node while help is open', async () => {
|
||||
const wrapper = mountPage()
|
||||
|
||||
selection.value = { nodePath: 'NodeB' }
|
||||
await flushPromises()
|
||||
|
||||
expect(openHelp).toHaveBeenCalledWith({ nodePath: 'NodeB' })
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('does not reopen help when the same node stays selected', async () => {
|
||||
const wrapper = mountPage()
|
||||
|
||||
selection.value = { nodePath: 'NodeA' }
|
||||
await flushPromises()
|
||||
|
||||
expect(openHelp).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('does not react to selection when help is closed', async () => {
|
||||
vi.mocked(useNodeHelpStore).mockReturnValueOnce({
|
||||
renderedHelpHtml: ref('<p>help</p>'),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
isHelpOpen: false,
|
||||
currentHelpNode: null,
|
||||
openHelp,
|
||||
closeHelp: vi.fn()
|
||||
} as any)
|
||||
|
||||
const wrapper = mountPage()
|
||||
|
||||
selection.value = { nodePath: 'NodeB' }
|
||||
await flushPromises()
|
||||
|
||||
expect(openHelp).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -21,32 +21,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { nodeDef } = useSelectionState()
|
||||
|
||||
const activeHelpDef = computed(() =>
|
||||
nodeHelpStore.isHelpOpen ? nodeDef.value : null
|
||||
)
|
||||
|
||||
// Keep the open help page synced with the current selection while help is open.
|
||||
whenever(activeHelpDef, (def) => {
|
||||
const currentHelpNode = nodeHelpStore.currentHelpNode
|
||||
if (currentHelpNode?.nodePath === def.nodePath) return
|
||||
nodeHelpStore.openHelp(def)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.learnMore')"
|
||||
@click.stop="props.openNodeHelp(nodeDef)"
|
||||
@click.stop="onHelpClick"
|
||||
>
|
||||
<i class="pi pi-question size-3.5" />
|
||||
</Button>
|
||||
@@ -85,6 +85,7 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -112,6 +113,13 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const toggleBookmark = async () => {
|
||||
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
|
||||
const onHelpClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'node_library_help_button'
|
||||
})
|
||||
props.openNodeHelp(nodeDef.value)
|
||||
}
|
||||
const editBlueprint = async () => {
|
||||
if (!props.node.data)
|
||||
throw new Error(
|
||||
|
||||
@@ -398,6 +398,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Check if the node was removed mid-sequence
|
||||
if (!nodeRefs.has(id)) return
|
||||
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
@@ -427,7 +430,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
requestAnimationFrame(initializeVueNodeLayout)
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
@@ -11,6 +12,12 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
tooltip: 'sideToolbar.assets',
|
||||
label: 'sideToolbar.labels.assets',
|
||||
component: markRaw(AssetsSidebarTab),
|
||||
type: 'vue'
|
||||
type: 'vue',
|
||||
iconBadge: () => {
|
||||
const queueStore = useQueueStore()
|
||||
return queueStore.pendingTasks.length > 0
|
||||
? queueStore.pendingTasks.length.toString()
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +123,7 @@ export const useContextMenuTranslation = () => {
|
||||
}
|
||||
|
||||
// for capture translation text of input and widget
|
||||
const extraInfo = (options.extra ||
|
||||
options.parentMenu?.options?.extra) as
|
||||
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
|
||||
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
|
||||
| undefined
|
||||
// widgets and inputs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -95,6 +96,8 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get teamWorkspacesEnabled() {
|
||||
if (!isCloud) return false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
|
||||
469
src/composables/useImageCrop.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'nw'
|
||||
| 'ne'
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
const MIN_CROP_SIZE = 16
|
||||
const CROP_BOX_BORDER = 2
|
||||
|
||||
interface UseImageCropOptions {
|
||||
imageEl: Ref<HTMLImageElement | null>
|
||||
containerEl: Ref<HTMLDivElement | null>
|
||||
modelValue: Ref<Bounds>
|
||||
}
|
||||
|
||||
export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
const { imageEl, containerEl, modelValue } = options
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
|
||||
const imageUrl = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const naturalWidth = ref(0)
|
||||
const naturalHeight = ref(0)
|
||||
const displayedWidth = ref(0)
|
||||
const displayedHeight = ref(0)
|
||||
const scaleFactor = ref(1)
|
||||
const imageOffsetX = ref(0)
|
||||
const imageOffsetY = ref(0)
|
||||
|
||||
const cropX = computed({
|
||||
get: () => modelValue.value.x,
|
||||
set: (v: number) => {
|
||||
modelValue.value.x = v
|
||||
}
|
||||
})
|
||||
|
||||
const cropY = computed({
|
||||
get: () => modelValue.value.y,
|
||||
set: (v: number) => {
|
||||
modelValue.value.y = v
|
||||
}
|
||||
})
|
||||
|
||||
const cropWidth = computed({
|
||||
get: () => modelValue.value.width || 512,
|
||||
set: (v: number) => {
|
||||
modelValue.value.width = v
|
||||
}
|
||||
})
|
||||
|
||||
const cropHeight = computed({
|
||||
get: () => modelValue.value.height || 512,
|
||||
set: (v: number) => {
|
||||
modelValue.value.height = v
|
||||
}
|
||||
})
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartY = ref(0)
|
||||
const dragStartCropX = ref(0)
|
||||
const dragStartCropY = ref(0)
|
||||
|
||||
const isResizing = ref(false)
|
||||
const resizeDirection = ref<ResizeDirection | null>(null)
|
||||
const resizeStartX = ref(0)
|
||||
const resizeStartY = ref(0)
|
||||
const resizeStartCropX = ref(0)
|
||||
const resizeStartCropY = ref(0)
|
||||
const resizeStartCropWidth = ref(0)
|
||||
const resizeStartCropHeight = ref(0)
|
||||
|
||||
useResizeObserver(containerEl, () => {
|
||||
if (imageEl.value && imageUrl.value) {
|
||||
updateDisplayedDimensions()
|
||||
}
|
||||
})
|
||||
|
||||
const getInputImageUrl = (): string | null => {
|
||||
if (!node.value) return null
|
||||
|
||||
const inputNode = node.value.getInputNode(0)
|
||||
|
||||
if (!inputNode) return null
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
|
||||
|
||||
if (urls?.length) {
|
||||
return urls[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const updateImageUrl = () => {
|
||||
imageUrl.value = getInputImageUrl()
|
||||
}
|
||||
|
||||
const updateDisplayedDimensions = () => {
|
||||
if (!imageEl.value || !containerEl.value) return
|
||||
|
||||
const img = imageEl.value
|
||||
const container = containerEl.value
|
||||
|
||||
naturalWidth.value = img.naturalWidth
|
||||
naturalHeight.value = img.naturalHeight
|
||||
|
||||
if (naturalWidth.value <= 0 || naturalHeight.value <= 0) {
|
||||
scaleFactor.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth
|
||||
const containerHeight = container.clientHeight
|
||||
|
||||
const imageAspect = naturalWidth.value / naturalHeight.value
|
||||
const containerAspect = containerWidth / containerHeight
|
||||
|
||||
if (imageAspect > containerAspect) {
|
||||
displayedWidth.value = containerWidth
|
||||
displayedHeight.value = containerWidth / imageAspect
|
||||
imageOffsetX.value = 0
|
||||
imageOffsetY.value = (containerHeight - displayedHeight.value) / 2
|
||||
} else {
|
||||
displayedHeight.value = containerHeight
|
||||
displayedWidth.value = containerHeight * imageAspect
|
||||
imageOffsetX.value = (containerWidth - displayedWidth.value) / 2
|
||||
imageOffsetY.value = 0
|
||||
}
|
||||
|
||||
if (naturalWidth.value <= 0 || displayedWidth.value <= 0) {
|
||||
scaleFactor.value = 1
|
||||
} else {
|
||||
scaleFactor.value = displayedWidth.value / naturalWidth.value
|
||||
}
|
||||
}
|
||||
|
||||
const getEffectiveScale = (): number => {
|
||||
const container = containerEl.value
|
||||
|
||||
if (!container || naturalWidth.value <= 0 || displayedWidth.value <= 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const clientWidth = container.clientWidth
|
||||
|
||||
if (!clientWidth || !rect.width) return 1
|
||||
|
||||
const renderedDisplayedWidth =
|
||||
(displayedWidth.value / clientWidth) * rect.width
|
||||
|
||||
return renderedDisplayedWidth / naturalWidth.value
|
||||
}
|
||||
|
||||
const cropBoxStyle = computed(() => ({
|
||||
left: `${imageOffsetX.value + cropX.value * scaleFactor.value - CROP_BOX_BORDER}px`,
|
||||
top: `${imageOffsetY.value + cropY.value * scaleFactor.value - CROP_BOX_BORDER}px`,
|
||||
width: `${cropWidth.value * scaleFactor.value}px`,
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
const cropImageStyle = computed(() => {
|
||||
if (!imageUrl.value) return {}
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${imageUrl.value})`,
|
||||
backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`,
|
||||
backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`,
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}
|
||||
})
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const resizeHandles = computed<ResizeHandle[]>(() => {
|
||||
const x = imageOffsetX.value + cropX.value * scaleFactor.value
|
||||
const y = imageOffsetY.value + cropY.value * scaleFactor.value
|
||||
const w = cropWidth.value * scaleFactor.value
|
||||
const h = cropHeight.value * scaleFactor.value
|
||||
|
||||
return [
|
||||
{
|
||||
direction: 'top',
|
||||
class: 'h-2 cursor-ns-resize',
|
||||
style: {
|
||||
left: `${x + HANDLE_SIZE}px`,
|
||||
top: `${y - HANDLE_SIZE / 2}px`,
|
||||
width: `${Math.max(0, w - HANDLE_SIZE * 2)}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
direction: 'bottom',
|
||||
class: 'h-2 cursor-ns-resize',
|
||||
style: {
|
||||
left: `${x + HANDLE_SIZE}px`,
|
||||
top: `${y + h - HANDLE_SIZE / 2}px`,
|
||||
width: `${Math.max(0, w - HANDLE_SIZE * 2)}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
direction: 'left',
|
||||
class: 'w-2 cursor-ew-resize',
|
||||
style: {
|
||||
left: `${x - HANDLE_SIZE / 2}px`,
|
||||
top: `${y + HANDLE_SIZE}px`,
|
||||
height: `${Math.max(0, h - HANDLE_SIZE * 2)}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
direction: 'right',
|
||||
class: 'w-2 cursor-ew-resize',
|
||||
style: {
|
||||
left: `${x + w - HANDLE_SIZE / 2}px`,
|
||||
top: `${y + HANDLE_SIZE}px`,
|
||||
height: `${Math.max(0, h - HANDLE_SIZE * 2)}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
direction: 'nw',
|
||||
class: 'cursor-nwse-resize rounded-sm bg-white/80',
|
||||
style: {
|
||||
left: `${x - CORNER_SIZE / 2}px`,
|
||||
top: `${y - CORNER_SIZE / 2}px`,
|
||||
width: `${CORNER_SIZE}px`,
|
||||
height: `${CORNER_SIZE}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
direction: 'ne',
|
||||
class: 'cursor-nesw-resize rounded-sm bg-white/80',
|
||||
style: {
|
||||
left: `${x + w - CORNER_SIZE / 2}px`,
|
||||
top: `${y - CORNER_SIZE / 2}px`,
|
||||
width: `${CORNER_SIZE}px`,
|
||||
height: `${CORNER_SIZE}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
direction: 'sw',
|
||||
class: 'cursor-nesw-resize rounded-sm bg-white/80',
|
||||
style: {
|
||||
left: `${x - CORNER_SIZE / 2}px`,
|
||||
top: `${y + h - CORNER_SIZE / 2}px`,
|
||||
width: `${CORNER_SIZE}px`,
|
||||
height: `${CORNER_SIZE}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
direction: 'se',
|
||||
class: 'cursor-nwse-resize rounded-sm bg-white/80',
|
||||
style: {
|
||||
left: `${x + w - CORNER_SIZE / 2}px`,
|
||||
top: `${y + h - CORNER_SIZE / 2}px`,
|
||||
width: `${CORNER_SIZE}px`,
|
||||
height: `${CORNER_SIZE}px`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const handleImageLoad = () => {
|
||||
isLoading.value = false
|
||||
updateDisplayedDimensions()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
imageUrl.value = null
|
||||
}
|
||||
|
||||
const capturePointer = (e: PointerEvent) =>
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
const releasePointer = (e: PointerEvent) =>
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
|
||||
const handleDragStart = (e: PointerEvent) => {
|
||||
if (!imageUrl.value) return
|
||||
|
||||
isDragging.value = true
|
||||
dragStartX.value = e.clientX
|
||||
dragStartY.value = e.clientY
|
||||
dragStartCropX.value = cropX.value
|
||||
dragStartCropY.value = cropY.value
|
||||
capturePointer(e)
|
||||
}
|
||||
|
||||
const handleDragMove = (e: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const effectiveScale = getEffectiveScale()
|
||||
if (effectiveScale === 0) return
|
||||
|
||||
const deltaX = (e.clientX - dragStartX.value) / effectiveScale
|
||||
const deltaY = (e.clientY - dragStartY.value) / effectiveScale
|
||||
|
||||
const maxX = naturalWidth.value - cropWidth.value
|
||||
const maxY = naturalHeight.value - cropHeight.value
|
||||
|
||||
cropX.value = Math.round(
|
||||
Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX))
|
||||
)
|
||||
cropY.value = Math.round(
|
||||
Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY))
|
||||
)
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
isDragging.value = false
|
||||
releasePointer(e)
|
||||
}
|
||||
|
||||
const handleResizeStart = (e: PointerEvent, direction: ResizeDirection) => {
|
||||
if (!imageUrl.value) return
|
||||
|
||||
e.stopPropagation()
|
||||
isResizing.value = true
|
||||
resizeDirection.value = direction
|
||||
|
||||
resizeStartX.value = e.clientX
|
||||
resizeStartY.value = e.clientY
|
||||
resizeStartCropX.value = cropX.value
|
||||
resizeStartCropY.value = cropY.value
|
||||
resizeStartCropWidth.value = cropWidth.value
|
||||
resizeStartCropHeight.value = cropHeight.value
|
||||
capturePointer(e)
|
||||
}
|
||||
|
||||
const handleResizeMove = (e: PointerEvent) => {
|
||||
if (!isResizing.value || !resizeDirection.value) return
|
||||
|
||||
const effectiveScale = getEffectiveScale()
|
||||
if (effectiveScale === 0) return
|
||||
|
||||
const dir = resizeDirection.value
|
||||
const deltaX = (e.clientX - resizeStartX.value) / effectiveScale
|
||||
const deltaY = (e.clientY - resizeStartY.value) / effectiveScale
|
||||
|
||||
const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw'
|
||||
const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se'
|
||||
const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne'
|
||||
const affectsBottom = dir === 'bottom' || dir === 'sw' || dir === 'se'
|
||||
|
||||
let newX = resizeStartCropX.value
|
||||
let newY = resizeStartCropY.value
|
||||
let newWidth = resizeStartCropWidth.value
|
||||
let newHeight = resizeStartCropHeight.value
|
||||
|
||||
if (affectsLeft) {
|
||||
const maxDeltaX = resizeStartCropWidth.value - MIN_CROP_SIZE
|
||||
const minDeltaX = -resizeStartCropX.value
|
||||
const clampedDeltaX = Math.max(minDeltaX, Math.min(maxDeltaX, deltaX))
|
||||
newX = resizeStartCropX.value + clampedDeltaX
|
||||
newWidth = resizeStartCropWidth.value - clampedDeltaX
|
||||
} else if (affectsRight) {
|
||||
const maxWidth = naturalWidth.value - resizeStartCropX.value
|
||||
newWidth = Math.max(
|
||||
MIN_CROP_SIZE,
|
||||
Math.min(maxWidth, resizeStartCropWidth.value + deltaX)
|
||||
)
|
||||
}
|
||||
|
||||
if (affectsTop) {
|
||||
const maxDeltaY = resizeStartCropHeight.value - MIN_CROP_SIZE
|
||||
const minDeltaY = -resizeStartCropY.value
|
||||
const clampedDeltaY = Math.max(minDeltaY, Math.min(maxDeltaY, deltaY))
|
||||
newY = resizeStartCropY.value + clampedDeltaY
|
||||
newHeight = resizeStartCropHeight.value - clampedDeltaY
|
||||
} else if (affectsBottom) {
|
||||
const maxHeight = naturalHeight.value - resizeStartCropY.value
|
||||
newHeight = Math.max(
|
||||
MIN_CROP_SIZE,
|
||||
Math.min(maxHeight, resizeStartCropHeight.value + deltaY)
|
||||
)
|
||||
}
|
||||
|
||||
if (affectsLeft || affectsRight) {
|
||||
cropX.value = Math.round(newX)
|
||||
cropWidth.value = Math.round(newWidth)
|
||||
}
|
||||
if (affectsTop || affectsBottom) {
|
||||
cropY.value = Math.round(newY)
|
||||
cropHeight.value = Math.round(newHeight)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResizeEnd = (e: PointerEvent) => {
|
||||
if (!isResizing.value) return
|
||||
|
||||
isResizing.value = false
|
||||
resizeDirection.value = null
|
||||
releasePointer(e)
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
if (nodeId != null) {
|
||||
node.value = app.rootGraph?.getNodeById(nodeId) || null
|
||||
}
|
||||
|
||||
updateImageUrl()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodeOutputs,
|
||||
() => updateImageUrl(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodePreviewImages,
|
||||
() => updateImageUrl(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(initialize)
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
isLoading,
|
||||
|
||||
cropX,
|
||||
cropY,
|
||||
cropWidth,
|
||||
cropHeight,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd
|
||||
}
|
||||
}
|
||||
@@ -511,6 +511,22 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
|
||||
if (load3d) {
|
||||
const node = nodeRef.value
|
||||
|
||||
const modelWidget = node?.widgets?.find(
|
||||
(w) => w.name === 'model_file' || w.name === 'image'
|
||||
)
|
||||
const value = modelWidget?.value
|
||||
if (typeof value === 'string') {
|
||||
void Load3dUtils.generateThumbnailIfNeeded(
|
||||
load3d,
|
||||
value,
|
||||
isPreview.value ? 'output' : 'input'
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
skeletonVisibilityChange: (value: boolean) => {
|
||||
modelConfig.value.showSkeleton = value
|
||||
|
||||
381
src/composables/useNodeHelpContent.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
function createMockNode(
|
||||
overrides: Partial<ComfyNodeDefImpl>
|
||||
): ComfyNodeDefImpl {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: 'A test node',
|
||||
category: 'test',
|
||||
python_module: 'comfy.test_node',
|
||||
inputs: {},
|
||||
outputs: [],
|
||||
deprecated: false,
|
||||
experimental: false,
|
||||
output_node: false,
|
||||
api_node: false,
|
||||
...overrides
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: vi.fn((url) => url)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: ref('en')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/types/nodeSource', () => ({
|
||||
NodeSourceType: {
|
||||
Core: 'core',
|
||||
CustomNodes: 'custom_nodes'
|
||||
},
|
||||
getNodeSource: vi.fn((pythonModule) => {
|
||||
if (pythonModule?.startsWith('custom_nodes.')) {
|
||||
return { type: 'custom_nodes' }
|
||||
}
|
||||
return { type: 'core' }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodeHelpContent', () => {
|
||||
const mockCoreNode = createMockNode({
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: 'A test node',
|
||||
python_module: 'comfy.test_node'
|
||||
})
|
||||
|
||||
const mockCustomNode = createMockNode({
|
||||
name: 'CustomNode',
|
||||
display_name: 'Custom Node',
|
||||
description: 'A custom node',
|
||||
python_module: 'custom_nodes.test_module.custom@1.0.0'
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should generate correct baseUrl for core nodes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test'
|
||||
})
|
||||
|
||||
const { baseUrl } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`)
|
||||
})
|
||||
|
||||
it('should generate correct baseUrl for custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test'
|
||||
})
|
||||
|
||||
const { baseUrl } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe('/extensions/test_module/docs/')
|
||||
})
|
||||
|
||||
it('should render markdown content correctly', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test Help\nThis is test help content'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('This is test help content')
|
||||
})
|
||||
|
||||
it('should handle fetch errors and fall back to description', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found'
|
||||
})
|
||||
|
||||
const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(error.value).toBe('Not Found')
|
||||
expect(renderedHelpHtml.value).toContain(mockCoreNode.description)
|
||||
})
|
||||
|
||||
it('should include alt attribute for images', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => ''
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('alt="image"')
|
||||
})
|
||||
|
||||
it('should prefix relative video src in custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="/extensions/test_module/docs/video.mp4"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative video src for core nodes with node-specific base URL', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video.mp4"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle loading state', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
const { isLoading } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should try fallback URL for custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Fallback content'
|
||||
})
|
||||
|
||||
useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/extensions/test_module/docs/CustomNode/en.md'
|
||||
)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/extensions/test_module/docs/CustomNode.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative source src in custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<video><source src="video.mp4" type="video/mp4" /></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="/extensions/test_module/docs/video.mp4"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative source src for core nodes with node-specific base URL', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<video><source src="video.webm" type="video/webm" /></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video.webm"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative img src in raw HTML for custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="/extensions/test_module/docs/image.png"'
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain('alt="Test image"')
|
||||
})
|
||||
|
||||
it('should prefix relative img src in raw HTML for core nodes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image.png"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain('alt="Test image"')
|
||||
})
|
||||
|
||||
it('should not prefix absolute img src in raw HTML', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"')
|
||||
expect(renderedHelpHtml.value).toContain('alt="Absolute"')
|
||||
})
|
||||
|
||||
it('should not prefix external img src in raw HTML', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<img src="https://example.com/image.png" alt="External">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="https://example.com/image.png"'
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain('alt="External"')
|
||||
})
|
||||
|
||||
it('should handle various quote styles in media src attributes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => `# Media Test
|
||||
|
||||
Testing quote styles in properly formed HTML:
|
||||
|
||||
<video src="video1.mp4" controls></video>
|
||||
<video src='video2.mp4' controls></video>
|
||||
<img src="image1.png" alt="Double quotes">
|
||||
<img src='image2.png' alt='Single quotes'>
|
||||
|
||||
<video controls>
|
||||
<source src="video3.mp4" type="video/mp4">
|
||||
<source src='video3.webm' type='video/webm'>
|
||||
</video>
|
||||
|
||||
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
// All media src attributes should be prefixed correctly
|
||||
// Note: marked normalizes quotes to double quotes in output
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video1.mp4"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video2.mp4"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image1.png"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image2.png"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video3.mp4"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video3.webm"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore stale requests when node changes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
let resolveFirst: (value: unknown) => void
|
||||
const firstRequest = new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
|
||||
mockFetch
|
||||
.mockImplementationOnce(() => firstRequest)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Second node content'
|
||||
})
|
||||
|
||||
const { helpContent } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
// Change node before first request completes
|
||||
nodeRef.value = mockCustomNode
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
// Now resolve the first (stale) request
|
||||
resolveFirst!({
|
||||
ok: true,
|
||||
text: async () => '# First node content'
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Should have second node's content, not first
|
||||
expect(helpContent.value).toBe('# Second node content')
|
||||
})
|
||||
})
|
||||
79
src/composables/useNodeHelpContent.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, ref, toValue, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { nodeHelpService } from '@/services/nodeHelpService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil'
|
||||
|
||||
/**
|
||||
* Composable for fetching and rendering node help content.
|
||||
* Creates independent state for each usage, allowing multiple panels
|
||||
* to show help content without interfering with each other.
|
||||
*
|
||||
* @param nodeRef - Reactive reference to the node to show help for
|
||||
* @returns Reactive help content state and rendered HTML
|
||||
*/
|
||||
export function useNodeHelpContent(
|
||||
nodeRef: MaybeRefOrGetter<ComfyNodeDefImpl | null>
|
||||
) {
|
||||
const { locale } = useI18n()
|
||||
|
||||
const helpContent = ref<string>('')
|
||||
const isLoading = ref<boolean>(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let currentRequest: Promise<string> | null = null
|
||||
|
||||
const baseUrl = computed(() => {
|
||||
const node = toValue(nodeRef)
|
||||
if (!node) return ''
|
||||
return getNodeHelpBaseUrl(node)
|
||||
})
|
||||
|
||||
const renderedHelpHtml = computed(() => {
|
||||
return renderMarkdownToHtml(helpContent.value, baseUrl.value)
|
||||
})
|
||||
|
||||
// Watch for node changes and fetch help content
|
||||
watch(
|
||||
() => toValue(nodeRef),
|
||||
async (node) => {
|
||||
helpContent.value = ''
|
||||
error.value = null
|
||||
|
||||
if (node) {
|
||||
isLoading.value = true
|
||||
const request = (currentRequest = nodeHelpService.fetchNodeHelp(
|
||||
node,
|
||||
locale.value || 'en'
|
||||
))
|
||||
|
||||
try {
|
||||
const content = await request
|
||||
if (currentRequest !== request) return
|
||||
helpContent.value = content
|
||||
} catch (e: unknown) {
|
||||
if (currentRequest !== request) return
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
helpContent.value = node.description || ''
|
||||
} finally {
|
||||
if (currentRequest === request) {
|
||||
currentRequest = null
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
helpContent,
|
||||
isLoading,
|
||||
error,
|
||||
baseUrl,
|
||||
renderedHelpHtml
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ export class GroupNodeConfig {
|
||||
}
|
||||
|
||||
getNodeDef(
|
||||
node: GroupNodeData
|
||||
node: GroupNodeData | GroupNodeWorkflowData['nodes'][number]
|
||||
): GroupNodeDef | ComfyNodeDef | null | undefined {
|
||||
if (node.type) {
|
||||
const def = globalDefs[node.type]
|
||||
@@ -386,7 +386,8 @@ export class GroupNodeConfig {
|
||||
let type: string | number | null = linksFrom[0]?.[0]?.[5] ?? null
|
||||
if (type === 'COMBO') {
|
||||
// Use the array items
|
||||
const source = node.outputs?.[0]?.widget?.name
|
||||
const output = node.outputs?.[0] as GroupNodeOutput | undefined
|
||||
const source = output?.widget?.name
|
||||
const nodeIdx = linksFrom[0]?.[0]?.[2]
|
||||
if (source && nodeIdx != null) {
|
||||
const fromTypeName = this.nodeData.nodes[Number(nodeIdx)]?.type
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import {
|
||||
type LGraphNode,
|
||||
type LGraphNodeConstructor,
|
||||
LiteGraph
|
||||
import type {
|
||||
GroupNodeConfigEntry,
|
||||
GroupNodeWorkflowData,
|
||||
LGraphNode,
|
||||
LGraphNodeConstructor
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import { type ComfyApp, app } from '../../scripts/app'
|
||||
@@ -15,18 +17,20 @@ import './groupNodeManage.css'
|
||||
|
||||
const ORDER: symbol = Symbol()
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function merge(target, source) {
|
||||
if (typeof target === 'object' && typeof source === 'object') {
|
||||
for (const key in source) {
|
||||
const sv = source[key]
|
||||
if (typeof sv === 'object') {
|
||||
let tv = target[key]
|
||||
if (!tv) tv = target[key] = {}
|
||||
merge(tv, source[key])
|
||||
} else {
|
||||
target[key] = sv
|
||||
function merge(
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
for (const key in source) {
|
||||
const sv = source[key]
|
||||
if (typeof sv === 'object' && sv !== null) {
|
||||
let tv = target[key] as Record<string, unknown> | undefined
|
||||
if (!tv) {
|
||||
tv = target[key] = {}
|
||||
}
|
||||
merge(tv, sv as Record<string, unknown>)
|
||||
} else {
|
||||
target[key] = sv
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +38,7 @@ function merge(target, source) {
|
||||
}
|
||||
|
||||
export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
tabs: Record<
|
||||
tabs!: Record<
|
||||
'Inputs' | 'Outputs' | 'Widgets',
|
||||
{ tab: HTMLAnchorElement; page: HTMLElement }
|
||||
>
|
||||
@@ -52,31 +55,26 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
>
|
||||
>
|
||||
> = {}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeItems: any[]
|
||||
nodeItems!: HTMLLIElement[]
|
||||
app: ComfyApp
|
||||
// @ts-expect-error fixme ts strict error
|
||||
groupNodeType: LGraphNodeConstructor<LGraphNode>
|
||||
groupNodeDef: any
|
||||
groupData: any
|
||||
groupNodeType!: LGraphNodeConstructor<LGraphNode>
|
||||
groupData!: GroupNodeConfig
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
innerNodesList: HTMLUListElement
|
||||
// @ts-expect-error fixme ts strict error
|
||||
widgetsPage: HTMLElement
|
||||
// @ts-expect-error fixme ts strict error
|
||||
inputsPage: HTMLElement
|
||||
// @ts-expect-error fixme ts strict error
|
||||
outputsPage: HTMLElement
|
||||
draggable: any
|
||||
innerNodesList!: HTMLUListElement
|
||||
widgetsPage!: HTMLElement
|
||||
inputsPage!: HTMLElement
|
||||
outputsPage!: HTMLElement
|
||||
draggable: DraggableList | undefined
|
||||
|
||||
get selectedNodeInnerIndex() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex
|
||||
get selectedNodeInnerIndex(): number {
|
||||
const index = this.selectedNodeIndex
|
||||
if (index == null) throw new Error('No node selected')
|
||||
const item = this.nodeItems[index]
|
||||
if (!item?.dataset.nodeindex) throw new Error('Invalid node item')
|
||||
return +item.dataset.nodeindex
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
constructor(app) {
|
||||
constructor(app: ComfyApp) {
|
||||
super()
|
||||
this.app = app
|
||||
this.element = $el('dialog.comfy-group-manage', {
|
||||
@@ -84,19 +82,15 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
}) as HTMLDialogElement
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
changeTab(tab) {
|
||||
changeTab(tab: keyof ManageGroupDialog['tabs']): void {
|
||||
this.tabs[this.selectedTab].tab.classList.remove('active')
|
||||
this.tabs[this.selectedTab].page.classList.remove('active')
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.tabs[tab].tab.classList.add('active')
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.tabs[tab].page.classList.add('active')
|
||||
this.selectedTab = tab
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
changeNode(index, force?) {
|
||||
changeNode(index: number, force?: boolean): void {
|
||||
if (!force && this.selectedNodeIndex === index) return
|
||||
|
||||
if (this.selectedNodeIndex != null) {
|
||||
@@ -122,43 +116,41 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
this.groupNodeType = LiteGraph.registered_node_types[
|
||||
`${PREFIX}${SEPARATOR}` + this.selectedGroup
|
||||
] as unknown as LGraphNodeConstructor<LGraphNode>
|
||||
this.groupNodeDef = this.groupNodeType.nodeData
|
||||
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)
|
||||
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)!
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
changeGroup(group, reset = true) {
|
||||
changeGroup(group: string, reset = true): void {
|
||||
this.selectedGroup = group
|
||||
this.getGroupData()
|
||||
|
||||
const nodes = this.groupData.nodeData.nodes
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.nodeItems = nodes.map((n, i) =>
|
||||
$el(
|
||||
'li.draggable-item',
|
||||
{
|
||||
dataset: {
|
||||
nodeindex: n.index + ''
|
||||
},
|
||||
onclick: () => {
|
||||
this.changeNode(i)
|
||||
}
|
||||
},
|
||||
[
|
||||
$el('span.drag-handle'),
|
||||
$el(
|
||||
'div',
|
||||
{
|
||||
textContent: n.title ?? n.type
|
||||
this.nodeItems = nodes.map(
|
||||
(n, i) =>
|
||||
$el(
|
||||
'li.draggable-item',
|
||||
{
|
||||
dataset: {
|
||||
nodeindex: n.index + ''
|
||||
},
|
||||
n.title
|
||||
? $el('span', {
|
||||
textContent: n.type
|
||||
})
|
||||
: []
|
||||
)
|
||||
]
|
||||
)
|
||||
onclick: () => {
|
||||
this.changeNode(i)
|
||||
}
|
||||
},
|
||||
[
|
||||
$el('span.drag-handle'),
|
||||
$el(
|
||||
'div',
|
||||
{
|
||||
textContent: n.title ?? n.type
|
||||
},
|
||||
n.title
|
||||
? $el('span', {
|
||||
textContent: n.type
|
||||
})
|
||||
: []
|
||||
)
|
||||
]
|
||||
) as HTMLLIElement
|
||||
)
|
||||
|
||||
this.innerNodesList.replaceChildren(...this.nodeItems)
|
||||
@@ -167,47 +159,46 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
this.selectedNodeIndex = null
|
||||
this.changeNode(0)
|
||||
} else {
|
||||
const items = this.draggable.getAllItems()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let index = items.findIndex((item) => item.classList.contains('selected'))
|
||||
if (index === -1) index = this.selectedNodeIndex
|
||||
const items = this.draggable!.getAllItems()
|
||||
let index = items.findIndex((item: Element) =>
|
||||
item.classList.contains('selected')
|
||||
)
|
||||
if (index === -1) index = this.selectedNodeIndex!
|
||||
this.changeNode(index, true)
|
||||
}
|
||||
|
||||
const ordered = [...nodes]
|
||||
this.draggable?.dispose()
|
||||
this.draggable = new DraggableList(this.innerNodesList, 'li')
|
||||
this.draggable.addEventListener(
|
||||
'dragend',
|
||||
// @ts-expect-error fixme ts strict error
|
||||
({ detail: { oldPosition, newPosition } }) => {
|
||||
if (oldPosition === newPosition) return
|
||||
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0])
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
this.storeModification({
|
||||
nodeIndex: ordered[i].index,
|
||||
section: ORDER,
|
||||
prop: 'order',
|
||||
value: i
|
||||
})
|
||||
}
|
||||
this.draggable.addEventListener('dragend', (e: Event) => {
|
||||
const { oldPosition, newPosition } = (e as CustomEvent).detail
|
||||
if (oldPosition === newPosition) return
|
||||
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0])
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
this.storeModification({
|
||||
nodeIndex: ordered[i].index,
|
||||
section: ORDER,
|
||||
prop: 'order',
|
||||
value: i
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
storeModification(props: {
|
||||
nodeIndex?: number
|
||||
section: symbol
|
||||
section: string | symbol
|
||||
prop: string
|
||||
value: any
|
||||
value: unknown
|
||||
}) {
|
||||
const { nodeIndex, section, prop, value } = props
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const groupMod = (this.modifications[this.selectedGroup] ??= {})
|
||||
const nodesMod = (groupMod.nodes ??= {})
|
||||
const groupKey = this.selectedGroup!
|
||||
const groupMod = (this.modifications[groupKey] ??= {})
|
||||
const nodesMod = ((groupMod as Record<string, unknown>).nodes ??=
|
||||
{}) as Record<string, Record<symbol | string, Record<string, unknown>>>
|
||||
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {})
|
||||
const typeMod = (nodeMod[section] ??= {})
|
||||
if (typeof value === 'object') {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const objMod = (typeMod[prop] ??= {})
|
||||
Object.assign(objMod, value)
|
||||
} else {
|
||||
@@ -215,35 +206,45 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
getEditElement(section, prop, value, placeholder, checked, checkable = true) {
|
||||
if (value === placeholder) value = ''
|
||||
getEditElement(
|
||||
section: string,
|
||||
prop: string | number,
|
||||
value: unknown,
|
||||
placeholder: string,
|
||||
checked: boolean,
|
||||
checkable = true
|
||||
): HTMLDivElement {
|
||||
let displayValue = value === placeholder ? '' : value
|
||||
|
||||
const mods =
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.modifications[this.selectedGroup]?.nodes?.[
|
||||
this.selectedNodeInnerIndex
|
||||
]?.[section]?.[prop]
|
||||
if (mods) {
|
||||
if (mods.name != null) {
|
||||
value = mods.name
|
||||
const groupKey = this.selectedGroup!
|
||||
const mods = (
|
||||
this.modifications[groupKey] as Record<string, unknown> | undefined
|
||||
)?.nodes as
|
||||
| Record<
|
||||
number,
|
||||
Record<string, Record<string, { name?: string; visible?: boolean }>>
|
||||
>
|
||||
| undefined
|
||||
const modEntry = mods?.[this.selectedNodeInnerIndex]?.[section]?.[prop]
|
||||
if (modEntry) {
|
||||
if (modEntry.name != null) {
|
||||
displayValue = modEntry.name
|
||||
}
|
||||
if (mods.visible != null) {
|
||||
checked = mods.visible
|
||||
if (modEntry.visible != null) {
|
||||
checked = modEntry.visible
|
||||
}
|
||||
}
|
||||
|
||||
return $el('div', [
|
||||
$el('input', {
|
||||
value,
|
||||
value: displayValue as string,
|
||||
placeholder,
|
||||
type: 'text',
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onchange: (e) => {
|
||||
onchange: (e: Event) => {
|
||||
this.storeModification({
|
||||
section,
|
||||
prop,
|
||||
value: { name: e.target.value }
|
||||
prop: String(prop),
|
||||
value: { name: (e.target as HTMLInputElement).value }
|
||||
})
|
||||
}
|
||||
}),
|
||||
@@ -252,25 +253,23 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
type: 'checkbox',
|
||||
checked,
|
||||
disabled: !checkable,
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onchange: (e) => {
|
||||
onchange: (e: Event) => {
|
||||
this.storeModification({
|
||||
section,
|
||||
prop,
|
||||
value: { visible: !!e.target.checked }
|
||||
prop: String(prop),
|
||||
value: { visible: !!(e.target as HTMLInputElement).checked }
|
||||
})
|
||||
}
|
||||
})
|
||||
])
|
||||
])
|
||||
]) as HTMLDivElement
|
||||
}
|
||||
|
||||
buildWidgetsPage() {
|
||||
const widgets =
|
||||
this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]
|
||||
const items = Object.keys(widgets ?? {})
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const type = app.rootGraph.extra.groupNodes[this.selectedGroup]
|
||||
const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]!
|
||||
const config = type.config?.[this.selectedNodeInnerIndex]?.input
|
||||
this.widgetsPage.replaceChildren(
|
||||
...items.map((oldName) => {
|
||||
@@ -289,28 +288,25 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
buildInputsPage() {
|
||||
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]
|
||||
const items = Object.keys(inputs ?? {})
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const type = app.rootGraph.extra.groupNodes[this.selectedGroup]
|
||||
const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]!
|
||||
const config = type.config?.[this.selectedNodeInnerIndex]?.input
|
||||
this.inputsPage.replaceChildren(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
...items
|
||||
.map((oldName) => {
|
||||
let value = inputs[oldName]
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
const elements = items
|
||||
.map((oldName) => {
|
||||
const value = inputs[oldName]
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.getEditElement(
|
||||
'input',
|
||||
oldName,
|
||||
value,
|
||||
oldName,
|
||||
config?.[oldName]?.visible !== false
|
||||
)
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
return this.getEditElement(
|
||||
'input',
|
||||
oldName,
|
||||
value,
|
||||
oldName,
|
||||
config?.[oldName]?.visible !== false
|
||||
)
|
||||
})
|
||||
.filter((el): el is HTMLDivElement => el !== null)
|
||||
this.inputsPage.replaceChildren(...elements)
|
||||
return !!items.length
|
||||
}
|
||||
|
||||
@@ -323,38 +319,35 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
const groupOutputs =
|
||||
this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const type = app.rootGraph.extra.groupNodes[this.selectedGroup]
|
||||
const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]!
|
||||
const config = type.config?.[this.selectedNodeInnerIndex]?.output
|
||||
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]
|
||||
const checkable = node.type !== 'PrimitiveNode'
|
||||
this.outputsPage.replaceChildren(
|
||||
...outputs
|
||||
// @ts-expect-error fixme ts strict error
|
||||
.map((type, slot) => {
|
||||
const groupOutputIndex = groupOutputs?.[slot]
|
||||
const oldName = innerNodeDef.output_name?.[slot] ?? type
|
||||
let value = config?.[slot]?.name
|
||||
const visible = config?.[slot]?.visible || groupOutputIndex != null
|
||||
if (!value || value === oldName) {
|
||||
value = ''
|
||||
}
|
||||
return this.getEditElement(
|
||||
'output',
|
||||
slot,
|
||||
value,
|
||||
oldName,
|
||||
visible,
|
||||
checkable
|
||||
)
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
const elements = outputs.map((outputType: unknown, slot: number) => {
|
||||
const groupOutputIndex = groupOutputs?.[slot]
|
||||
const oldName = innerNodeDef?.output_name?.[slot] ?? String(outputType)
|
||||
let value = config?.[slot]?.name
|
||||
const visible = config?.[slot]?.visible || groupOutputIndex != null
|
||||
if (!value || value === oldName) {
|
||||
value = ''
|
||||
}
|
||||
return this.getEditElement(
|
||||
'output',
|
||||
slot,
|
||||
value,
|
||||
oldName,
|
||||
visible,
|
||||
checkable
|
||||
)
|
||||
})
|
||||
this.outputsPage.replaceChildren(...elements)
|
||||
return !!outputs.length
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
show(type?) {
|
||||
override show(groupNodeType?: string | HTMLElement | HTMLElement[]): void {
|
||||
// Extract string type - this method repurposes the show signature
|
||||
const nodeType =
|
||||
typeof groupNodeType === 'string' ? groupNodeType : undefined
|
||||
const groupNodes = Object.keys(app.rootGraph.extra?.groupNodes ?? {}).sort(
|
||||
(a, b) => a.localeCompare(b)
|
||||
)
|
||||
@@ -371,24 +364,27 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
this.outputsPage
|
||||
])
|
||||
|
||||
this.tabs = [
|
||||
type TabName = 'Inputs' | 'Widgets' | 'Outputs'
|
||||
const tabEntries: [TabName, HTMLElement][] = [
|
||||
['Inputs', this.inputsPage],
|
||||
['Widgets', this.widgetsPage],
|
||||
['Outputs', this.outputsPage]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
].reduce((p, [name, page]: [string, HTMLElement]) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
p[name] = {
|
||||
tab: $el('a', {
|
||||
onclick: () => {
|
||||
this.changeTab(name)
|
||||
},
|
||||
textContent: name
|
||||
}),
|
||||
page
|
||||
}
|
||||
return p
|
||||
}, {}) as any
|
||||
]
|
||||
this.tabs = tabEntries.reduce(
|
||||
(p, [name, page]) => {
|
||||
p[name] = {
|
||||
tab: $el('a', {
|
||||
onclick: () => {
|
||||
this.changeTab(name)
|
||||
},
|
||||
textContent: name
|
||||
}) as HTMLAnchorElement,
|
||||
page
|
||||
}
|
||||
return p
|
||||
},
|
||||
{} as ManageGroupDialog['tabs']
|
||||
)
|
||||
|
||||
const outer = $el('div.comfy-group-manage-outer', [
|
||||
$el('header', [
|
||||
@@ -396,15 +392,14 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
$el(
|
||||
'select',
|
||||
{
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onchange: (e) => {
|
||||
this.changeGroup(e.target.value)
|
||||
onchange: (e: Event) => {
|
||||
this.changeGroup((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
},
|
||||
groupNodes.map((g) =>
|
||||
$el('option', {
|
||||
textContent: g,
|
||||
selected: `${PREFIX}${SEPARATOR}${g}` === type,
|
||||
selected: `${PREFIX}${SEPARATOR}${g}` === nodeType,
|
||||
value: g
|
||||
})
|
||||
)
|
||||
@@ -439,8 +434,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
`Are you sure you want to remove the node: "${this.selectedGroup}"`
|
||||
)
|
||||
) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
delete app.rootGraph.extra.groupNodes[this.selectedGroup]
|
||||
delete app.rootGraph.extra.groupNodes![this.selectedGroup!]
|
||||
LiteGraph.unregisterNodeType(
|
||||
`${PREFIX}${SEPARATOR}` + this.selectedGroup
|
||||
)
|
||||
@@ -454,97 +448,106 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
'button.comfy-btn',
|
||||
{
|
||||
onclick: async () => {
|
||||
let nodesByType
|
||||
let recreateNodes = []
|
||||
const types = {}
|
||||
type NodesByType = Record<string, LGraphNode[]>
|
||||
let nodesByType: NodesByType | undefined
|
||||
const recreateNodes: LGraphNode[] = []
|
||||
const types: Record<string, GroupNodeWorkflowData> = {}
|
||||
for (const g in this.modifications) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const type = app.rootGraph.extra.groupNodes[g]
|
||||
let config = (type.config ??= {})
|
||||
const groupNodeData = app.rootGraph.extra.groupNodes![g]!
|
||||
let config = (groupNodeData.config ??= {})
|
||||
|
||||
let nodeMods = this.modifications[g]?.nodes
|
||||
type NodeMods = Record<
|
||||
string,
|
||||
Record<symbol | string, Record<string, unknown>>
|
||||
>
|
||||
let nodeMods = this.modifications[g]?.nodes as
|
||||
| NodeMods
|
||||
| undefined
|
||||
if (nodeMods) {
|
||||
const keys = Object.keys(nodeMods)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
if (nodeMods[keys[0]][ORDER]) {
|
||||
if (nodeMods[keys[0]]?.[ORDER]) {
|
||||
// If any node is reordered, they will all need sequencing
|
||||
const orderedNodes = []
|
||||
const orderedMods = {}
|
||||
const orderedConfig = {}
|
||||
const orderedNodes: GroupNodeWorkflowData['nodes'] = []
|
||||
const orderedMods: NodeMods = {}
|
||||
const orderedConfig: Record<number, GroupNodeConfigEntry> =
|
||||
{}
|
||||
|
||||
for (const n of keys) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const order = nodeMods[n][ORDER].order
|
||||
orderedNodes[order] = type.nodes[+n]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const order = (nodeMods[n][ORDER] as { order: number })
|
||||
.order
|
||||
orderedNodes[order] = groupNodeData.nodes[+n]
|
||||
orderedMods[order] = nodeMods[n]
|
||||
orderedNodes[order].index = order
|
||||
}
|
||||
|
||||
// Rewrite links
|
||||
for (const l of type.links) {
|
||||
// @ts-expect-error l[0]/l[2] used as node index
|
||||
if (l[0] != null) l[0] = type.nodes[l[0]].index
|
||||
// @ts-expect-error l[0]/l[2] used as node index
|
||||
if (l[2] != null) l[2] = type.nodes[l[2]].index
|
||||
const nodesLen = groupNodeData.nodes.length
|
||||
for (const l of groupNodeData.links) {
|
||||
const srcIdx = l[0] as number
|
||||
const dstIdx = l[2] as number
|
||||
if (srcIdx != null && srcIdx < nodesLen)
|
||||
l[0] = groupNodeData.nodes[srcIdx].index!
|
||||
if (dstIdx != null && dstIdx < nodesLen)
|
||||
l[2] = groupNodeData.nodes[dstIdx].index!
|
||||
}
|
||||
|
||||
// Rewrite externals
|
||||
if (type.external) {
|
||||
for (const ext of type.external) {
|
||||
if (ext[0] != null) {
|
||||
// @ts-expect-error ext[0] used as node index
|
||||
ext[0] = type.nodes[ext[0]].index
|
||||
if (groupNodeData.external) {
|
||||
for (const ext of groupNodeData.external) {
|
||||
const extIdx = ext[0] as number
|
||||
if (extIdx != null && extIdx < nodesLen) {
|
||||
ext[0] = groupNodeData.nodes[extIdx].index!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite modifications
|
||||
for (const id of keys) {
|
||||
// @ts-expect-error id used as node index
|
||||
if (config[id]) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
orderedConfig[type.nodes[id].index] = config[id]
|
||||
if (config[+id]) {
|
||||
orderedConfig[groupNodeData.nodes[+id].index!] =
|
||||
config[+id]
|
||||
}
|
||||
// @ts-expect-error id used as config key
|
||||
delete config[id]
|
||||
delete config[+id]
|
||||
}
|
||||
|
||||
type.nodes = orderedNodes
|
||||
groupNodeData.nodes = orderedNodes
|
||||
nodeMods = orderedMods
|
||||
type.config = config = orderedConfig
|
||||
groupNodeData.config = config = orderedConfig
|
||||
}
|
||||
|
||||
merge(config, nodeMods)
|
||||
merge(
|
||||
config as Record<string, unknown>,
|
||||
nodeMods as Record<string, unknown>
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
types[g] = type
|
||||
types[g] = groupNodeData
|
||||
|
||||
if (!nodesByType) {
|
||||
nodesByType = app.rootGraph.nodes.reduce((p, n) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
p[n.type] ??= []
|
||||
// @ts-expect-error fixme ts strict error
|
||||
p[n.type].push(n)
|
||||
return p
|
||||
}, {})
|
||||
nodesByType = app.rootGraph.nodes.reduce<NodesByType>(
|
||||
(p, n) => {
|
||||
const nodeType = n.type ?? ''
|
||||
p[nodeType] ??= []
|
||||
p[nodeType].push(n)
|
||||
return p
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const nodes = nodesByType[`${PREFIX}${SEPARATOR}` + g]
|
||||
if (nodes) recreateNodes.push(...nodes)
|
||||
const groupTypeNodes = nodesByType[`${PREFIX}${SEPARATOR}` + g]
|
||||
if (groupTypeNodes) recreateNodes.push(...groupTypeNodes)
|
||||
}
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(types, [])
|
||||
|
||||
for (const node of recreateNodes) {
|
||||
node.recreate()
|
||||
node.recreate?.()
|
||||
}
|
||||
|
||||
this.modifications = {}
|
||||
this.app.canvas.setDirty(true, true)
|
||||
this.changeGroup(this.selectedGroup, false)
|
||||
this.changeGroup(this.selectedGroup!, false)
|
||||
}
|
||||
},
|
||||
'Save'
|
||||
@@ -559,8 +562,8 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
|
||||
this.element.replaceChildren(outer)
|
||||
this.changeGroup(
|
||||
type
|
||||
? (groupNodes.find((g) => `${PREFIX}${SEPARATOR}${g}` === type) ??
|
||||
nodeType
|
||||
? (groupNodes.find((g) => `${PREFIX}${SEPARATOR}${g}` === nodeType) ??
|
||||
groupNodes[0])
|
||||
: groupNodes[0]
|
||||
)
|
||||
|
||||
12
src/extensions/core/imageCrop.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.ImageCrop',
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'ImageCrop') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
|
||||
}
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import './groupNode'
|
||||
import './groupNodeManage'
|
||||
import './groupOptions'
|
||||
import './imageCompare'
|
||||
import './imageCrop'
|
||||
import './load3d'
|
||||
import './maskeditor'
|
||||
import './nodeTemplates'
|
||||
|
||||