Merge branch 'main' into pysssss/asset-delete-progress

This commit is contained in:
Jin Yi
2026-01-21 13:45:51 +09:00
committed by GitHub
192 changed files with 5868 additions and 2349 deletions

View File

@@ -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"

View File

@@ -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
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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"
]
}

View File

@@ -1,2 +0,0 @@
packages/registry-types/src/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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 (

View File

@@ -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"
]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View 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
```

View 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
```

View 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
View 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
```

View 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

View File

@@ -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'],

View File

@@ -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}`
]
}

View File

@@ -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}`
]

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
View 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`)

View File

@@ -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
View 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()`)

View File

@@ -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

View 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>

View File

@@ -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>

View File

@@ -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"
>

View File

@@ -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')
})
})

View 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>

View File

@@ -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) => ({

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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
}>()

View File

@@ -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()

View File

@@ -100,6 +100,7 @@
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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(

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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)

View 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
}
}

View File

@@ -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

View 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 () => '![image](test.jpg)'
})
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')
})
})

View 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
}
}

View File

@@ -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

View File

@@ -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]
)

View 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)])
}
})

View File

@@ -10,6 +10,7 @@ import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './imageCrop'
import './load3d'
import './maskeditor'
import './nodeTemplates'

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