Compare commits

..

6 Commits

Author SHA1 Message Date
bymyself
c8e1f494fa Fix Rectangle/Rect type compatibility and unify type system
This commit addresses PR review comments by fixing the fundamental
type incompatibility between Rectangle (Array<number>) and strict
tuple types (Rect = [x, y, width, height]).

Key changes:
- Updated Rectangle class methods to accept both Rect and Rectangle unions
- Fixed all measure functions (containsRect, overlapBounding, etc.) to accept Rectangle
- Updated boundingRect interfaces to consistently use Rectangle instead of Rect
- Fixed all tests and Vue components to use Rectangle instead of array literals
- Resolved Point type conflicts between litegraph and pathRenderer modules
- Removed ReadOnlyPoint types and unified with Point arrays

The type system is now consistent: boundingRect properties return Rectangle
objects with full functionality, while Rect remains as a simple tuple type
for data interchange.
2025-09-19 22:25:01 -07:00
bymyself
c82c3c24f7 [refactor] convert Rectangle from Float64Array to Array inheritance - addresses review feedback
Replace Float64Array inheritance with Array<number> to remove legacy typed array usage.
Maintains compatibility with array indexing (rect[0], rect[1], etc.) and property access
(rect.x, rect.y, etc.) while adding .set() and .subarray() methods for compatibility.

Co-authored-by: webfiltered <webfiltered@users.noreply.github.com>
2025-09-19 20:33:54 -07:00
Christian Byrne
fffa81c9b5 Update src/lib/litegraph/src/LGraphCanvas.ts
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-19 20:33:54 -07:00
bymyself
db35e0b7d2 Update test snapshots to reflect Float32Array removal
- Updated snapshots to show regular arrays instead of Float32Array
- Regenerated snapshots for LGraph, ConfigureGraph tests
- All actively running tests now have correct array expectations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 20:33:53 -07:00
bymyself
0c6eeb0632 [perf] Fix Float32Array test assertions and link adapter
Fix the remaining Float32Array usage that was causing test failures:
- Update test assertions to expect regular arrays instead of Float32Array
- Convert link adapter Float32Array creation to regular arrays

Resolves: AssertionError: expected [ 50, 60 ] to deeply equal Float32Array[ 50, 60 ]
2025-09-19 16:38:09 -07:00
bymyself
fca95ad07e [perf] Remove legacy Float32Array usage from LiteGraph
Removes Float32Array usage throughout LiteGraph codebase, eliminating
type conversion overhead for Canvas 2D rendering operations.

## Changes Made

### Core Data Structures
- **LGraphNode**: Convert position/size arrays from Float32Array to regular arrays
- **LLink**: Convert coordinate storage from Float32Array to regular arrays
- **Reroute**: Replace malloc pattern with standard properties
- **LGraphGroup**: Convert boundary arrays from Float32Array to regular arrays

### Rendering Optimizations
- **LGraphCanvas**: Convert static rendering buffers to regular arrays
- **Drawing Functions**: Replace .set() method calls with direct array assignment
- **Measurement**: Update bounds calculation to use regular arrays

### Type System Updates
- **Interfaces**: Update Point/Size/Rect types to support both regular arrays and typed arrays
- **API Compatibility**: Maintain all existing property names and method signatures

## Performance Benefits

- Eliminates Float32Array conversion overhead in Canvas 2D operations
- Reduces memory allocation complexity (removed malloc patterns)
- Improves TypeScript integration with native array support
- Maintains full API compatibility with zero breaking changes

## Background

Float32Array usage was originally designed for WebGL integration, but the
current Canvas 2D rendering pipeline doesn't use WebGL, making the typed
arrays an unnecessary performance overhead. Every Canvas 2D operation that
reads coordinates from Float32Array incurs type conversion costs.
2025-09-19 16:38:09 -07:00
276 changed files with 4589 additions and 6872 deletions

View File

@@ -32,10 +32,11 @@ jobs:
with:
repository: Comfy-Org/ComfyUI_frontend
path: ComfyUI_frontend
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI_devtools
path: ComfyUI/custom_nodes/ComfyUI_devtools
- name: Checkout custom node repository
uses: actions/checkout@v4
with:

View File

@@ -27,10 +27,12 @@ jobs:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- name: Install pnpm
uses: pnpm/action-setup@v4

3
.gitignore vendored
View File

@@ -22,8 +22,6 @@ dist-ssr
*.local
# Claude configuration
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
CLAUDE.local.md
# Editor directories and files
@@ -46,7 +44,6 @@ components.d.ts
tests-ui/data/*
tests-ui/ComfyUI_examples
tests-ui/workflows/examples
coverage/
# Browser tests
/test-results/

View File

@@ -1,61 +1,17 @@
# Desktop/Electron
/src/types/desktop/ @webfiltered
/src/constants/desktopDialogs.ts @webfiltered
/src/constants/desktopMaintenanceTasks.ts @webfiltered
/src/stores/electronDownloadStore.ts @webfiltered
/src/extensions/core/electronAdapter.ts @webfiltered
/src/views/DesktopDialogView.vue @webfiltered
/src/components/install/ @webfiltered
/src/components/maintenance/ @webfiltered
/vite.electron.config.mts @webfiltered
# Admins
* @Comfy-Org/comfy_frontend_devs
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Maintainers
*.md @Comfy-Org/comfy_maintainer
/tests-ui/ @Comfy-Org/comfy_maintainer
/browser_tests/ @Comfy-Org/comfy_maintainer
/.env_example @Comfy-Org/comfy_maintainer
# Topbar
/src/components/topbar/ @pythongosssss
# Translations (AIGODLIKE team + shinshin86)
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
# Load 3D extension
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
# Legacy UI
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88
# Assets
/src/platform/assets/ @arjansingh
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/components/load3d/ @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
# Mask Editor extension
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs

View File

@@ -16,14 +16,9 @@ Without this flag, parallel tests will conflict and fail randomly.
### ComfyUI devtools
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
For local development, copy the devtools files to your ComfyUI installation:
```bash
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
```
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
@@ -56,6 +51,14 @@ TEST_COMFYUI_DIR=/path/to/your/ComfyUI
### Common Setup Issues
**Most tests require the new menu system** - Add to your test:
```typescript
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
```
### Release API Mocking
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.

View File

@@ -1643,7 +1643,7 @@ export const comfyPageFixture = base.extend<{
try {
await comfyPage.setupSettings({
'Comfy.UseNewMenu': 'Top',
'Comfy.UseNewMenu': 'Disabled',
// Hide canvas menu/info/selection toolbox by default.
'Comfy.Graph.CanvasInfo': false,
'Comfy.Graph.CanvasMenu': false,

View File

@@ -22,13 +22,6 @@ export class VueNodeHelpers {
)
}
/**
* Get locator for a Vue node by the node's title (displayed name in the header)
*/
getNodeByTitle(title: string): Locator {
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
}
/**
* Get total count of Vue nodes in the DOM
*/

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Background Image Upload', () => {
test.beforeEach(async ({ comfyPage }) => {
// Reset the background image setting before each test

View File

@@ -15,10 +15,6 @@ async function afterChange(comfyPage: ComfyPage) {
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Change Tracker', () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
interface ChatHistoryEntry {
prompt: string
response: string

View File

@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
import type { Palette } from '../../src/schemas/colorPaletteSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const customColorPalettes: Record<string, Palette> = {
obsidian: {
version: 102,

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', () => {
test('Should execute command', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', () => {

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()

View File

@@ -4,10 +4,6 @@ import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
comfyPage

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('DOM Widget', () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/collapsed_multiline')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Execution', () => {
test('Report error on unconnected slot', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Feature Flags', () => {
test('Client and server exchange feature flags on connection', async ({
comfyPage

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph', () => {
// Should be able to fix link input slot index after swap the input order
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph Canvas Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
// Set link render mode to spline to make sure it's not affected by other tests'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -4,10 +4,6 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Group Node', () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'

View File

@@ -9,10 +9,6 @@ import {
} from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('groups/mixed_graph_items')

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
comfyPage

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {
document.addEventListener('litegraph:canvas', (e) => resolve(e), {

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load Workflow in Media', () => {
const fileNames = [
'workflow.webp',

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('LOD Threshold', () => {
test('Should switch to low quality mode at correct zoom threshold', async ({
comfyPage

View File

@@ -4,10 +4,6 @@ import type { ComfyApp } from '../../src/scripts/app'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node Badge', () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
test.describe('Optional input', () => {

View File

@@ -3,10 +3,6 @@ import {
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node search box', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Note Node', () => {
test('Can load node nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/note_nodes')

View File

@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive/primitive_node')

View File

@@ -40,7 +40,6 @@ test.describe('Reroute Node', () => {
test.describe('LiteGraph Native Reroute Node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Canvas Right Click Menu', () => {
test('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()

View File

@@ -4,9 +4,6 @@ import { comfyPageFixture } from '../fixtures/ComfyPage'
const test = comfyPageFixture
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Selection Toolbox - More Options Submenus', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Settings Search functionality', () => {
test.beforeEach(async ({ comfyPage }) => {
// Register test settings to verify hidden/deprecated filtering

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Nodes - Delete Key Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
// Enable Vue nodes rendering

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,48 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
})
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
const vueNodesContainer = comfyPage.vueNodes.nodes
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
const buttonsInNodes = vueNodesContainer.getByRole('button')
await expect(textboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
await comfyPage.zoom(120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
await expect(textboxesInNodes.first()).toBeHidden()
await expect(buttonsInNodes.first()).toBeHidden()
await comfyPage.zoom(-120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-lod-inactive.png'
)
await expect(textboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,51 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Node Selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
const modifiers = [
{ key: 'Control', name: 'ctrl' },
{ key: 'Shift', name: 'shift' }
] as const
for (const { key: modifier, name } of modifiers) {
test(`should allow selecting multiple nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await comfyPage.page.getByText('KSampler').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
})
test(`should allow de-selecting nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Load Checkpoint').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
}
})

View File

@@ -1,45 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const BYPASS_HOTKEY = 'Control+b'
const BYPASS_CLASS = /before:bg-bypass\/60/
test.describe('Vue Node Bypass', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling bypass on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
})
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await expect(ksamplerNode).toHaveClass(BYPASS_CLASS)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS)
})
})

View File

@@ -1,32 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /border-error/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should display error state when node is missing (node from workflow is not installed)', async ({
comfyPage
}) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('missing/missing_nodes')
// Close missing nodes warning dialog
await comfyPage.page.getByRole('button', { name: 'Close' }).click()
await comfyPage.page.waitForSelector('.comfy-missing-nodes', {
state: 'hidden'
})
// Expect error state on missing unknown node
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'UNKNOWN NODE'
})
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
})

View File

@@ -1,45 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const MUTE_HOTKEY = 'Control+m'
const MUTE_CLASS = /opacity-50/
test.describe('Vue Node Mute', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling mute on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
})
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
})
})

View File

@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Combo text widget', () => {
test('Truncates text when resized', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
@@ -322,9 +318,6 @@ test.describe('Animated image widget', () => {
test.describe('Load audio widget', () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_audio_widget')
// Wait for the audio widget to be rendered in the DOM
await comfyPage.page.waitForSelector('.comfy-audio', { state: 'attached' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
})
})

View File

@@ -3,6 +3,7 @@
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
// "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true

View File

@@ -1,17 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
/* Build scripts configuration */
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts"
]
}

View File

@@ -33,13 +33,7 @@ export default defineConfig([
},
parserOptions: {
parser: tseslint.parser,
projectService: {
allowDefaultProject: [
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts'
]
},
projectService: true,
tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020,
sourceType: 'module',
@@ -83,25 +77,12 @@ export default defineConfig([
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
/* Toggle on to do additional until we can clean up existing violations.
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/no-useless-v-bind': 'error',
// */
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
<!-- Fullscreen mode on mobile browsers -->
<meta name="mobile-web-app-capable" content="yes">
<!-- Fullscreen mode on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.2",
"version": "1.28.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -18,18 +18,15 @@
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:all": "nx run test",
"test:browser": "npx nx e2e",
"test:unit": "nx run test tests-ui/tests",
"test:component": "nx run test src/components/",
"test:litegraph": "vitest run --config vitest.litegraph.config.ts",
"test:unit": "nx run test tests-ui/tests",
"preinstall": "npx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip --cache",
@@ -97,7 +94,6 @@
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
"vue-component-type-helpers": "^3.0.7",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7",
"zip-dir": "^2.0.0",
@@ -107,7 +103,6 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/tailwind-utils": "workspace:*",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",
@@ -131,6 +126,7 @@
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"es-toolkit": "^1.39.9",
@@ -148,6 +144,7 @@
"primevue": "^4.2.5",
"reka-ui": "^2.5.0",
"semver": "^7.7.2",
"tailwind-merge": "^3.3.1",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",

View File

@@ -1,31 +0,0 @@
# @comfyorg/tailwind-utils
Shared Tailwind CSS utility functions for the ComfyUI Frontend monorepo.
## Usage
The `cn` function combines `clsx` and `tailwind-merge` to handle conditional classes and resolve Tailwind conflicts.
```typescript
import { cn } from '@comfyorg/tailwind-utils'
// Use with conditional classes (object)
<div :class="cn('transition-opacity', { 'opacity-75': !isHovered })" />
// Use with conditional classes (ternary)
<button
:class="cn('px-4 py-2', isActive ? 'bg-blue-500' : 'bg-gray-500')"
/>
```
## Installation
This package is part of the ComfyUI Frontend monorepo and is automatically available to all workspace packages.
```json
{
"dependencies": {
"@comfyorg/tailwind-utils": "workspace:*"
}
}
```

View File

@@ -1,30 +0,0 @@
{
"name": "@comfyorg/tailwind-utils",
"version": "1.0.0",
"type": "module",
"description": "Shared Tailwind CSS utilities for ComfyUI Frontend",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}

View File

@@ -1,8 +0,0 @@
import clsx, { type ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

38
pnpm-lock.yaml generated
View File

@@ -17,9 +17,6 @@ importers:
'@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0
version: 0.4.73-0
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
@@ -89,6 +86,9 @@ importers:
chart.js:
specifier: ^4.5.0
version: 4.5.0
clsx:
specifier: ^2.1.1
version: 2.1.1
dompurify:
specifier: ^3.2.5
version: 3.2.5
@@ -140,6 +140,9 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.2
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
three:
specifier: ^0.170.0
version: 0.170.0
@@ -336,9 +339,6 @@ importers:
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.0.7
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
@@ -352,19 +352,6 @@ importers:
specifier: ^3.24.1
version: 3.24.1(zod@3.24.1)
packages/tailwind-utils:
dependencies:
clsx:
specifier: ^2.1.1
version: 2.1.1
tailwind-merge:
specifier: ^2.2.0
version: 2.6.0
devDependencies:
typescript:
specifier: ^5.4.5
version: 5.9.2
packages:
'@adobe/css-tools@4.4.3':
@@ -5898,8 +5885,8 @@ packages:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwindcss-primeui@0.6.1:
resolution: {integrity: sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==}
@@ -6333,9 +6320,6 @@ packages:
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.0.8:
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -8892,7 +8876,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.0.8
vue-component-type-helpers: 3.0.7
'@swc/helpers@0.5.17':
dependencies:
@@ -13088,7 +13072,7 @@ snapshots:
dependencies:
'@pkgr/core': 0.2.9
tailwind-merge@2.6.0: {}
tailwind-merge@3.3.1: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.1.12):
dependencies:
@@ -13558,8 +13542,6 @@ snapshots:
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.0.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -9,18 +9,9 @@ import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json'
interface WidgetInfo {
name?: string
label?: string
}
interface WidgetLabels {
[key: string]: Record<string, { name: string }>
}
test('collect-i18n-node-defs', async ({ comfyPage }) => {
// Mock view route
await comfyPage.page.route('**/view**', async (route) => {
comfyPage.page.route('**/view**', async (route) => {
await route.fulfill({
body: JSON.stringify({})
})
@@ -29,7 +20,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const nodeDefs: ComfyNodeDefImpl[] = (
Object.values(
await comfyPage.page.evaluate(async () => {
// @ts-expect-error - app is dynamically added to window
const api = window['app'].api as ComfyApi
return await api.getNodeDefs()
})
@@ -62,7 +52,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
)
async function extractWidgetLabels() {
const nodeLabels: WidgetLabels = {}
const nodeLabels = {}
for (const nodeDef of nodeDefs) {
const inputNames = Object.values(nodeDef.inputs).map(
@@ -75,15 +65,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const widgetsMappings = await comfyPage.page.evaluate(
(args) => {
const [nodeName, displayName, inputNames] = args
// @ts-expect-error - LiteGraph is dynamically added to window
const node = window['LiteGraph'].createNode(nodeName, displayName)
if (!node.widgets?.length) return {}
return Object.fromEntries(
node.widgets
.filter(
(w: WidgetInfo) => w?.name && !inputNames.includes(w.name)
)
.map((w: WidgetInfo) => [w.name, w.label])
.filter((w) => w?.name && !inputNames.includes(w.name))
.map((w) => [w.name, w.label])
)
},
[nodeDef.name, nodeDef.display_name, inputNames]

View File

@@ -72,7 +72,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
const relativePath = file.replace(srcLocaleDir, '')
const targetPath = join(tempBaseDir, relativePath)
ensureDir(dirname(targetPath))
writeFileSync(targetPath, readFileSync(file, 'utf8'))
writeFileSync(targetPath, readFileSync(file))
}
console.log('Captured current locale files to temp/base/')
}

View File

@@ -1,14 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
/* Script files configuration */
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts"
]
}

View File

@@ -16,18 +16,14 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { usePreserveWidgetScroll } from '@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useConflictDetection } from './composables/useConflictDetection'
import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
// Preserve native scrolling in Vue widgets
usePreserveWidgetScroll()
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}

View File

@@ -929,6 +929,48 @@ audio.comfy-audio.empty-audio-widget {
}
/* End of [Desktop] Electron window specific styles */
/* Vue Node LOD (Level of Detail) System */
/* These classes control rendering detail based on zoom level */
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
.lg-node--lod-minimal {
min-height: 32px;
transition: min-height 0.2s ease;
/* Performance optimizations */
text-shadow: none;
backdrop-filter: none;
}
.lg-node--lod-minimal .lg-node-body {
display: none !important;
}
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
.lg-node--lod-reduced {
transition: opacity 0.1s ease;
/* Performance optimizations */
text-shadow: none;
}
.lg-node--lod-reduced .lg-widget-label,
.lg-node--lod-reduced .lg-slot-label {
display: none;
}
.lg-node--lod-reduced .lg-slot {
opacity: 0.6;
font-size: 0.75rem;
}
.lg-node--lod-reduced .lg-widget {
margin: 2px 0;
font-size: 0.875rem;
}
/* Full LOD (zoom > 0.8) - Complete detail rendering */
.lg-node--lod-full {
/* Uses default styling - no overrides needed */
}
.lg-node {
/* Disable text selection on all nodes */
@@ -954,51 +996,23 @@ audio.comfy-audio.empty-audio-widget {
will-change: transform;
}
/* START LOD specific styles */
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
.isLOD .lg-node {
box-shadow: none;
filter: none;
backdrop-filter: none;
text-shadow: none;
-webkit-mask-image: none;
mask-image: none;
clip-path: none;
background-image: none;
text-rendering: optimizeSpeed;
border-radius: 0;
contain: layout style;
transition: none;
/* Global performance optimizations for LOD */
.lg-node--lod-minimal,
.lg-node--lod-reduced {
/* Remove ALL expensive paint effects */
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
text-shadow: none !important;
-webkit-mask-image: none !important;
mask-image: none !important;
clip-path: none !important;
}
.isLOD .lg-node-widgets {
pointer-events: none;
/* Reduce paint complexity for minimal LOD */
.lg-node--lod-minimal {
/* Skip complex borders */
border-radius: 0 !important;
/* Use solid colors only */
background-image: none !important;
}
.lod-toggle {
visibility: visible;
}
.isLOD .lod-toggle {
visibility: hidden;
}
.lod-fallback {
display: none;
}
.isLOD .lod-fallback {
display: block;
}
.isLOD .image-preview img {
image-rendering: pixelated;
}
.isLOD .slot-dot {
border-radius: 0;
}
/* END LOD specific styles */

View File

@@ -1,98 +0,0 @@
/**
* Cross-browser async utilities for scheduling tasks during browser idle time
* with proper fallbacks for browsers that don't support requestIdleCallback.
*
* Implementation based on:
* https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
*/
interface IdleDeadline {
didTimeout: boolean
timeRemaining(): number
}
interface IDisposable {
dispose(): void
}
/**
* Internal implementation function that handles the actual scheduling logic.
* Uses feature detection to determine whether to use native requestIdleCallback
* or fall back to setTimeout-based implementation.
*/
let _runWhenIdle: (
targetWindow: any,
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
/**
* Execute the callback during the next browser idle period.
* Falls back to setTimeout-based scheduling in browsers without native support.
*/
export let runWhenGlobalIdle: (
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: any = globalThis
if (
typeof safeGlobal.requestIdleCallback !== 'function' ||
typeof safeGlobal.cancelIdleCallback !== 'function'
) {
// Fallback implementation for browsers without native support (e.g., Safari)
_runWhenIdle = (_targetWindow, runner, _timeout?) => {
setTimeout(() => {
if (disposed) {
return
}
// Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
const end = Date.now() + 15
const deadline: IdleDeadline = {
didTimeout: true,
timeRemaining() {
return Math.max(0, end - Date.now())
}
}
runner(Object.freeze(deadline))
})
let disposed = false
return {
dispose() {
if (disposed) {
return
}
disposed = true
}
}
}
} else {
// Native requestIdleCallback implementation
_runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
const handle: number = targetWindow.requestIdleCallback(
runner,
typeof timeout === 'number' ? { timeout } : undefined
)
let disposed = false
return {
dispose() {
if (disposed) {
return
}
disposed = true
targetWindow.cancelIdleCallback(handle)
}
}
}
}
runWhenGlobalIdle = (runner, timeout) =>
_runWhenIdle(globalThis, runner, timeout)
})()

View File

@@ -1,8 +1,5 @@
<template>
<div
ref="rootEl"
class="relative overflow-hidden h-full w-full bg-neutral-900"
>
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
</div>
@@ -101,13 +98,12 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
@apply overflow-hidden;
overflow-x: auto;
}
:deep(.p-terminal) .xterm-screen {
@apply bg-neutral-900 overflow-hidden;
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -1,71 +0,0 @@
<template>
<div :class="wrapperClass">
<div class="grid grid-rows-2 gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img
src="/assets/images/comfy-brand-mark.svg"
:alt="t('g.logoAlt')"
class="w-60"
/>
</div>
<!-- Bottom container: Progress and text -->
<div class="flex flex-col items-center justify-center gap-4">
<ProgressBar
v-if="!hideProgress"
:mode="progressMode"
:value="progressPercentage ?? 0"
:show-value="false"
class="w-90 h-2 mt-8"
:pt="{ value: { class: 'bg-brand-yellow' } }"
/>
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }}
</h1>
<p v-if="statusText" class="text-lg text-neutral-400">
{{ statusText }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
/** Props for the StartupDisplay component */
interface StartupDisplayProps {
/** Progress: 0-100 for determinate, undefined for indeterminate */
progressPercentage?: number
/** Main title text */
title?: string
/** Status text shown below the title */
statusText?: string
/** Hide the progress bar */
hideProgress?: boolean
/** Use full screen wrapper (default: true) */
fullScreen?: boolean
}
const {
progressPercentage,
title,
statusText,
hideProgress = false,
fullScreen = true
} = defineProps<StartupDisplayProps>()
const progressMode = computed(() =>
progressPercentage === undefined ? 'indeterminate' : 'determinate'
)
const wrapperClass = computed(() =>
fullScreen
? 'flex items-center justify-center min-h-screen'
: 'flex items-center justify-center'
)
</script>

View File

@@ -58,11 +58,11 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'

View File

@@ -33,7 +33,7 @@
<!-- TransformPane for Vue node rendering -->
<TransformPane
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@@ -43,6 +43,8 @@
v-for="nodeData in allNodes"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:readonly="false"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
@@ -51,6 +53,9 @@
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@update:collapsed="handleNodeCollapse"
@update:title="handleNodeTitleUpdate"
/>
</TransformPane>
@@ -74,6 +79,7 @@ import {
nextTick,
onMounted,
onUnmounted,
provide,
ref,
shallowRef,
watch,
@@ -111,11 +117,14 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -162,13 +171,20 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags
const { shouldRenderVueNodes } = useVueFeatureFlags()
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle()
const { handleTransformUpdate } = useViewportCulling()
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
const viewportCulling = useViewportCulling(
isVueNodesEnabled,
vueNodeLifecycle.vueNodeData,
vueNodeLifecycle.nodeDataTrigger,
vueNodeLifecycle.nodeManager
)
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const handleVueNodeLifecycleReset = async () => {
if (shouldRenderVueNodes.value) {
if (isVueNodesEnabled.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs()
await nextTick()
vueNodeLifecycle.initializeNodeManager()
@@ -187,9 +203,32 @@ watch(
}
)
const allNodes = computed(() =>
Array.from(vueNodeLifecycle.vueNodeData.value.values())
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const allNodes = viewportCulling.allNodes
const handleTransformUpdate = () => {
viewportCulling.handleTransformUpdate()
// TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value()
}
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes
useExecutionStateProvider()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -308,27 +347,13 @@ watch(
removeSlotError(node)
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) continue
const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined
)
const slotErrorsChanged =
validErrors.length > 0 &&
validErrors.some((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
for (const error of nodeErrors.errors) {
if (error.extra_info && error.extra_info.input_name) {
const inputIndex = node.findInputSlot(error.extra_info.input_name)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
return true
}
return false
})
// Trigger Vue node data update if slot errors changed
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
comfyApp.graph.onTrigger('node:slot-errors:changed', {
nodeId: node.id
})
}
}
}

View File

@@ -33,11 +33,9 @@ const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
function hideTooltip() {
return (tooltipText.value = '')
}
const hideTooltip = () => (tooltipText.value = '')
async function showTooltip(tooltip: string | null | undefined) {
const showTooltip = async (tooltip: string | null | undefined) => {
if (!tooltip) return
left.value = comfyApp.canvas.mouse[0] + 'px'
@@ -58,9 +56,9 @@ async function showTooltip(tooltip: string | null | undefined) {
}
}
function onIdle() {
const onIdle = () => {
const { canvas } = comfyApp
const node = canvas?.node_over
const node = canvas.node_over
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }

View File

@@ -11,7 +11,7 @@
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
:pt="{
header: 'hidden',
content: 'p-1 h-10 flex flex-row gap-1'
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
}"
@wheel="canvasInteractions.handleWheel"
>

View File

@@ -49,11 +49,11 @@
</template>
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import {
forceCloseMoreOptionsSignal,
moreOptionsOpen,
@@ -152,7 +152,9 @@ const repositionPopover = () => {
}
}
const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, {
autoStart: false
})
function openPopover(triggerEvent?: Event): boolean {
const el = getButtonEl()

View File

@@ -141,13 +141,13 @@ import {
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -168,8 +168,7 @@ const EXTERNAL_LINKS = {
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
@@ -223,11 +222,7 @@ const moreItems = computed<MenuItem[]>(() => {
label: t('helpCenter.desktopUserGuide'),
visible: isElectron(),
action: () => {
const docsUrl =
electronAPI().getPlatform() === 'darwin'
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
openExternalLink(docsUrl)
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
}
},

View File

@@ -10,14 +10,14 @@
</p>
</div>
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg">
<!-- Auto Update Setting -->
<div class="flex items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.autoUpdate') }}
</h3>
<p class="text-neutral-400 mt-1">
<p class="text-sm text-neutral-400 mt-1">
{{ $t('install.settings.autoUpdateDescription') }}
</p>
</div>
@@ -32,10 +32,14 @@
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.allowMetrics') }}
</h3>
<p class="text-neutral-400">
<p class="text-sm text-neutral-400 mt-1">
{{ $t('install.settings.allowMetricsDescription') }}
</p>
<a href="#" @click.prevent="showMetricsInfo">
<a
href="#"
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
@click.prevent="showMetricsInfo"
>
{{ $t('install.settings.learnMoreAboutData') }}
</a>
</div>
@@ -47,9 +51,7 @@
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t('install.settings.dataCollectionDialog.title')"
class="select-none"
>
<div class="text-neutral-300">
<h4 class="font-medium mb-2">
@@ -108,7 +110,11 @@
</ul>
<div class="mt-4">
<a href="https://comfy.org/privacy" target="_blank">
<a
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 hover:text-blue-300 underline"
>
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
</a>
</div>

View File

@@ -1,66 +1,130 @@
<template>
<div
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
>
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.gpuPicker.title') }}
</h2>
<div class="flex flex-col gap-6 w-[600px] h-[30rem] select-none">
<!-- Installation Path Section -->
<div class="grow flex flex-col gap-4 text-neutral-300">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.gpuSelection.selectGpu') }}
</h2>
<!-- GPU Selection buttons - takes up remaining space and centers content -->
<div class="flex-1 flex gap-8 justify-center items-center">
<!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
:image-path="'/assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')"
/>
<HardwareOption
v-else
:image-path="'/assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
:selected="selected === 'nvidia'"
:recommended="true"
@click="pickGpu('nvidia')"
/>
<!-- CPU -->
<HardwareOption
placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'"
@click="pickGpu('cpu')"
/>
<!-- Manual Install -->
<HardwareOption
placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'"
@click="pickGpu('unsupported')"
/>
</div>
<p class="m-1 text-neutral-400">
{{ $t('install.gpuSelection.selectGpuDescription') }}:
</p>
<div class="pt-12 px-24 h-16">
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
<Tag
:value="$t('install.gpuPicker.recommended')"
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
/>
<i-lucide:badge-check class="text-neutral-300 text-lg" />
<!-- GPU Selection buttons -->
<div
class="flex gap-2 text-center transition-opacity"
:class="{ selected: selected }"
>
<!-- NVIDIA -->
<div
v-if="platform !== 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'nvidia' }"
role="button"
@click="pickGpu('nvidia')"
>
<img
class="m-12"
alt="NVIDIA logo"
width="196"
height="32"
src="/assets/images/nvidia-logo.svg"
/>
</div>
<!-- MPS -->
<div
v-if="platform === 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'mps' }"
role="button"
@click="pickGpu('mps')"
>
<img
class="rounded-lg hover-brighten"
alt="Apple Metal Performance Shaders Logo"
width="292"
ratio
src="/assets/images/apple-mps-logo.png"
/>
</div>
<!-- Manual configuration -->
<div
class="gpu-button"
:class="{ selected: selected === 'unsupported' }"
role="button"
@click="pickGpu('unsupported')"
>
<img
class="m-12"
alt="Manual configuration"
width="196"
src="/assets/images/manual-configuration.svg"
/>
</div>
</div>
<!-- Details on selected GPU -->
<p v-if="selected === 'nvidia'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'CUDA'" />
{{ $t('install.gpuSelection.nvidiaDescription') }}
</p>
<p v-if="selected === 'mps'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'MPS'" />
{{ $t('install.gpuSelection.mpsDescription') }}
</p>
<div v-if="selected === 'unsupported'" class="text-neutral-300">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.customSkipsPython') }}
</p>
<ul>
<li>
<strong>
{{ $t('install.gpuSelection.customComfyNeedsPython') }}
</strong>
</li>
<li>{{ $t('install.gpuSelection.customManualVenv') }}</li>
<li>{{ $t('install.gpuSelection.customInstallRequirements') }}</li>
<li>{{ $t('install.gpuSelection.customMayNotWork') }}</li>
</ul>
</div>
<div v-if="selected === 'cpu'">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.cpuModeDescription') }}
</p>
<p class="m-1">
{{ $t('install.gpuSelection.cpuModeDescription2') }}
</p>
</div>
</div>
<div class="text-neutral-300 px-24">
<p v-show="descriptionText" class="leading-relaxed">
{{ descriptionText }}
</p>
<div
class="transition-opacity flex gap-3 h-0"
:class="{
'opacity-40': selected && selected !== 'cpu'
}"
>
<ToggleSwitch
v-model="cpuMode"
input-id="cpu-mode"
class="-translate-y-40"
/>
<label for="cpu-mode" class="select-none">
{{ $t('install.gpuSelection.enableCpuMode') }}
</label>
</div>
</div>
</template>
@@ -68,12 +132,20 @@
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HardwareOption from '@/components/install/HardwareOption.vue'
import { st } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
const { t } = useI18n()
const cpuMode = computed({
get: () => selected.value === 'cpu',
set: (value) => {
selected.value = value ? 'cpu' : null
}
})
const selected = defineModel<TorchDeviceType | null>('device', {
required: true
})
@@ -81,23 +153,55 @@ const selected = defineModel<TorchDeviceType | null>('device', {
const electron = electronAPI()
const platform = electron.getPlatform()
const showRecommendedBadge = computed(
() => selected.value === 'mps' || selected.value === 'nvidia'
)
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
cpu: 'cpu',
unsupported: 'manual'
} as const
const descriptionText = computed(() => {
const key = selected.value ? descriptionKeys[selected.value] : undefined
return st(`install.gpuPicker.${key}Description`, '')
})
const pickGpu = (value: TorchDeviceType) => {
selected.value = value
const pickGpu = (value: typeof selected.value) => {
const newValue = selected.value === value ? null : value
selected.value = newValue
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
.p-tag {
--p-tag-gap: 0.5rem;
}
.hover-brighten {
@apply transition-colors;
transition-property: filter, box-shadow;
&:hover {
filter: brightness(107%) contrast(105%);
box-shadow: 0 0 0.25rem #ffffff79;
}
}
.p-accordioncontent-content {
@apply bg-neutral-900 rounded-lg transition-colors;
}
div.selected {
.gpu-button:not(.selected) {
@apply opacity-50 hover:opacity-100;
}
}
.gpu-button {
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
&.selected {
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
}
}
.disabled {
@apply pointer-events-none opacity-40;
}
.p-card-header {
@apply text-center grow;
}
.p-card-body {
@apply text-center pt-0;
}
</style>

View File

@@ -1,73 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import HardwareOption from './HardwareOption.vue'
const meta: Meta<typeof HardwareOption> = {
title: 'Desktop/Components/HardwareOption',
component: HardwareOption,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#1a1a1a' }]
}
},
argTypes: {
selected: { control: 'boolean' },
imagePath: { control: 'text' },
placeholderText: { control: 'text' },
subtitle: { control: 'text' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AppleMetalSelected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: true
}
}
export const AppleMetalUnselected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: false
}
}
export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
value: 'cpu',
selected: false
}
}
export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
value: 'unsupported',
selected: false
}
}
export const NvidiaSelected: Story = {
args: {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
value: 'nvidia',
selected: true
}
}

View File

@@ -1,55 +0,0 @@
<template>
<div class="relative">
<!-- Recommended Badge -->
<button
:class="
cn(
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@click="$emit('click')"
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
<span v-else class="text-xl font-medium text-neutral-400">
{{ placeholderText }}
</span>
</div>
<!-- Text Content -->
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string
placeholderText: string
subtitle?: string
value: TorchDeviceType
selected?: boolean
recommended?: boolean
}
defineProps<Props>()
defineEmits<{ click: [] }>()
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<!-- Back button -->
<Button
v-if="currentStep !== '1'"
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
@click="$emit('previous')"
/>
<div v-else></div>
<!-- Step indicators in center -->
<StepList class="flex justify-center items-center gap-3 select-none">
<Step value="1" :pt="stepPassthrough">
{{ $t('install.gpu') }}
</Step>
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
{{ $t('install.installLocation') }}
</Step>
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
{{ $t('install.desktopSettings') }}
</Step>
</StepList>
<!-- Next/Install button -->
<Button
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
:pt="{
label: { class: 'text-neutral-900 font-inter font-black' }
}"
:disabled="!canProceed"
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
/>
</div>
</template>
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{
/** Current step index as string ('1', '2', '3', '4') */
currentStep: string
/** Whether the user can proceed to the next step */
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()
defineEmits<{
previous: []
next: []
install: []
}>()
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
root: { class: 'flex-none p-0 m-0' },
header: ({ context }) => ({
class: [
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
context.active
? 'bg-brand-yellow w-8 rounded-sm'
: 'bg-neutral-700 w-2.5',
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
].join(' ')
}),
number: { class: 'hidden' },
title: { class: 'hidden' }
}
</script>

View File

@@ -1,148 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import InstallLocationPicker from './InstallLocationPicker.vue'
const meta: Meta<typeof InstallLocationPicker> = {
title: 'Desktop/Components/InstallLocationPicker',
component: InstallLocationPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
},
decorators: [
() => {
// Mock electron API
;(window as any).electronAPI = {
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
}),
validateInstallPath: () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false
}),
validateComfyUISource: () =>
Promise.resolve({
isValid: true
}),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
// Default story with accordion expanded
export const Default: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with different background to test transparency
export const OnNeutral900: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-900 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with debug overlay showing background colors
export const DebugBackgrounds: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8 relative">
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
<div>Parent bg: neutral-950 (#0a0a0a)</div>
<div>Accordion content: bg-transparent</div>
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
</div>
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}

View File

@@ -1,215 +1,103 @@
<template>
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<div class="flex flex-col gap-6 w-[600px]">
<!-- Installation Path Section -->
<div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.locationPicker.title') }}
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.chooseInstallationLocation') }}
</h2>
<p class="text-center text-neutral-400 px-12">
{{ $t('install.locationPicker.subtitle') }}
<p class="text-neutral-400 my-0">
{{ $t('install.installLocationDescription') }}
</p>
<!-- Path Input -->
<div class="flex gap-2 px-12">
<InputText
v-model="installPath"
:placeholder="$t('install.locationPicker.pathPlaceholder')"
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
/>
<Button
icon="pi pi-folder-open"
severity="secondary"
class="bg-neutral-700 hover:bg-neutral-600 border-0"
@click="browsePath"
/>
<div class="flex gap-2">
<IconField class="flex-1">
<InputText
v-model="installPath"
class="w-full"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
/>
<InputIcon
v-tooltip.top="$t('install.installLocationTooltip')"
class="pi pi-info-circle"
/>
</IconField>
<Button icon="pi pi-folder" class="w-12" @click="browsePath" />
</div>
<!-- Error Messages -->
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
<Message
v-if="pathError"
severity="error"
class="whitespace-pre-line w-full"
>
{{ pathError }}
</Message>
<Message v-if="pathExists" severity="warn" class="w-full">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
{{ $t('install.nonDefaultDrive') }}
</Message>
<Message v-if="pathError" severity="error" class="whitespace-pre-line">
{{ pathError }}
</Message>
<Message v-if="pathExists" severity="warn">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- System Paths Info -->
<div class="bg-neutral-800 p-4 rounded-lg">
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100">
{{ $t('install.systemLocations') }}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-folder text-neutral-400" />
<span class="text-neutral-400">App Data:</span>
<span class="text-neutral-200">{{ appData }}</span>
<span
v-tooltip="$t('install.appDataLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
<div class="flex items-center gap-2">
<i class="pi pi-desktop text-neutral-400" />
<span class="text-neutral-400">App Path:</span>
<span class="text-neutral-200">{{ appPath }}</span>
<span
v-tooltip="$t('install.appPathLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
</div>
<!-- Collapsible Sections using PrimeVue Accordion -->
<Accordion
v-model:value="activeAccordionIndex"
:multiple="true"
class="location-picker-accordion"
:pt="{
root: 'bg-transparent border-0',
panel: {
root: 'border-0 mb-0'
},
header: {
root: 'border-0',
content:
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
toggleicon: 'text-xs order-first mr-0'
},
content: {
root: 'bg-transparent border-0',
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
}
}"
>
<AccordionPanel value="0">
<AccordionHeader>
{{ $t('install.locationPicker.migrateFromExisting') }}
</AccordionHeader>
<AccordionContent>
<MigrationPicker
v-model:source-path="migrationSourcePath"
v-model:migration-item-ids="migrationItemIds"
/>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader>
{{ $t('install.locationPicker.chooseDownloadServers') }}
</AccordionHeader>
<AccordionContent>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" class="my-8" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { type ModelRef, computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const migrationSourcePath = defineModel<string>('migrationSourcePath')
const migrationItemIds = defineModel<string[]>('migrationItemIds')
const pythonMirror = defineModel<string>('pythonMirror', {
default: ''
})
const pypiMirror = defineModel<string>('pypiMirror', {
default: ''
})
const torchMirror = defineModel<string>('torchMirror', {
default: ''
})
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false)
// Accordion state - array of active panel values
const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI()
// Mirror configuration logic
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
// Get default install path on component mount
// Get system paths on component mount
onMounted(async () => {
const paths = await electron.getSystemPaths()
appData.value = paths.appData
appPath.value = paths.appPath
installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath)
userIsInChina.value = await isInChina()
})
const validatePath = async (path: string | undefined) => {
@@ -263,52 +151,3 @@ const onFocus = async () => {
await validatePath(installPath.value)
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
@apply px-12;
.p-accordionpanel {
@apply border-0 bg-transparent;
}
.p-accordionheader {
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
@apply rounded-t-xl rounded-b-none;
}
}
.p-accordioncontent {
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
}
.p-accordioncontent-content {
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
}
/* Override default chevron icons to use up/down */
.p-accordionheader-toggle-icon {
&::before {
content: '\e933';
}
}
.p-accordionpanel-active {
.p-accordionheader-toggle-icon {
&::before {
content: '\e902';
}
}
}
}
</style>

View File

@@ -1,45 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import MigrationPicker from './MigrationPicker.vue'
const meta: Meta<typeof MigrationPicker> = {
title: 'Desktop/Components/MigrationPicker',
component: MigrationPicker,
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' }
]
}
},
decorators: [
() => {
;(window as any).electronAPI = {
validateComfyUISource: () => Promise.resolve({ isValid: true }),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { MigrationPicker },
setup() {
const sourcePath = ref('')
const migrationItemIds = ref<string[]>([])
return { sourcePath, migrationItemIds }
},
template:
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
})
}

View File

@@ -2,6 +2,10 @@
<div class="flex flex-col gap-6 w-[600px]">
<!-- Source Location Section -->
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.migrateFromExistingInstallation') }}
</h2>
<p class="text-neutral-400 my-0">
{{ $t('install.migrationSourcePathDescription') }}
</p>
@@ -9,7 +13,7 @@
<div class="flex gap-2">
<InputText
v-model="sourcePath"
:placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
placeholder="Select existing ComfyUI installation (optional)"
class="flex-1"
:class="{ 'p-invalid': pathError }"
@update:model-value="validateSource"
@@ -23,7 +27,10 @@
</div>
<!-- Migration Options -->
<div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
<div
v-if="isValidSource"
class="flex flex-col gap-4 bg-neutral-800 p-4 rounded-lg"
>
<h3 class="text-lg mt-0 font-medium text-neutral-100">
{{ $t('install.selectItemsToMigrate') }}
</h3>

View File

@@ -0,0 +1,121 @@
<template>
<Panel
:header="$t('install.settings.mirrorSettings')"
toggleable
:collapsed="!showMirrorInputs"
pt:root="bg-neutral-800 border-none w-[600px]"
>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
<template #icons>
<i
v-tooltip="validationStateTooltip"
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500':
validationState === ValidationState.VALID,
'pi pi-times text-red-500':
validationState === ValidationState.INVALID
}"
/>
</template>
</Panel>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
import type { ModelRef } from 'vue'
import { computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
onMounted(async () => {
userIsInChina.value = await isInChina()
})
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
const validationState = computed(() => {
return mergeValidationStates(validationStates.value)
})
const validationStateTooltip = computed(() => {
switch (validationState.value) {
case ValidationState.INVALID:
return t('install.settings.mirrorsUnreachable')
case ValidationState.VALID:
return t('install.settings.mirrorsReachable')
default:
return t('install.settings.checkingMirrors')
}
})
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="flex flex-col gap-4 text-neutral-400 text-sm">
<div>
<h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
<div class="flex flex-col items-center gap-4">
<div class="w-full">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t(`settings.${normalizedSettingId}.name`) }}
</h3>
<p class="my-1">
<p class="text-sm text-neutral-400 mt-1">
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
</p>
</div>
@@ -16,61 +16,18 @@
"
@state-change="validationState = $event"
/>
<div v-if="secondParagraph" class="mt-2">
<a href="#" @click.prevent="showDialog = true">
{{ $t('g.learnMore') }}
</a>
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
class="select-none max-w-3xl"
>
<div class="text-neutral-300">
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
<div class="mt-2 break-all">
<span class="text-neutral-300 font-semibold">
{{ EXAMPLE_URL_FIRST_PART }}
</span>
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
</div>
<Divider />
<p>
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
</p>
<span class="text-neutral-300 font-semibold">
{{ FILE_URL_SCHEME }}
</span>
<span>
{{ EXAMPLE_FILE_URL }}
</span>
</div>
</Dialog>
</div>
</div>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { st } from '@/i18n'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const FILE_URL_SCHEME = 'file://'
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
const EXAMPLE_URL_FIRST_PART =
'https://github.com/astral-sh/python-build-standalone/releases/download'
const EXAMPLE_URL_SECOND_PART =
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
const { item } = defineProps<{
item: UVMirror
}>()
@@ -81,16 +38,11 @@ const emit = defineEmits<{
const modelValue = defineModel<string>('modelValue', { required: true })
const validationState = ref<ValidationState>(ValidationState.IDLE)
const showDialog = ref(false)
const normalizedSettingId = computed(() => {
return normalizeI18nKey(item.settingId)
})
const secondParagraph = computed(() =>
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
)
onMounted(() => {
modelValue.value = item.mirror
})

View File

@@ -1,9 +1,9 @@
<!-- Reference:
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
-->
<template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview">
<div class="_sb_node_preview">
<div class="_sb_table">
<div
class="node_header text-ellipsis mr-4"
@@ -85,8 +85,6 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
import _ from 'es-toolkit/compat'
import { computed } from 'vue'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
@@ -96,8 +94,6 @@ const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefV2
}>()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const colorPaletteStore = useColorPaletteStore()
const litegraphColors = computed(
() => colorPaletteStore.completedActivePalette.colors.litegraph_base

View File

@@ -64,29 +64,31 @@ const litegraphService = useLitegraphService()
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
const dismissable = ref(true)
function getNewNodeLocation(): Point {
const getNewNodeLocation = (): Point => {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
: litegraphService.getCanvasCenter()
}
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
const addFilter = (filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) => {
nodeFilters.value.push(filter)
}
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
const removeFilter = (
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => {
nodeFilters.value = nodeFilters.value.filter(
(f) => toRaw(f) !== toRaw(filter)
)
}
function clearFilters() {
const clearFilters = () => {
nodeFilters.value = []
}
function closeDialog() {
const closeDialog = () => {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl) {
const addNode = (nodeDef: ComfyNodeDefImpl) => {
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
@@ -104,7 +106,7 @@ function addNode(nodeDef: ComfyNodeDefImpl) {
window.requestAnimationFrame(closeDialog)
}
function showSearchBox(e: CanvasPointerEvent | null) {
const showSearchBox = (e: CanvasPointerEvent | null) => {
if (newSearchBoxEnabled.value) {
if (e?.pointerType === 'touch') {
setTimeout(() => {
@@ -118,12 +120,11 @@ function showSearchBox(e: CanvasPointerEvent | null) {
}
}
function getFirstLink() {
return canvasStore.getCanvas().linkConnector.renderLinks.at(0)
}
const getFirstLink = () =>
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
const nodeDefStore = useNodeDefStore()
function showNewSearchBox(e: CanvasPointerEvent | null) {
const showNewSearchBox = (e: CanvasPointerEvent | null) => {
const firstLink = getFirstLink()
if (firstLink) {
const filter =
@@ -148,7 +149,7 @@ function showNewSearchBox(e: CanvasPointerEvent | null) {
}, 300)
}
function showContextMenu(e: CanvasPointerEvent) {
const showContextMenu = (e: CanvasPointerEvent) => {
const firstLink = getFirstLink()
if (!firstLink) return
@@ -225,7 +226,7 @@ watchEffect(() => {
)
})
function canvasEventHandler(e: LiteGraphCanvasEvent) {
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
if (e.detail.subType === 'empty-double-click') {
showSearchBox(e.detail.originalEvent)
} else if (e.detail.subType === 'group-double-click') {
@@ -248,10 +249,8 @@ const linkReleaseActionShift = computed(() =>
)
// Prevent normal LinkConnector reset (called by CanvasPointer.finally)
function preventDefault(e: Event) {
return e.preventDefault()
}
function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
const preventDefault = (e: Event) => e.preventDefault()
const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => {
e.preventDefault()
const canvas = canvasStore.getCanvas()
@@ -261,7 +260,7 @@ function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
})
}
function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => {
disconnectOnReset = true
const action = e.detail.shiftKey
? linkReleaseActionShift.value
@@ -282,7 +281,7 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
}
// Resets litegraph state
function reset() {
const reset = () => {
listenerController?.abort()
listenerController = null
triggerEvent = null

View File

@@ -62,14 +62,14 @@ import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import { useDialogService } from '@/services/dialogService'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import SidebarIcon from './SidebarIcon.vue'

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="workflowTabRef"
class="flex p-2 gap-2 workflow-tab group"
class="flex p-2 gap-2 workflow-tab"
v-bind="$attrs"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@@ -11,13 +11,9 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
class="group-hover:hidden absolute font-bold text-2xl top-1/2 left-1/2 -translate-1/2 z-10 bg-(--comfy-menu-secondary-bg) w-4"
></span
>
<span v-if="shouldShowStatusIndicator" class="status-indicator"></span>
<Button
class="close-button p-0 w-auto invisible"
class="close-button p-0 w-auto"
icon="pi pi-times"
text
severity="secondary"
@@ -178,6 +174,18 @@ onUnmounted(() => {
})
</script>
<style scoped>
@reference '../../assets/css/style.css';
.status-indicator {
@apply absolute font-bold;
font-size: 1.5rem;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
<style>
.p-tooltip.workflow-tab-tooltip {
z-index: 1200 !important;

View File

@@ -358,6 +358,14 @@ onUpdated(() => {
@apply visible;
}
:deep(.p-togglebutton:hover) .status-indicator {
@apply hidden;
}
:deep(.p-togglebutton) .close-button {
@apply invisible;
}
:deep(.p-scrollpanel-content) {
@apply h-full;
}

View File

@@ -127,7 +127,7 @@ const toggleRightPanel = () => {
const layoutClasses = cn(
'base-widget-layout',
'rounded-2xl overflow-hidden relative',
'bg-gray-50 dark-theme:bg-gray-800'
'bg-zinc-50 dark-theme:bg-zinc-800'
)
const rightPanelButtonClasses = computed(() => {
@@ -144,7 +144,7 @@ const closeButtonClasses = cn(
const mainContainerClasses = cn(
'flex-1 flex',
'bg-gray-100 dark-theme:bg-neutral-900'
'bg-zinc-100 dark-theme:bg-neutral-900'
)
const headerClasses = cn(

View File

@@ -3,8 +3,8 @@
class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
:class="
active
? 'bg-white dark-theme:bg-charcoal-600 text-neutral'
: 'text-neutral hover:bg-gray-100 dark-theme:hover:bg-charcoal-300'
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
: 'text-neutral hover:bg-zinc-100 dark-theme:hover:bg-zinc-700/50'
"
role="button"
@click="onClick"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-charcoal-600">
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-zinc-800">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-charcoal-600">
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-zinc-800">
<slot></slot>
</div>
</template>

View File

@@ -103,22 +103,14 @@ Utility composables for common patterns:
| `useChainCallback` | Chains multiple callbacks together |
### Manager
Composables for ComfyUI Manager integration (located in
`@/workbench/extensions/manager/composables`):
Composables for ComfyUI Manager integration:
| Composable | Description |
|------------|-------------|
| `useManagerStatePersistence` | Persists manager UI state |
| `useManagerState` | Determines availability of manager UI modes |
| `useManagerQueue` | Handles manager task queue state |
| `useConflictAcknowledgment` | Tracks conflict dismissal state |
| `useConflictDetection` | Orchestrates conflict detection workflow |
| `useImportFailedDetection` | Handles import-failed conflict dialogs |
| `useRegistrySearch` | Manages registry search UI state |
#### Node Pack
Node package helpers live under
`@/workbench/extensions/manager/composables/nodePack`:
### Node Pack
Composables for node package management:
| Composable | Description |
|------------|-------------|
@@ -126,9 +118,6 @@ Node package helpers live under
| `useMissingNodes` | Detects and handles missing nodes |
| `useNodePacks` | Core node package functionality |
| `usePackUpdateStatus` | Tracks package update availability |
| `usePacksSelection` | Provides selection helpers for pack lists |
| `usePacksStatus` | Aggregates status across multiple packs |
| `useUpdateAvailableNodes` | Detects available updates for nodes |
| `useWorkflowPacks` | Manages packages used in workflows |
### Node
@@ -419,4 +408,4 @@ export function useFetchData(url) {
}
```
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).

View File

@@ -1,5 +1,4 @@
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
@@ -34,8 +33,19 @@ export const useCurrentUser = () => {
return null
})
const onUserResolved = (callback: (user: AuthUserInfo) => void) =>
whenever(resolvedUserInfo, callback, { immediate: true })
const onUserResolved = (callback: (user: AuthUserInfo) => void) => {
if (resolvedUserInfo.value) {
callback(resolvedUserInfo.value)
}
const stop = watch(resolvedUserInfo, (value) => {
if (value) {
callback(value)
}
})
return () => stop()
}
const userDisplayName = computed(() => {
if (isApiKeyLogin.value) {

View File

@@ -9,8 +9,7 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
const fitAddon = new FitAddon()
const terminal = markRaw(
new Terminal({
convertEol: true,
theme: { background: '#171717' }
convertEol: true
})
)
terminal.loadAddon(fitAddon)

View File

@@ -0,0 +1,136 @@
import { onUnmounted, ref } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called when sync stops
*/
onStop?: () => void
}
interface CanvasTransform {
scale: number
offsetX: number
offsetY: number
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
*
* The sync function typically reads canvas.ds properties like offset and scale to keep
* Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const syncWithCanvas = (canvas: LGraphCanvas) => {
* canvas.ds.scale
* canvas.ds.offset
* }
*
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* syncWithCanvas,
* {
* autoStart: false,
* onStart: () => emit('rafStatusChange', true),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
syncFn: (canvas: LGraphCanvas) => void,
options: CanvasTransformSyncOptions = {}
) {
const { onStart, onStop, autoStart = true } = options
const { getCanvas } = useCanvasStore()
const isActive = ref(false)
let rafId: number | null = null
let lastTransform: CanvasTransform = {
scale: 0,
offsetX: 0,
offsetY: 0
}
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
const ds = canvas.ds
return (
ds.scale !== lastTransform.scale ||
ds.offset[0] !== lastTransform.offsetX ||
ds.offset[1] !== lastTransform.offsetY
)
}
const sync = () => {
if (!isActive.value) return
const canvas = getCanvas()
if (!canvas) return
try {
// Only run sync if transform actually changed
if (hasTransformChanged(canvas)) {
lastTransform = {
scale: canvas.ds.scale,
offsetX: canvas.ds.offset[0],
offsetY: canvas.ds.offset[1]
}
syncFn(canvas)
}
} catch (error) {
console.error('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
const startSync = () => {
if (isActive.value) return
isActive.value = true
onStart?.()
// Reset last transform to force initial sync
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
sync()
}
const stopSync = () => {
isActive.value = false
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
onStop?.()
}
onUnmounted(stopSync)
if (autoStart) {
startSync()
}
return {
isActive,
startSync,
stopSync
}
}

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