Compare commits
10 Commits
si/demo
...
jobs-api-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be26408dd3 | ||
|
|
d27e177d61 | ||
|
|
9b912ff454 | ||
|
|
7ef92b0411 | ||
|
|
400249cb08 | ||
|
|
eada1eca50 | ||
|
|
f44c0e3842 | ||
|
|
6485f871ba | ||
|
|
71c6d62c7e | ||
|
|
b198828887 |
21
.cursor/rules/unit-test.mdc
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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
@@ -1,14 +0,0 @@
|
||||
# PR Review Context
|
||||
|
||||
Context for automated PR review system.
|
||||
|
||||
## Review Scope
|
||||
|
||||
This automated review performs comprehensive analysis:
|
||||
- Architecture and design patterns
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Code quality and maintainability
|
||||
- Integration concerns
|
||||
|
||||
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
||||
39
.github/CLAUDE.md
vendored
@@ -1,3 +1,36 @@
|
||||
<!-- A rose by any other name would smell as sweet,
|
||||
But Claude insists on files named for its own conceit. -->
|
||||
@AGENTS.md
|
||||
# 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`.
|
||||
@@ -1,17 +0,0 @@
|
||||
# Storybook Guidelines
|
||||
|
||||
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
|
||||
|
||||
## Available Context
|
||||
|
||||
Stories have access to:
|
||||
- All ComfyUI stores
|
||||
- PrimeVue with ComfyUI theming
|
||||
- i18n system
|
||||
- CSS variables and styling
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Import Errors**: Verify `@/` alias works
|
||||
2. **Missing Styles**: Check CSS imports in `preview.ts`
|
||||
3. **Store Errors**: Check store initialization in setup
|
||||
@@ -1,3 +1,197 @@
|
||||
<!-- Though standards bloom in open fields so wide,
|
||||
Anthropic walks a path of lonely pride. -->
|
||||
@AGENTS.md
|
||||
# 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
|
||||
17
AGENTS.md
@@ -1,7 +1,5 @@
|
||||
# Repository Guidelines
|
||||
|
||||
See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob).
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source: `src/`
|
||||
@@ -48,21 +46,6 @@ The project uses **Nx** for build orchestration and task management
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm format` / `pnpm format:check`: Prettier
|
||||
- `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
|
||||
|
||||
|
||||
31
CLAUDE.md
@@ -1 +1,30 @@
|
||||
@AGENTS.md
|
||||
# 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
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
@@ -55,7 +55,8 @@
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -539,4 +539,4 @@ For comprehensive troubleshooting and technical support, please refer to our off
|
||||
|
||||
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
|
||||
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
|
||||
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting
|
||||
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting
|
||||
@@ -1,8 +0,0 @@
|
||||
# E2E Testing Guidelines
|
||||
|
||||
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `assets/` - Test data (JSON workflows, fixtures)
|
||||
- Tests use premade JSON workflows to load desired graph state
|
||||
@@ -1,3 +1,17 @@
|
||||
<!-- In gardens where the agents freely play,
|
||||
One stubborn flower turns the other way. -->
|
||||
@AGENTS.md
|
||||
# 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
|
||||
164
browser_tests/fixtures/utils/taskHistory.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { Request, Route } from '@playwright/test'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
TaskItem,
|
||||
TaskOutput
|
||||
} from '../../../src/schemas/apiSchema'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
/** keyof TaskOutput[string] */
|
||||
type OutputFileType = 'images' | 'audio' | 'animated'
|
||||
|
||||
const DEFAULT_IMAGE = 'example.webp'
|
||||
|
||||
const getFilenameParam = (request: Request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.searchParams.get('filename') || DEFAULT_IMAGE
|
||||
}
|
||||
|
||||
const getContentType = (filename: string, fileType: OutputFileType) => {
|
||||
const subtype = path.extname(filename).slice(1)
|
||||
switch (fileType) {
|
||||
case 'images':
|
||||
return `image/${subtype}`
|
||||
case 'audio':
|
||||
return `audio/${subtype}`
|
||||
case 'animated':
|
||||
return `video/${subtype}`
|
||||
}
|
||||
}
|
||||
|
||||
const setQueueIndex = (task: TaskItem) => {
|
||||
task.prompt[0] = TaskHistory.queueIndex++
|
||||
}
|
||||
|
||||
const setPromptId = (task: TaskItem) => {
|
||||
task.prompt[1] = uuidv4()
|
||||
}
|
||||
|
||||
export default class TaskHistory {
|
||||
static queueIndex = 0
|
||||
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
||||
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
taskType: 'History'
|
||||
}
|
||||
private tasks: HistoryTaskItem[] = []
|
||||
private outputContentTypes: Map<string, string> = new Map()
|
||||
|
||||
constructor(readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private loadAsset: (filename: string) => Buffer = _.memoize(
|
||||
(filename: string) => {
|
||||
const filePath = this.comfyPage.assetPath(filename)
|
||||
return fs.readFileSync(filePath)
|
||||
}
|
||||
)
|
||||
|
||||
private async handleGetHistory(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.tasks)
|
||||
})
|
||||
}
|
||||
|
||||
private async handleGetView(route: Route) {
|
||||
const fileName = getFilenameParam(route.request())
|
||||
if (!this.outputContentTypes.has(fileName)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
const asset = this.loadAsset(fileName)
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: this.outputContentTypes.get(fileName),
|
||||
body: asset,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Content-Length': asset.byteLength.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async setupRoutes() {
|
||||
return this.comfyPage.page.route(
|
||||
/.*\/api\/(view|history)(\?.*)?$/,
|
||||
async (route) => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
|
||||
const isViewReq = request.url().includes('view') && method === 'GET'
|
||||
if (isViewReq) return this.handleGetView(route)
|
||||
|
||||
const isHistoryPath = request.url().includes('history')
|
||||
const isGetHistoryReq = isHistoryPath && method === 'GET'
|
||||
if (isGetHistoryReq) return this.handleGetHistory(route)
|
||||
|
||||
const isClearReq =
|
||||
method === 'POST' &&
|
||||
isHistoryPath &&
|
||||
request.postDataJSON()?.clear === true
|
||||
if (isClearReq) return this.clearTasks()
|
||||
|
||||
return route.continue()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private createOutputs(
|
||||
filenames: string[],
|
||||
filetype: OutputFileType
|
||||
): TaskOutput {
|
||||
return filenames.reduce((outputs, filename, i) => {
|
||||
const nodeId = `${i + 1}`
|
||||
outputs[nodeId] = {
|
||||
[filetype]: [{ filename, subfolder: '', type: 'output' }]
|
||||
}
|
||||
const contentType = getContentType(filename, filetype)
|
||||
this.outputContentTypes.set(filename, contentType)
|
||||
return outputs
|
||||
}, {})
|
||||
}
|
||||
|
||||
private addTask(task: HistoryTaskItem) {
|
||||
setPromptId(task)
|
||||
setQueueIndex(task)
|
||||
this.tasks.unshift(task) // Tasks are added to the front of the queue
|
||||
}
|
||||
|
||||
clearTasks(): this {
|
||||
this.tasks = []
|
||||
return this
|
||||
}
|
||||
|
||||
withTask(
|
||||
outputFilenames: string[],
|
||||
outputFiletype: OutputFileType = 'images',
|
||||
overrides: Partial<HistoryTaskItem> = {}
|
||||
): this {
|
||||
this.addTask({
|
||||
...TaskHistory.defaultTask,
|
||||
outputs: this.createOutputs(outputFilenames, outputFiletype),
|
||||
...overrides
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/** Repeats the last task in the task history a specified number of times. */
|
||||
repeat(n: number): this {
|
||||
for (let i = 0; i < n; i++)
|
||||
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,33 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -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}`
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
"short_name": "ComfyUI",
|
||||
"description": "ComfyUI: AI image generation platform",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/images/comfy-logo-single.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.38.4",
|
||||
"version": "1.38.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||
--modal-card-tag-background: var(--color-smoke-200);
|
||||
--modal-card-tag-background: var(--color-smoke-400);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-white);
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Source Code Guidelines
|
||||
|
||||
## Error Handling
|
||||
|
||||
- User-friendly and actionable messages
|
||||
- Proper error propagation
|
||||
|
||||
## Security
|
||||
|
||||
- Sanitize HTML with DOMPurify
|
||||
- Validate trusted sources
|
||||
- Never log secrets
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
- Follow domain-driven design for organizing files/folders
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
- Clean up subscriptions
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use `es-toolkit` for utility functions
|
||||
- Use TypeScript for type safety
|
||||
- Avoid `@ts-expect-error` - fix the underlying issue
|
||||
- Use `vue-i18n` for ALL user-facing strings (`src/locales/en/main.json`)
|
||||
@@ -1,3 +1,57 @@
|
||||
<!-- We forked the path, yet here we are again—
|
||||
Maintaining two files where one would have been sane. -->
|
||||
@AGENTS.md
|
||||
# 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`
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Component Guidelines
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
@@ -1,3 +1,45 @@
|
||||
<!-- "Play nice with others," mother always said,
|
||||
But Claude prefers its own file name instead. -->
|
||||
@AGENTS.md
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center" :class="cn(!isDocked && '-ml-2')">
|
||||
<div class="flex h-full items-center">
|
||||
<div
|
||||
v-if="isDragging && !isDocked"
|
||||
:class="actionbarClass"
|
||||
@@ -77,6 +77,7 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
|
||||
const tabContainer = document.querySelector('.workflow-tabs-container')
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
@@ -87,7 +88,14 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body
|
||||
containerElement: document.body,
|
||||
onMove: (event) => {
|
||||
// Prevent dragging the menu over the top of the tabs
|
||||
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
|
||||
if (event.y < minY) {
|
||||
event.y = minY
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update storedPosition when x or y changes
|
||||
|
||||
@@ -55,4 +55,17 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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-1 h-10 flex flex-row gap-1'
|
||||
content: 'p-2 h-12 flex flex-row gap-1'
|
||||
}"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
|
||||
@@ -6,19 +6,67 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
import Button from '@/components/ui/button/Button.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'
|
||||
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
const mockLGraphNode = {
|
||||
type: 'TestNode',
|
||||
title: 'Test Node'
|
||||
}
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
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
|
||||
})
|
||||
}))
|
||||
|
||||
describe('InfoButton', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
let nodeDefStore: ReturnType<typeof useNodeDefStore>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -33,6 +81,9 @@ describe('InfoButton', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefStore = useNodeDefStore()
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -41,15 +92,58 @@ describe('InfoButton', () => {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
directives: { tooltip: Tooltip },
|
||||
components: { Button }
|
||||
stubs: {
|
||||
'i-lucide:info': true,
|
||||
Button: {
|
||||
template:
|
||||
'<button class="help-button" severity="secondary"><slot /></button>',
|
||||
props: ['severity', 'text', 'class'],
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('should open the info panel on click', async () => {
|
||||
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)
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('[data-testid="info-button"]')
|
||||
const button = wrapper.find('button')
|
||||
await button.trigger('click')
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { renderedHelpHtml, isLoading, error } = useNodeHelpContent(() => node)
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
|
||||
|
||||
const inputList = computed(() =>
|
||||
Object.values(node.inputs).map((spec) => ({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const QueueJobItemStub = defineComponent({
|
||||
name: 'QueueJobItemStub',
|
||||
@@ -26,25 +25,20 @@ const QueueJobItemStub = defineComponent({
|
||||
template: '<div class="queue-job-item-stub"></div>'
|
||||
})
|
||||
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
|
||||
const { taskRef, ...rest } = overrides
|
||||
return {
|
||||
id: 'job-id',
|
||||
title: 'Example job',
|
||||
meta: 'Meta text',
|
||||
state: 'running',
|
||||
iconName: 'icon',
|
||||
iconImageUrl: 'https://example.com/icon.png',
|
||||
showClear: true,
|
||||
taskRef: (taskRef ?? {
|
||||
workflow: { id: 'workflow-id' }
|
||||
}) as TaskItemImpl,
|
||||
progressTotalPercent: 60,
|
||||
progressCurrentPercent: 30,
|
||||
runningNodeName: 'Node A',
|
||||
...rest
|
||||
}
|
||||
}
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'job-id',
|
||||
title: 'Example job',
|
||||
meta: 'Meta text',
|
||||
state: 'running',
|
||||
iconName: 'icon',
|
||||
iconImageUrl: 'https://example.com/icon.png',
|
||||
showClear: true,
|
||||
taskRef: { workflow: { id: 'workflow-id' } },
|
||||
progressTotalPercent: 60,
|
||||
progressCurrentPercent: 30,
|
||||
runningNodeName: 'Node A',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountComponent = (groups: JobGroup[]) =>
|
||||
mount(JobGroupsList, {
|
||||
|
||||
@@ -18,7 +18,7 @@ const createTaskWithError = (
|
||||
errorMessage,
|
||||
executionError,
|
||||
createTime: createTime ?? Date.now()
|
||||
}) as Partial<TaskItemImpl> as TaskItemImpl
|
||||
}) as unknown as TaskItemImpl
|
||||
|
||||
describe('useJobErrorReporting', () => {
|
||||
let taskState = ref<TaskItemImpl | null>(null)
|
||||
@@ -129,6 +129,31 @@ describe('useJobErrorReporting', () => {
|
||||
expect(showErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes execution_error directly to dialog', () => {
|
||||
const executionError: ExecutionError = {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 12345,
|
||||
node_id: '5',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'Error',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['line 1'],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
taskState.value = createTaskWithError(
|
||||
'job-1',
|
||||
'Error',
|
||||
executionError,
|
||||
12345
|
||||
)
|
||||
|
||||
composable.reportJobError()
|
||||
|
||||
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||
expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError)
|
||||
})
|
||||
|
||||
it('does nothing when no error message and no execution_error', () => {
|
||||
taskState.value = createTaskWithError('job-1')
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<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[]
|
||||
@@ -11,10 +13,20 @@ 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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
@@ -6,8 +7,7 @@ import {
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
triggerRef,
|
||||
watch
|
||||
triggerRef
|
||||
} from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -225,10 +225,13 @@ function setDraggableState() {
|
||||
activeWidgets.value = aw
|
||||
}
|
||||
}
|
||||
watch(filteredActive, () => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
filteredActive,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
if (activeNode.value) pruneDisconnected(activeNode.value)
|
||||
|
||||
@@ -44,9 +44,7 @@
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle
|
||||
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
|
||||
/>
|
||||
<ModeToggle v-if="showLinearToggle" />
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
@@ -54,7 +52,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { useResizeObserver, whenever } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -70,7 +68,6 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
@@ -86,11 +83,15 @@ const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const sideToolbarRef = ref<HTMLElement>()
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const showLinearToggle = ref(useFeatureFlags().flags.linearToggleEnabled)
|
||||
whenever(
|
||||
() => canvasStore.linearMode,
|
||||
() => (showLinearToggle.value = true)
|
||||
)
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
|
||||
@@ -204,9 +204,6 @@
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -247,12 +244,6 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface JobOutputItem {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -331,13 +322,7 @@ const {
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
|
||||
const {
|
||||
downloadMultipleAssets,
|
||||
deleteMultipleAssets,
|
||||
addMultipleToWorkflow,
|
||||
openMultipleWorkflows,
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
@@ -493,10 +478,7 @@ function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
// Delay clearing to allow command callbacks to emit before component unmounts
|
||||
requestAnimationFrame(() => {
|
||||
contextMenuAsset.value = null
|
||||
})
|
||||
contextMenuAsset.value = null
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
@@ -513,21 +495,6 @@ const handleClearQueue = async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
||||
await addMultipleToWorkflow(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
|
||||
await openMultipleWorkflows(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
||||
await exportMultipleWorkflows(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
|
||||
@@ -582,29 +549,30 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
outputsToDisplay.length < outputCount
|
||||
|
||||
if (needsFullOutputs) {
|
||||
try {
|
||||
const jobDetail = await getJobDetail(promptId)
|
||||
if (jobDetail?.outputs) {
|
||||
// Convert job outputs to ResultItemImpl array
|
||||
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
|
||||
([nodeId, nodeOutputs]) =>
|
||||
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
||||
(items as JobOutputItem[])
|
||||
.map(
|
||||
(item) =>
|
||||
new ResultItemImpl({
|
||||
...item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
)
|
||||
.filter((r) => r.supportsPreview)
|
||||
const jobDetail = await getJobDetail(promptId)
|
||||
if (jobDetail?.outputs) {
|
||||
// Convert job outputs to ResultItemImpl array
|
||||
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
|
||||
([nodeId, nodeOutputs]) =>
|
||||
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
||||
(
|
||||
items as Array<{
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
}>
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch job detail for folder view:', error)
|
||||
outputsToDisplay = []
|
||||
.map(
|
||||
(item) =>
|
||||
new ResultItemImpl({
|
||||
...item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
)
|
||||
.filter((r) => r.supportsPreview)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,15 +139,7 @@ import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import type { Ref } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
render
|
||||
} from 'vue'
|
||||
import { computed, h, nextTick, onMounted, ref, render } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
@@ -179,8 +171,6 @@ 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()
|
||||
@@ -282,7 +272,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
draggable: node.leaf,
|
||||
renderDragPreview(container) {
|
||||
const vnode = h(NodePreview, { nodeDef: node.data })
|
||||
vnode.appContext = appContext
|
||||
render(vnode, container)
|
||||
return () => {
|
||||
render(null, container)
|
||||
|
||||
@@ -22,15 +22,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
nextTick,
|
||||
ref,
|
||||
render,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { computed, h, nextTick, ref, render, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FolderCustomizationDialog from '@/components/common/CustomizationDialog.vue'
|
||||
@@ -49,8 +41,6 @@ import type {
|
||||
TreeNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const instance = getCurrentInstance()!
|
||||
const appContext = instance.appContext
|
||||
const props = defineProps<{
|
||||
filteredNodeDefs: ComfyNodeDefImpl[]
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
@@ -164,7 +154,6 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
},
|
||||
renderDragPreview(container) {
|
||||
const vnode = h(NodePreview, { nodeDef: node.data })
|
||||
vnode.appContext = appContext
|
||||
render(vnode, container)
|
||||
return () => {
|
||||
render(null, container)
|
||||
|
||||
100
src/components/sidebar/tabs/nodeLibrary/NodeHelpPage.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState')
|
||||
vi.mock('@/stores/workspace/nodeHelpStore')
|
||||
|
||||
const baseNode = {
|
||||
nodePath: 'NodeA',
|
||||
display_name: 'Node A',
|
||||
description: '',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
|
||||
describe('NodeHelpPage', () => {
|
||||
const selection = ref<any | null>(null)
|
||||
let openHelp: ReturnType<typeof vi.fn>
|
||||
|
||||
const mountPage = () =>
|
||||
mount(NodeHelpPage, {
|
||||
props: { node: baseNode as any },
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
stubs: {
|
||||
ProgressSpinner: true,
|
||||
Button: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
selection.value = null
|
||||
openHelp = vi.fn()
|
||||
|
||||
vi.mocked(useSelectionState).mockReturnValue({
|
||||
nodeDef: computed(() => selection.value)
|
||||
} as any)
|
||||
|
||||
vi.mocked(useNodeHelpStore).mockReturnValue({
|
||||
renderedHelpHtml: ref('<p>help</p>'),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
isHelpOpen: true,
|
||||
currentHelpNode: { nodePath: 'NodeA' },
|
||||
openHelp,
|
||||
closeHelp: vi.fn()
|
||||
} as any)
|
||||
})
|
||||
|
||||
test('opens help for a newly selected node while help is open', async () => {
|
||||
const wrapper = mountPage()
|
||||
|
||||
selection.value = { nodePath: 'NodeB' }
|
||||
await flushPromises()
|
||||
|
||||
expect(openHelp).toHaveBeenCalledWith({ nodePath: 'NodeB' })
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('does not reopen help when the same node stays selected', async () => {
|
||||
const wrapper = mountPage()
|
||||
|
||||
selection.value = { nodePath: 'NodeA' }
|
||||
await flushPromises()
|
||||
|
||||
expect(openHelp).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('does not react to selection when help is closed', async () => {
|
||||
vi.mocked(useNodeHelpStore).mockReturnValueOnce({
|
||||
renderedHelpHtml: ref('<p>help</p>'),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
isHelpOpen: false,
|
||||
currentHelpNode: null,
|
||||
openHelp,
|
||||
closeHelp: vi.fn()
|
||||
} as any)
|
||||
|
||||
const wrapper = mountPage()
|
||||
|
||||
selection.value = { nodePath: 'NodeB' }
|
||||
await flushPromises()
|
||||
|
||||
expect(openHelp).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -21,13 +21,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { nodeDef } = useSelectionState()
|
||||
|
||||
const activeHelpDef = computed(() =>
|
||||
nodeHelpStore.isHelpOpen ? nodeDef.value : null
|
||||
)
|
||||
|
||||
// Keep the open help page synced with the current selection while help is open.
|
||||
whenever(activeHelpDef, (def) => {
|
||||
const currentHelpNode = nodeHelpStore.currentHelpNode
|
||||
if (currentHelpNode?.nodePath === def.nodePath) return
|
||||
nodeHelpStore.openHelp(def)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.learnMore')"
|
||||
@click.stop="onHelpClick"
|
||||
@click.stop="props.openNodeHelp(nodeDef)"
|
||||
>
|
||||
<i class="pi pi-question size-3.5" />
|
||||
</Button>
|
||||
@@ -85,7 +85,6 @@ 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'
|
||||
@@ -113,13 +112,6 @@ 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(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full shrink-0 items-center gap-1 empty:hidden">
|
||||
<div class="flex h-full shrink-0 items-center gap-1">
|
||||
<Button
|
||||
v-for="(button, index) in actionBarButtonStore.buttons"
|
||||
:key="index"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
|
||||
compact && 'size-full aspect-square'
|
||||
compact && 'size-full '
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
|
||||
{{ $t('menu.helpAndFeedback') }}
|
||||
<i class="icon-[lucide--circle-help] ml-0.5" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Button
|
||||
v-if="isActiveTab"
|
||||
class="context-menu-button -mx-1 w-auto px-1 py-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
@click.stop="handleMenuClick"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
</Button>
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
v-else-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
||||
@@ -38,9 +47,26 @@
|
||||
:thumbnail-url="thumbnailUrl"
|
||||
:is-active-tab="isActiveTab"
|
||||
/>
|
||||
|
||||
<Menu
|
||||
v-if="isActiveTab"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'background-color: var(--comfy-menu-bg)'
|
||||
},
|
||||
itemLink: {
|
||||
class: 'py-2'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuState } from 'primevue/menu'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -49,11 +75,14 @@ import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
@@ -118,6 +147,12 @@ const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(() =>
|
||||
useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
)
|
||||
|
||||
// Event handlers that delegate to the popover component
|
||||
const handleMouseEnter = (event: Event) => {
|
||||
popoverRef.value?.showPopover(event)
|
||||
@@ -131,6 +166,14 @@ const handleClick = (event: Event) => {
|
||||
popoverRef.value?.togglePopover(event)
|
||||
}
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'workflow_tab_menu_selected'
|
||||
})
|
||||
// Show breadcrumb menu instead of emitting context click
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
|
||||
@@ -47,7 +47,7 @@ const transform = computed(() => {
|
||||
<template>
|
||||
<div
|
||||
ref="zoomPane"
|
||||
class="contain-size place-content-center"
|
||||
class="contain-size flex place-content-center"
|
||||
@wheel="handleWheel"
|
||||
@pointerdown.prevent="handleDown"
|
||||
@pointermove="handleMove"
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TagsInput from './TagsInput.vue'
|
||||
import TagsInputInput from './TagsInputInput.vue'
|
||||
import TagsInputItem from './TagsInputItem.vue'
|
||||
import TagsInputItemDelete from './TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from './TagsInputItemText.vue'
|
||||
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: ComponentExposed<C>
|
||||
}
|
||||
|
||||
const meta: GenericMeta<typeof TagsInput> = {
|
||||
title: 'Components/TagsInput',
|
||||
component: TagsInput,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'object',
|
||||
description: 'Array of tag values'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description:
|
||||
'When true, completely disables the component. When false (default), shows read-only state with edit icon until clicked.'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(args.modelValue || ['tag1', 'tag2'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
|
||||
</TagsInput>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Tags: {{ tags.join(', ') }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: ['Vue', 'TypeScript'],
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref<string[]>([])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Start typing to add tags..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ManyTags: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref([
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Vue',
|
||||
'React',
|
||||
'Svelte',
|
||||
'Node.js',
|
||||
'Python',
|
||||
'Rust'
|
||||
])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true
|
||||
},
|
||||
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(['Read', 'Only', 'Tags'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Cannot add tags..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(['Full', 'Width'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-full" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { h, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TagsInput from './TagsInput.vue'
|
||||
import TagsInputInput from './TagsInputInput.vue'
|
||||
import TagsInputItem from './TagsInputItem.vue'
|
||||
import TagsInputItemDelete from './TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from './TagsInputItemText.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { removeTag: 'Remove tag' } } }
|
||||
})
|
||||
|
||||
describe('TagsInput', () => {
|
||||
function mountTagsInput(props = {}, slots = {}) {
|
||||
return mount(TagsInput, {
|
||||
props: {
|
||||
modelValue: [],
|
||||
...props
|
||||
},
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })
|
||||
|
||||
expect(wrapper.text()).toContain('Slot Content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TagsInput with child components', () => {
|
||||
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
|
||||
return mount(TagsInput, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
modelValue: tags
|
||||
},
|
||||
slots: {
|
||||
default: () => [
|
||||
...tags.map((tag) =>
|
||||
h(TagsInputItem, { key: tag, value: tag }, () => [
|
||||
h(TagsInputItemText),
|
||||
h(TagsInputItemDelete)
|
||||
])
|
||||
),
|
||||
h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders tags structure and content', () => {
|
||||
const tags = ['tag1', 'tag2']
|
||||
const wrapper = mountFullTagsInput(tags)
|
||||
|
||||
const items = wrapper.findAllComponents(TagsInputItem)
|
||||
const textElements = wrapper.findAllComponents(TagsInputItemText)
|
||||
const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
|
||||
|
||||
expect(items).toHaveLength(tags.length)
|
||||
expect(textElements).toHaveLength(tags.length)
|
||||
expect(deleteButtons).toHaveLength(tags.length)
|
||||
|
||||
textElements.forEach((el, i) => {
|
||||
expect(el.text()).toBe(tags[i])
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates model value when adding a tag', async () => {
|
||||
let currentTags = ['existing']
|
||||
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: currentTags,
|
||||
'onUpdate:modelValue': (payload) => {
|
||||
currentTags = payload
|
||||
}
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
await input.setValue('newTag')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await nextTick()
|
||||
|
||||
expect(currentTags).toContain('newTag')
|
||||
})
|
||||
|
||||
it('does not enter edit mode when disabled', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: ['tag1'],
|
||||
disabled: true
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('exits edit mode when clicking outside', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: ['tag1']
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
|
||||
document.body.click()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows placeholder when modelValue is empty', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: []
|
||||
},
|
||||
slots: {
|
||||
default: ({ isEmpty }: { isEmpty: boolean }) =>
|
||||
h(TagsInputInput, { placeholder: 'Add tag...', isEmpty })
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('placeholder')).toBe('Add tag...')
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup lang="ts" generic="T extends AcceptableInputValue = string">
|
||||
import { onClickOutside, useCurrentElement } from '@vueuse/core'
|
||||
import type {
|
||||
AcceptableInputValue,
|
||||
TagsInputRootEmits,
|
||||
TagsInputRootProps
|
||||
} from 'reka-ui'
|
||||
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, nextTick, provide, ref } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
|
||||
import type { FocusCallback } from './tagsInputContext'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<TagsInputRootEmits<T>>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const rootEl = useCurrentElement<HTMLElement>()
|
||||
const focusInput = ref<FocusCallback>()
|
||||
|
||||
provide(tagsInputFocusKey, (callback: FocusCallback) => {
|
||||
focusInput.value = callback
|
||||
})
|
||||
provide(tagsInputIsEditingKey, isEditing)
|
||||
|
||||
const internalDisabled = computed(() => disabled || !isEditing.value)
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
...restProps,
|
||||
disabled: internalDisabled.value
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
async function enableEditing() {
|
||||
if (!disabled && !isEditing.value) {
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
focusInput.value?.()
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(rootEl, () => {
|
||||
isEditing.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputRoot
|
||||
v-slot="{ modelValue }"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
|
||||
!internalDisabled &&
|
||||
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
|
||||
!disabled && !isEditing && 'cursor-pointer',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="enableEditing"
|
||||
>
|
||||
<slot :is-empty="modelValue.length === 0" />
|
||||
<i
|
||||
v-if="!disabled && !isEditing"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
|
||||
/>
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputInputProps } from 'reka-ui'
|
||||
import { TagsInputInput, useForwardExpose, useForwardProps } from 'reka-ui'
|
||||
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
|
||||
|
||||
const {
|
||||
isEmpty = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
TagsInputInputProps & { class?: HTMLAttributes['class']; isEmpty?: boolean }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
const isEditing = inject(tagsInputIsEditingKey, ref(true))
|
||||
const showInput = computed(() => isEditing.value || isEmpty)
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose()
|
||||
const registerFocus = inject(tagsInputFocusKey, undefined)
|
||||
|
||||
onMounted(() => {
|
||||
registerFocus?.(() => currentElement.value?.focus())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
registerFocus?.(undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputInput
|
||||
v-if="showInput"
|
||||
:ref="forwardRef"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-6 flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground focus:outline-none appearance-none border-none',
|
||||
!isEditing && 'pointer-events-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemProps } from 'reka-ui'
|
||||
import { TagsInputItem, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-6 items-center gap-1 rounded-sm bg-modal-card-tag-background py-1 pl-2 pr-1 text-modal-card-tag-foreground backdrop-blur-sm ring-offset-base-background data-[state=active]:ring-2 data-[state=active]:ring-base-foreground data-[state=active]:ring-offset-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemDeleteProps } from 'reka-ui'
|
||||
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemDelete
|
||||
v-bind="forwardedProps"
|
||||
:as="Button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.removeTag')"
|
||||
:class="
|
||||
cn(
|
||||
'opacity-60 hover:bg-transparent hover:opacity-100 transition-[opacity,width] duration-150 w-4 data-[disabled]:w-0 data-[disabled]:opacity-0 data-[disabled]:pointer-events-none overflow-hidden',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</slot>
|
||||
</TagsInputItemDelete>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemTextProps } from 'reka-ui'
|
||||
import { TagsInputItemText, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemTextProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemText
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('bg-transparent text-xs', className)"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
export type FocusCallback = (() => void) | undefined
|
||||
|
||||
export const tagsInputFocusKey: InjectionKey<
|
||||
(callback: FocusCallback) => void
|
||||
> = Symbol('tagsInputFocus')
|
||||
|
||||
export const tagsInputIsEditingKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('tagsInputIsEditing')
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
||||
<Button
|
||||
v-show="!isRightPanelOpen && hasRightPanel"
|
||||
size="lg"
|
||||
size="icon"
|
||||
:class="
|
||||
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
||||
@@ -10,7 +10,7 @@
|
||||
"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right]" />
|
||||
<i class="icon-[lucide--panel-right] text-sm" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -64,7 +64,7 @@
|
||||
>
|
||||
<Button
|
||||
v-if="isRightPanelOpen && hasRightPanel"
|
||||
size="lg"
|
||||
size="icon"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right-close]" />
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
<aside
|
||||
v-if="hasRightPanel && isRightPanelOpen"
|
||||
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
|
||||
class="w-1/4 min-w-40 max-w-80"
|
||||
>
|
||||
<slot name="rightPanel"></slot>
|
||||
</aside>
|
||||
@@ -111,10 +111,6 @@ const { contentTitle } = defineProps<{
|
||||
contentTitle: string
|
||||
}>()
|
||||
|
||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||
default: false
|
||||
})
|
||||
|
||||
const BREAKPOINTS = { md: 880 }
|
||||
const PANEL_SIZES = {
|
||||
width: 'w-1/3',
|
||||
@@ -129,6 +125,7 @@ const breakpoints = useBreakpoints(BREAKPOINTS)
|
||||
const notMobile = breakpoints.greater('md')
|
||||
|
||||
const isLeftPanelOpen = ref<boolean>(true)
|
||||
const isRightPanelOpen = ref<boolean>(false)
|
||||
const mobileMenuOpen = ref<boolean>(false)
|
||||
|
||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||
|
||||
@@ -41,8 +41,8 @@ export const useCurrentUser = () => {
|
||||
whenever(() => authStore.tokenRefreshTrigger, callback)
|
||||
|
||||
const onUserLogout = (callback: () => void) => {
|
||||
watch(resolvedUserInfo, (user, prevUser) => {
|
||||
if (prevUser && !user) callback()
|
||||
watch(resolvedUserInfo, (user) => {
|
||||
if (!user) callback()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ export const useFirebaseAuthActions = () => {
|
||||
}, reportError)
|
||||
|
||||
const accessBillingPortal = wrapWithErrorHandlingAsync<
|
||||
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
|
||||
[targetTier?: BillingPortalTargetTier],
|
||||
void
|
||||
>(async (targetTier, openInNewTab = true) => {
|
||||
>(async (targetTier) => {
|
||||
const response = await authStore.accessBillingPortal(targetTier)
|
||||
if (!response.billing_portal_url) {
|
||||
throw new Error(
|
||||
@@ -115,11 +115,7 @@ export const useFirebaseAuthActions = () => {
|
||||
})
|
||||
)
|
||||
}
|
||||
if (openInNewTab) {
|
||||
window.open(response.billing_portal_url, '_blank')
|
||||
} else {
|
||||
globalThis.location.href = response.billing_portal_url
|
||||
}
|
||||
window.open(response.billing_portal_url, '_blank')
|
||||
}, reportError)
|
||||
|
||||
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
41
src/composables/element/useResponsiveCollapse.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
type BreakpointKey = keyof typeof breakpointsTailwind
|
||||
|
||||
/**
|
||||
* Composable for element with responsive collapsed state
|
||||
* @param breakpointThreshold - Breakpoint at which the element should become collapsible
|
||||
*/
|
||||
export const useResponsiveCollapse = (
|
||||
breakpointThreshold: BreakpointKey = 'lg'
|
||||
) => {
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
const isSmallScreen = breakpoints.smallerOrEqual(breakpointThreshold)
|
||||
const isOpen = ref(!isSmallScreen.value)
|
||||
|
||||
/**
|
||||
* Handles screen size changes to automatically open/close the element
|
||||
* when crossing the breakpoint threshold
|
||||
*/
|
||||
const onIsSmallScreenChange = () => {
|
||||
if (isSmallScreen.value && isOpen.value) {
|
||||
isOpen.value = false
|
||||
} else if (!isSmallScreen.value && !isOpen.value) {
|
||||
isOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(isSmallScreen, onIsSmallScreenChange)
|
||||
|
||||
return {
|
||||
breakpoints,
|
||||
isOpen,
|
||||
isSmallScreen,
|
||||
|
||||
open: () => (isOpen.value = true),
|
||||
close: () => (isOpen.value = false),
|
||||
toggle: () => (isOpen.value = !isOpen.value)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
@@ -17,7 +16,7 @@ export function useImageMenuOptions() {
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
}
|
||||
|
||||
const openImage = (node: LGraphNode) => {
|
||||
const openImage = (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
@@ -26,7 +25,7 @@ export function useImageMenuOptions() {
|
||||
window.open(url.toString(), '_blank')
|
||||
}
|
||||
|
||||
const copyImage = async (node: LGraphNode) => {
|
||||
const copyImage = async (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
@@ -63,7 +62,7 @@ export function useImageMenuOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (node: LGraphNode) => {
|
||||
const saveImage = (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
@@ -77,7 +76,7 @@ export function useImageMenuOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
const getImageMenuOptions = (node: LGraphNode): MenuOption[] => {
|
||||
const getImageMenuOptions = (node: any): MenuOption[] => {
|
||||
if (!node?.imgs?.length) return []
|
||||
|
||||
return [
|
||||
|
||||
@@ -404,9 +404,8 @@ export function useBrushDrawing(initialSettings?: {
|
||||
device = root.device
|
||||
console.warn('✅ TypeGPU initialized! Root:', root)
|
||||
console.warn('Device info:', root.device.limits)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.warn('Failed to initialize TypeGPU:', message)
|
||||
} catch (error: any) {
|
||||
console.warn('Failed to initialize TypeGPU:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,6 @@ describe('useCanvasHistory', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
mockRefs.maskCanvas = {
|
||||
// oxlint-disable-next-line no-misused-spread
|
||||
...mockRefs.maskCanvas,
|
||||
width: 0,
|
||||
height: 0
|
||||
|
||||
@@ -2,17 +2,6 @@ import { formatCreditsFromUsd } from '@/base/credits/comfyCredits'
|
||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Meshy credit pricing constant.
|
||||
* 1 Meshy credit = $0.04 USD
|
||||
* Change this value to update all Meshy node prices.
|
||||
*/
|
||||
const MESHY_CREDIT_PRICE_USD = 0.04
|
||||
|
||||
/** Convert Meshy credits to USD */
|
||||
const meshyCreditsToUsd = (credits: number): number =>
|
||||
credits * MESHY_CREDIT_PRICE_USD
|
||||
|
||||
const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
@@ -220,24 +209,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const generateAudioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'generate_audio'
|
||||
) as IComboWidget | undefined
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based'
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const generateAudio =
|
||||
generateAudioWidget &&
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
const priceByModel: Record<string, Record<string, [number, number]>> = {
|
||||
'seedance-1-5-pro': {
|
||||
'480p': [0.12, 0.12],
|
||||
'720p': [0.26, 0.26],
|
||||
'1080p': [0.58, 0.59]
|
||||
},
|
||||
'seedance-1-0-pro': {
|
||||
'480p': [0.23, 0.24],
|
||||
'720p': [0.51, 0.56],
|
||||
@@ -255,15 +233,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const modelKey = model.includes('seedance-1-5-pro')
|
||||
? 'seedance-1-5-pro'
|
||||
: model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
const modelKey = model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
|
||||
const resKey = resolution.includes('1080')
|
||||
? '1080p'
|
||||
@@ -279,10 +255,8 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
|
||||
const [min10s, max10s] = baseRange
|
||||
const scale = seconds / 10
|
||||
const audioMultiplier =
|
||||
modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1
|
||||
const minCost = min10s * scale * audioMultiplier
|
||||
const maxCost = max10s * scale * audioMultiplier
|
||||
const minCost = min10s * scale
|
||||
const maxCost = max10s * scale
|
||||
|
||||
if (minCost === maxCost) return formatCreditsLabel(minCost)
|
||||
return formatCreditsRangeLabel(minCost, maxCost)
|
||||
@@ -551,54 +525,6 @@ const calculateTripo3DGenerationPrice = (
|
||||
return formatCreditsLabel(dollars)
|
||||
}
|
||||
|
||||
/**
|
||||
* Meshy Image to 3D pricing calculator.
|
||||
* Pricing based on should_texture widget:
|
||||
* - Without texture: 20 credits
|
||||
* - With texture: 30 credits
|
||||
*/
|
||||
const calculateMeshyImageToModelPrice = (node: LGraphNode): string => {
|
||||
const shouldTextureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'should_texture'
|
||||
) as IComboWidget
|
||||
|
||||
if (!shouldTextureWidget) {
|
||||
return formatCreditsRangeLabel(
|
||||
meshyCreditsToUsd(20),
|
||||
meshyCreditsToUsd(30),
|
||||
{ note: '(varies with texture)' }
|
||||
)
|
||||
}
|
||||
|
||||
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
|
||||
const credits = shouldTexture === 'true' ? 30 : 20
|
||||
return formatCreditsLabel(meshyCreditsToUsd(credits))
|
||||
}
|
||||
|
||||
/**
|
||||
* Meshy Multi-Image to 3D pricing calculator.
|
||||
* Pricing based on should_texture widget:
|
||||
* - Without texture: 5 credits
|
||||
* - With texture: 15 credits
|
||||
*/
|
||||
const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => {
|
||||
const shouldTextureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'should_texture'
|
||||
) as IComboWidget
|
||||
|
||||
if (!shouldTextureWidget) {
|
||||
return formatCreditsRangeLabel(
|
||||
meshyCreditsToUsd(5),
|
||||
meshyCreditsToUsd(15),
|
||||
{ note: '(varies with texture)' }
|
||||
)
|
||||
}
|
||||
|
||||
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
|
||||
const credits = shouldTexture === 'true' ? 15 : 5
|
||||
return formatCreditsLabel(meshyCreditsToUsd(credits))
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pricing data for API nodes, now supporting both strings and functions
|
||||
*/
|
||||
@@ -1886,27 +1812,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
TripoRefineNode: {
|
||||
displayPrice: formatCreditsLabel(0.3)
|
||||
},
|
||||
MeshyTextToModelNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(20))
|
||||
},
|
||||
MeshyRefineNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
|
||||
},
|
||||
MeshyImageToModelNode: {
|
||||
displayPrice: calculateMeshyImageToModelPrice
|
||||
},
|
||||
MeshyMultiImageToModelNode: {
|
||||
displayPrice: calculateMeshyMultiImageToModelPrice
|
||||
},
|
||||
MeshyRigModelNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(5))
|
||||
},
|
||||
MeshyAnimateModelNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(3))
|
||||
},
|
||||
MeshyTextureNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
|
||||
},
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
@@ -2622,9 +2527,6 @@ export const useNodePricing = () => {
|
||||
'animate_in_place'
|
||||
],
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Meshy nodes
|
||||
MeshyImageToModelNode: ['should_texture'],
|
||||
MeshyMultiImageToModelNode: ['should_texture'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
GeminiImage2Node: ['resolution'],
|
||||
@@ -2638,24 +2540,9 @@ export const useNodePricing = () => {
|
||||
'sequential_image_generation',
|
||||
'max_images'
|
||||
],
|
||||
ByteDanceTextToVideoNode: [
|
||||
'model',
|
||||
'duration',
|
||||
'resolution',
|
||||
'generate_audio'
|
||||
],
|
||||
ByteDanceImageToVideoNode: [
|
||||
'model',
|
||||
'duration',
|
||||
'resolution',
|
||||
'generate_audio'
|
||||
],
|
||||
ByteDanceFirstLastFrameNode: [
|
||||
'model',
|
||||
'duration',
|
||||
'resolution',
|
||||
'generate_audio'
|
||||
],
|
||||
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution'],
|
||||
|
||||
@@ -57,8 +57,8 @@ export const useCompletionSummary = () => {
|
||||
}
|
||||
if (prev && !active) {
|
||||
const start = lastActiveStartTs.value ?? 0
|
||||
const finished = queueStore.historyTasks.filter((t) => {
|
||||
const ts = t.executionEndTimestamp
|
||||
const finished = queueStore.historyTasks.filter((t: any) => {
|
||||
const ts: number | undefined = t.executionEndTimestamp
|
||||
return typeof ts === 'number' && ts >= start
|
||||
})
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export type JobListItem = {
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
taskRef?: TaskItemImpl
|
||||
taskRef?: any
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
runningNodeName?: string
|
||||
|
||||
@@ -60,10 +60,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
|
||||
const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
const fetchApiMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
interrupt: (...args: any[]) => interruptMock(...args),
|
||||
deleteItem: (...args: any[]) => deleteItemMock(...args)
|
||||
deleteItem: (...args: any[]) => deleteItemMock(...args),
|
||||
fetchApi: (...args: any[]) => fetchApiMock(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -123,22 +125,13 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
}))
|
||||
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type MockTaskRef = Record<string, unknown>
|
||||
|
||||
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
|
||||
taskRef?: MockTaskRef
|
||||
}
|
||||
|
||||
const createJobItem = (
|
||||
overrides: Partial<TestJobListItem> = {}
|
||||
): JobListItem => ({
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: overrides.id ?? 'job-1',
|
||||
title: overrides.title ?? 'Test job',
|
||||
meta: overrides.meta ?? 'meta',
|
||||
state: overrides.state ?? 'completed',
|
||||
taskRef: overrides.taskRef as TaskItemImpl | undefined,
|
||||
taskRef: overrides.taskRef,
|
||||
iconName: overrides.iconName,
|
||||
iconImageUrl: overrides.iconImageUrl,
|
||||
showClear: overrides.showClear,
|
||||
|
||||
@@ -36,7 +36,7 @@ interface CivitaiModelVersionResponse {
|
||||
model: CivitaiModel
|
||||
modelId: number
|
||||
files: CivitaiModelFile[]
|
||||
[key: string]: unknown
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,10 +123,7 @@ export const useContextMenuTranslation = () => {
|
||||
}
|
||||
|
||||
// for capture translation text of input and widget
|
||||
const extraInfo = (options.extra ||
|
||||
options.parentMenu?.options?.extra) as
|
||||
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
|
||||
| undefined
|
||||
const extraInfo: any = options.extra || options.parentMenu?.options?.extra
|
||||
// widgets and inputs
|
||||
const matchInput = value.content?.match(reInput)
|
||||
if (matchInput) {
|
||||
|
||||
@@ -1234,7 +1234,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
{
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Toggle Simple Mode',
|
||||
label: 'toggle linear mode',
|
||||
function: () => {
|
||||
const newMode = !canvasStore.linearMode
|
||||
app.rootGraph.extra.linearMode = newMode
|
||||
|
||||
@@ -17,8 +17,7 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +92,6 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get teamWorkspacesEnabled() {
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
CameraType,
|
||||
EventCallback,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
@@ -565,7 +564,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const handleEvents = (action: 'add' | 'remove') => {
|
||||
Object.entries(eventConfig).forEach(([event, handler]) => {
|
||||
const method = `${action}EventListener` as const
|
||||
load3d?.[method](event, handler as EventCallback)
|
||||
load3d?.[method](event, handler)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,9 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
CameraType,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
@@ -275,18 +271,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
const sourceCameraState = source.getCameraState()
|
||||
|
||||
const sceneConfig = node.properties['Scene Config'] as
|
||||
| SceneConfig
|
||||
| undefined
|
||||
const modelConfig = node.properties['Model Config'] as
|
||||
| ModelConfig
|
||||
| undefined
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const lightConfig = node.properties['Light Config'] as
|
||||
| LightConfig
|
||||
| undefined
|
||||
const sceneConfig = node.properties['Scene Config'] as any
|
||||
const modelConfig = node.properties['Model Config'] as any
|
||||
const cameraConfig = node.properties['Camera Config'] as any
|
||||
const lightConfig = node.properties['Light Config'] as any
|
||||
|
||||
isPreview.value = node.type === 'Preview3D'
|
||||
|
||||
@@ -450,9 +438,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
materialMode: initialState.value.materialMode
|
||||
}
|
||||
|
||||
const currentCameraConfig = nodeValue.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const currentCameraConfig = nodeValue.properties['Camera Config'] as any
|
||||
nodeValue.properties['Camera Config'] = {
|
||||
...currentCameraConfig,
|
||||
state: initialState.value.cameraState
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
function createMockNode(
|
||||
overrides: Partial<ComfyNodeDefImpl>
|
||||
): ComfyNodeDefImpl {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: 'A test node',
|
||||
category: 'test',
|
||||
python_module: 'comfy.test_node',
|
||||
inputs: {},
|
||||
outputs: [],
|
||||
deprecated: false,
|
||||
experimental: false,
|
||||
output_node: false,
|
||||
api_node: false,
|
||||
...overrides
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: vi.fn((url) => url)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: ref('en')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/types/nodeSource', () => ({
|
||||
NodeSourceType: {
|
||||
Core: 'core',
|
||||
CustomNodes: 'custom_nodes'
|
||||
},
|
||||
getNodeSource: vi.fn((pythonModule) => {
|
||||
if (pythonModule?.startsWith('custom_nodes.')) {
|
||||
return { type: 'custom_nodes' }
|
||||
}
|
||||
return { type: 'core' }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodeHelpContent', () => {
|
||||
const mockCoreNode = createMockNode({
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: 'A test node',
|
||||
python_module: 'comfy.test_node'
|
||||
})
|
||||
|
||||
const mockCustomNode = createMockNode({
|
||||
name: 'CustomNode',
|
||||
display_name: 'Custom Node',
|
||||
description: 'A custom node',
|
||||
python_module: 'custom_nodes.test_module.custom@1.0.0'
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should generate correct baseUrl for core nodes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test'
|
||||
})
|
||||
|
||||
const { baseUrl } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`)
|
||||
})
|
||||
|
||||
it('should generate correct baseUrl for custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test'
|
||||
})
|
||||
|
||||
const { baseUrl } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe('/extensions/test_module/docs/')
|
||||
})
|
||||
|
||||
it('should render markdown content correctly', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test Help\nThis is test help content'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('This is test help content')
|
||||
})
|
||||
|
||||
it('should handle fetch errors and fall back to description', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found'
|
||||
})
|
||||
|
||||
const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(error.value).toBe('Not Found')
|
||||
expect(renderedHelpHtml.value).toContain(mockCoreNode.description)
|
||||
})
|
||||
|
||||
it('should include alt attribute for images', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => ''
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('alt="image"')
|
||||
})
|
||||
|
||||
it('should prefix relative video src in custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="/extensions/test_module/docs/video.mp4"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative video src for core nodes with node-specific base URL', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video.mp4"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle loading state', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
const { isLoading } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should try fallback URL for custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Fallback content'
|
||||
})
|
||||
|
||||
useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/extensions/test_module/docs/CustomNode/en.md'
|
||||
)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/extensions/test_module/docs/CustomNode.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative source src in custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<video><source src="video.mp4" type="video/mp4" /></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="/extensions/test_module/docs/video.mp4"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative source src for core nodes with node-specific base URL', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<video><source src="video.webm" type="video/webm" /></video>'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video.webm"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative img src in raw HTML for custom nodes', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="/extensions/test_module/docs/image.png"'
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain('alt="Test image"')
|
||||
})
|
||||
|
||||
it('should prefix relative img src in raw HTML for core nodes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image.png"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain('alt="Test image"')
|
||||
})
|
||||
|
||||
it('should not prefix absolute img src in raw HTML', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"')
|
||||
expect(renderedHelpHtml.value).toContain('alt="Absolute"')
|
||||
})
|
||||
|
||||
it('should not prefix external img src in raw HTML', async () => {
|
||||
const nodeRef = ref(mockCustomNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<img src="https://example.com/image.png" alt="External">'
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
'src="https://example.com/image.png"'
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain('alt="External"')
|
||||
})
|
||||
|
||||
it('should handle various quote styles in media src attributes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => `# Media Test
|
||||
|
||||
Testing quote styles in properly formed HTML:
|
||||
|
||||
<video src="video1.mp4" controls></video>
|
||||
<video src='video2.mp4' controls></video>
|
||||
<img src="image1.png" alt="Double quotes">
|
||||
<img src='image2.png' alt='Single quotes'>
|
||||
|
||||
<video controls>
|
||||
<source src="video3.mp4" type="video/mp4">
|
||||
<source src='video3.webm' type='video/webm'>
|
||||
</video>
|
||||
|
||||
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
|
||||
})
|
||||
|
||||
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
// All media src attributes should be prefixed correctly
|
||||
// Note: marked normalizes quotes to double quotes in output
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video1.mp4"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video2.mp4"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image1.png"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image2.png"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video3.mp4"`
|
||||
)
|
||||
expect(renderedHelpHtml.value).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video3.webm"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore stale requests when node changes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
let resolveFirst: (value: unknown) => void
|
||||
const firstRequest = new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
|
||||
mockFetch
|
||||
.mockImplementationOnce(() => firstRequest)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Second node content'
|
||||
})
|
||||
|
||||
const { helpContent } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
// Change node before first request completes
|
||||
nodeRef.value = mockCustomNode
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
// Now resolve the first (stale) request
|
||||
resolveFirst!({
|
||||
ok: true,
|
||||
text: async () => '# First node content'
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Should have second node's content, not first
|
||||
expect(helpContent.value).toBe('# Second node content')
|
||||
})
|
||||
})
|
||||