Compare commits

...

27 Commits

Author SHA1 Message Date
Comfy Org PR Bot
28d9be45e2 1.34.9 (#7534)
Patch version increment to 1.34.9

**Base branch:** `core/1.34`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7534-1-34-9-2cb6d73d3650816cafd7c0eb92482d1f)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-15 19:05:31 -07:00
Comfy Org PR Bot
56fde56960 [backport core/1.34] fix: collapsed nodes getting extra height based on contents (#7527)
Backport of #7490 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7527-backport-core-1-34-fix-collapsed-nodes-getting-extra-height-based-on-contents-2ca6d73d3650818da7cdc7e38bfcfb28)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
2025-12-15 17:22:42 -07:00
Comfy Org PR Bot
37e8f0fe9f [backport core/1.34] fix: prevent middle mouse button from triggering node resize in vueNodes mode (#7528)
Backport of #7511 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7528-backport-core-1-34-fix-prevent-middle-mouse-button-from-triggering-node-resize-in-vueN-2ca6d73d36508190af3fc437346f397f)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-15 17:22:01 -07:00
Christian Byrne
c5ebb479a2 [backport core/1.34] feat: bring node to front when clicking on any widget (#7520)
## Summary
Backport of #7202 to core/1.34

- Adds a capture-phase pointerdown handler to NodeWidgets that calls
`bringNodeToFront` whenever any widget is clicked
- Improves UX by ensuring the interacted node is always visible on top,
without requiring the node itself to be selected

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/7131

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7520-backport-core-1-34-feat-bring-node-to-front-when-clicking-on-any-widget-2ca6d73d365081609bc0e2732a99f5d1)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-15 17:20:55 -07:00
Christian Byrne
72eee5bfe9 [backport core/1.34] style-fix: Don't add body padding with no body. (#7523)
## Summary

Backport of #7424 to core/1.34.

Small fix for collapsed nodes - don't add body padding when node is
collapsed.

### Changes
- Removed unconditional `pb-1` from node base classes
- Added conditional `!isCollapsed && ' pb-1'` to only apply padding when
node is expanded

Original PR: #7424

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

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-15 14:52:50 -07:00
Christian Byrne
0c56b7b178 [backport core/1.34] fix: inner groups being moved double when moving outer group (in vue mode) (#7525)
## Summary

Backport of #7447 to core/1.34.

Fixes issue when dragging a group that had inner groups when in vue
mode.

When dragging the outer group in Vue mode:

1. getAllNestedItems(selected) returns ALL items: outer group + inner
groups + nodes
2. moveChildNodesInGroupVueMode loops through all items
3. For outer group G1: calls G1.move(delta, true) then
moveGroupChildren(G1, ...)
4. moveGroupChildren calls G2.move(delta) (no skipChildren) - this moves
G2 AND G2's children!
5. Then the loop reaches G2: calls G2.move(delta, true) - moves G2 again
6. Plus moveGroupChildren(G2, ...) processes G2's children again

This PR fixes it by adding `skipChildren=true` to the `move` call.
2025-12-15 14:52:41 -07:00
Christian Byrne
ea94d4e547 [backport core/1.34] Feat: Remove the Nodes 2.0 Trial Banner (#7522)
## Summary
Backport of #7390 to core/1.34

The option to try it out is still in the Menu if you're looking for it.

## Conflicts Resolved
- All test snapshot conflicts: accepted PR version (banner removal
changes snapshots)
- 4 bringToFront snapshots: re-added (deleted in target, modified in PR)

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

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-15 14:52:14 -07:00
Christian Byrne
2212f50afb [backport core/1.34] fix: refreshing assets causes entire panel to re-render (enter loading state) (#7526)
Backport of #7449 to core/1.34.

## Original PR
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7449

## Changes
- Only show loading spinner when `loading && !displayAssets.length`
instead of just `loading`
- Prevents jarring re-render when refreshing assets
2025-12-15 14:51:46 -07:00
Christian Byrne
16628f3798 [backport core/1.34] feat: add live preview method setting for prompt execution (#7521)
## Summary

Backport of #7385 to core/1.34.

Add frontend setting to override live preview method per prompt
execution.

- **What**: New setting `Comfy.Execution.PreviewMethod` allows users to
override preview method (default/none/auto/latent2rgb/taesd) from
frontend.

## Conflict Resolution

- `src/schemas/apiSchema.ts`: Kept both `Comfy.Queue.ImageFit` (target
branch) and `Comfy.Execution.PreviewMethod` (PR addition)

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-15 14:51:27 -07:00
Comfy Org PR Bot
5007190c3a [backport core/1.34] fix: text-white usage causes video dimensions to be invisible on light theme (#7524)
Backport of #7408 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7524-backport-core-1-34-fix-text-white-usage-causes-video-dimensions-to-be-invisible-on-lig-2ca6d73d365081bf9622e5beba5e68f0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-15 14:51:13 -07:00
Comfy Org PR Bot
9516100185 [backport core/1.34] fix: loading api-format workflow that contains "parameters" string (#7516)
Backport of #7411 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7516-backport-core-1-34-fix-loading-api-format-workflow-that-contains-parameters-string-2ca6d73d365081438d19fd6ed3272684)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-15 14:30:39 -07:00
Comfy Org PR Bot
56985acc25 [backport core/1.34] fix: work around Chrome GPU bug causing severe lag when dragging links (#7515)
Backport of #7394 to `core/1.34`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-15 14:17:44 -07:00
Comfy Org PR Bot
55bcb79ed5 [backport core/1.34] feat: improve search alg in templates (#7514)
Backport of #7377 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7514-backport-core-1-34-feat-improve-search-alg-in-templates-2ca6d73d365081dfb99fef48cb5e3abe)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-15 14:17:13 -07:00
Comfy Org PR Bot
0e19ed2f53 [backport core/1.34] add warning when using legacy mask editor (indicating it will be removed in next version) (#7513)
Backport of #7332 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7513-backport-core-1-34-add-warning-when-using-legacy-mask-editor-indicating-it-will-be-rem-2ca6d73d36508154937dd5b12e39ea47)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-15 14:16:51 -07:00
Comfy Org PR Bot
29fb0e0d1d [backport core/1.34] fix: node shape not reactive in vueNodes mode (#7512)
Backport of #7302 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7512-backport-core-1-34-fix-node-shape-not-reactive-in-vueNodes-mode-2ca6d73d365081de894fe4e3327791ad)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-15 14:16:31 -07:00
Comfy Org PR Bot
3f6111947b [backport core/1.34] Topbar: add Custom Nodes Manager button (#7495)
Backport of #7400 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7495-backport-core-1-34-Topbar-add-Custom-Nodes-Manager-button-2ca6d73d36508193897cdda51b5953f0)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-15 12:53:57 -08:00
Comfy Org PR Bot
02b3509c48 [backport core/1.34] Fix compatibility with older browsers (#7506)
Backport of #7205 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7506-backport-core-1-34-Fix-compatibility-with-older-browsers-2ca6d73d36508177bb41fa59ce6d9563)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-15 12:32:26 -07:00
Comfy Org PR Bot
92de66ccc0 [backport core/1.34] fix: move selected groups when dragging nodes in vueNodes mode (#7505)
Backport of #7306 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7505-backport-core-1-34-fix-move-selected-groups-when-dragging-nodes-in-vueNodes-mode-2ca6d73d365081e2af2fd6a5e9599941)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-15 12:32:01 -07:00
Comfy Org PR Bot
2290cef175 [backport core/1.34] hotfix: stabilize flaky workflow sidebar browser tests (#7504)
Backport of #7280 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7504-backport-core-1-34-hotfix-stabilize-flaky-workflow-sidebar-browser-tests-2ca6d73d3650810ab183d3f0e9316b70)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-15 12:31:47 -07:00
Comfy Org PR Bot
9e4025a341 [backport core/1.34] fix: loading state to show loader only if it takes more than 250ms (#7503)
Backport of #7268 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7503-backport-core-1-34-fix-loading-state-to-show-loader-only-if-it-takes-more-than-250ms-2ca6d73d365081a791e8f38454c74c28)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2025-12-15 12:31:15 -07:00
Comfy Org PR Bot
d1a95e2fc7 [backport core/1.34] fix: Note/MarkdownNote node color change not reactive in vueNodes mode (#7502)
Backport of #7294 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7502-backport-core-1-34-fix-Note-MarkdownNote-node-color-change-not-reactive-in-vueNodes-mo-2ca6d73d365081e5bbf7db392b3a481f)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-15 12:30:44 -07:00
Comfy Org PR Bot
dfcb83479e [backport core/1.34] fix: mouse accidentally sticks and drag the node (#7500)
Backport of #7186 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7500-backport-core-1-34-fix-mouse-accidentally-sticks-and-drag-the-node-2ca6d73d3650814694b4cfc6700027c2)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-15 12:30:22 -07:00
Comfy Org PR Bot
a7d365062b [backport core/1.34] Fix desktop menu docs links regression (#7501)
Backport of #7181 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7501-backport-core-1-34-Fix-desktop-menu-docs-links-regression-2ca6d73d365081bc8785ecc142e9c729)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-15 12:29:51 -07:00
Comfy Org PR Bot
c071c0b80e [backport core/1.34] feat(server-config): add legacy manager UI toggle (#7481)
Backport of #7478 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7481-backport-core-1-34-feat-server-config-add-legacy-manager-UI-toggle-2ca6d73d365081718d41e9cbd9f28bf9)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-14 19:00:09 -08:00
Benjamin Lu
39037da56c 1.34.8 (#7310)
Patch version increment to 1.34.8
2025-12-09 19:20:22 -08:00
Benjamin Lu
bcd20507b8 [backport core/1.34] Move cancel button into actionbar (#7297) (#7298)
## Summary
Backport of 2903560416 to core/1.34 to
place the cancel control alongside the run controls.

## Changes
- **What**: Move the interrupt button into the actionbar and remove the
duplicate from the top menu.
- **Breaking**: none
- **Dependencies**: none

## Review Focus
Check spacing/hover states of the new cancel control in docked vs
floating layouts.

## Screenshots (if applicable)
- n/a

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7298-backport-core-1-34-Move-cancel-button-into-actionbar-7297-2c46d73d36508188ad42ceaec0c2d469)
by [Unito](https://www.unito.io)
2025-12-09 20:16:48 -07:00
Comfy Org PR Bot
eab11fbafd [backport core/1.34] Add label to open subgraph button (#7289)
Backport of #7244 to `core/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7289-backport-core-1-34-Add-label-to-open-subgraph-button-2c46d73d365081fe8dd1e9fcb81c9c8e)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-09 03:22:06 -07:00
65 changed files with 958 additions and 387 deletions

View File

@@ -0,0 +1,92 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 17,
"last_link_id": 9,
"nodes": [
{
"id": 17,
"type": "VAEDecode",
"pos": [
318.8446183157076,
355.3961392345528
],
"size": [
225,
102
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [],
"groups": [
{
"id": 4,
"title": "Outer Group",
"bounding": [
-46.25245366331014,
-150.82497138023245,
1034.4034361963616,
1007.338460439933
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Inner Group",
"bounding": [
80.96059074101554,
28.123757436778178,
718.286373661183,
691.2397164539732
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7121393732101533,
"offset": [
289.18242848011835,
367.0747755524199
]
},
"frontendVersion": "1.35.5",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "Vue"
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

View File

@@ -126,6 +126,20 @@ class ConfirmDialog {
const loc = this[locator]
await expect(loc).toBeVisible()
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
@@ -242,6 +256,9 @@ export class ComfyPage {
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.nextFrame()
}
async setupUser(username: string) {
@@ -1617,6 +1634,55 @@ export class ComfyPage {
}, focusMode)
await this.nextFrame()
}
/**
* Get the position of a group by title.
* @param title The title of the group to find
* @returns The group's canvas position
* @throws Error if group not found
*/
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window['app'].graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Drag a group by its title.
* @param options.name The title of the group to drag
* @param options.deltaX Horizontal drag distance in screen pixels
* @param options.deltaY Vertical drag distance in screen pixels
*/
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window['app']
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
// Position in the title area of the group
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
}
export const testComfySnapToGridGridSize = 50

View File

@@ -137,6 +137,13 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
await this.page.keyboard.type(newName)
await this.page.keyboard.press('Enter')
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() => !window['app']?.extensionManager?.workflow?.isBusy,
undefined,
{ timeout: 3000 }
)
}
async insertWorkflow(locator: Locator) {

View File

@@ -92,9 +92,26 @@ export class Topbar {
)
// Wait for the dialog to close.
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}
}
async openTopbarMenu() {
// If menu is already open, close it first to reset state
const isAlreadyOpen = await this.menuLocator.isVisible()
if (isAlreadyOpen) {
// Click outside the menu to close it properly
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
}
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
return this.menuLocator
@@ -162,15 +179,36 @@ export class Topbar {
await topLevelMenu.hover()
// Hover over top-level menu with retry logic for flaky submenu appearance
const submenu = this.getVisibleSubmenu()
try {
await submenu.waitFor({ state: 'visible', timeout: 1000 })
} catch {
// Click outside to reset, then reopen menu
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
// Re-hover on top-level menu to trigger submenu
await topLevelMenu.hover()
await submenu.waitFor({ state: 'visible', timeout: 1000 })
}
let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = currentMenu
.locator(
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
)
const menuItem = submenu
.locator(`.p-tieredmenu-item:has-text("${commandName}")`)
.first()
await menuItem.waitFor({ state: 'visible' })
// For the last item, click it
if (i === path.length - 1) {
await menuItem.click()
return
}
// Otherwise, hover to open nested submenu
await menuItem.hover()
currentMenu = menuItem
}

View File

@@ -12,6 +12,7 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -340,6 +340,11 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.getGraphNodesCount()
// Get the bounding box of the canvas element
@@ -358,6 +363,10 @@ test.describe('Workflows sidebar', () => {
'#graph-canvas',
{ targetPosition }
)
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
// Wait for nodes to be inserted after drag-drop with retryable assertion
await expect
.poll(() => comfyPage.getGraphNodesCount(), { timeout: 3000 })
.toBe(nodeCount * 2)
})
})

View File

@@ -32,4 +32,42 @@ test.describe('Vue Node Groups', () => {
'vue-groups-fit-to-contents.png'
)
})
test('should move nested groups together when dragging outer group', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
// Get initial positions with null guards
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
const initialOffsetX = innerInitial.x - outerInitial.x
const initialOffsetY = innerInitial.y - outerInitial.y
// Drag the outer group
const dragDelta = { x: 100, y: 80 }
await comfyPage.dragGroup({
name: 'Outer Group',
deltaX: dragDelta.x,
deltaY: dragDelta.y
})
// Use retrying assertion to wait for positions to update
await expect(async () => {
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
const finalOffsetX = innerFinal.x - outerFinal.x
const finalOffsetY = innerFinal.y - outerFinal.y
// Both groups should have moved
expect(outerFinal.x).not.toBe(outerInitial.x)
expect(innerFinal.x).not.toBe(innerInitial.x)
// The relative offset should be maintained (inner group moved with outer)
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,144 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { fitToViewInstant } from '../../../../helpers/fitToView'
test.describe('Vue Node Bring to Front', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
/**
* Helper to get the z-index of a node by its title
*/
async function getNodeZIndex(
comfyPage: ComfyPage,
title: string
): Promise<number> {
const node = comfyPage.vueNodes.getNodeByTitle(title)
const style = await node.getAttribute('style')
if (!style) {
throw new Error(
`Node "${title}" has no style attribute (observed: ${style})`
)
}
const match = style.match(/z-index:\s*(\d+)/)
if (!match) {
throw new Error(
`Node "${title}" has no z-index in style (observed: "${style}")`
)
}
return parseInt(match[1], 10)
}
/**
* Helper to get the bounding box center of a node
*/
async function getNodeCenter(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const node = comfyPage.vueNodes.getNodeByTitle(title)
const box = await node.boundingBox()
if (!box) throw new Error(`Node "${title}" not found`)
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
test('should bring overlapped node to front when clicking on it', async ({
comfyPage
}) => {
// Get initial positions
const clipCenter = await getNodeCenter(comfyPage, 'CLIP Text Encode')
const ksamplerHeader = await comfyPage.page
.getByText('KSampler')
.boundingBox()
if (!ksamplerHeader) throw new Error('KSampler header not found')
// Drag KSampler on top of CLIP Text Encode
await comfyPage.dragAndDrop(
{ x: ksamplerHeader.x + 50, y: ksamplerHeader.y + 10 },
clipCenter
)
await comfyPage.nextFrame()
// Screenshot showing KSampler on top of CLIP
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-overlapped-before.png'
)
// KSampler should be on top (higher z-index) after being dragged
const ksamplerZIndexBefore = await getNodeZIndex(comfyPage, 'KSampler')
const clipZIndexBefore = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
expect(ksamplerZIndexBefore).toBeGreaterThan(clipZIndexBefore)
// Click on CLIP Text Encode (underneath) - need to click on a visible part
// Since KSampler is on top, we click on the edge of CLIP that should still be visible
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
const clipBox = await clipNode.boundingBox()
if (!clipBox) throw new Error('CLIP node not found')
// Click on a visible edge of CLIP
await comfyPage.page.mouse.click(clipBox.x + 30, clipBox.y + 10)
await comfyPage.nextFrame()
// CLIP should now be on top - compare post-action z-indices
const clipZIndexAfter = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
const ksamplerZIndexAfter = await getNodeZIndex(comfyPage, 'KSampler')
expect(clipZIndexAfter).toBeGreaterThan(ksamplerZIndexAfter)
// Screenshot showing CLIP now on top
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-overlapped-after.png'
)
})
test('should bring overlapped node to front when clicking on its widget', async ({
comfyPage
}) => {
// Get CLIP Text Encode position (it has a text widget)
const clipCenter = await getNodeCenter(comfyPage, 'CLIP Text Encode')
// Get VAE Decode position and drag it on top of CLIP
const vaeHeader = await comfyPage.page.getByText('VAE Decode').boundingBox()
if (!vaeHeader) throw new Error('VAE Decode header not found')
await comfyPage.dragAndDrop(
{ x: vaeHeader.x + 50, y: vaeHeader.y + 10 },
{ x: clipCenter.x - 50, y: clipCenter.y }
)
await comfyPage.nextFrame()
// VAE should be on top after drag
const vaeZIndexBefore = await getNodeZIndex(comfyPage, 'VAE Decode')
const clipZIndexBefore = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
expect(vaeZIndexBefore).toBeGreaterThan(clipZIndexBefore)
// Screenshot showing VAE on top
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-widget-overlapped-before.png'
)
// Click on the text widget of CLIP Text Encode
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
const clipBox = await clipNode.boundingBox()
if (!clipBox) throw new Error('CLIP node not found')
await comfyPage.page.mouse.click(clipBox.x + 170, clipBox.y + 80)
await comfyPage.nextFrame()
// CLIP should now be on top - compare post-action z-indices
const clipZIndexAfter = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
const vaeZIndexAfter = await getNodeZIndex(comfyPage, 'VAE Decode')
expect(clipZIndexAfter).toBeGreaterThan(vaeZIndexAfter)
// Screenshot showing CLIP now on top after widget click
await expect(comfyPage.canvas).toHaveScreenshot(
'bring-to-front-widget-overlapped-after.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.34.7",
"version": "1.34.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -10,59 +10,66 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div class="flex items-center gap-2">
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="mr-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
v-if="managerState.shouldShowManagerButtons.value && isDesktop"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
<IconButton
v-tooltip.bottom="customNodesManagerTooltipConfig"
type="transparent"
size="sm"
class="text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('menu.customNodesManager')"
@click="openCustomNodeManager"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
<i class="icon-[lucide--puzzle] size-4" />
</IconButton>
</div>
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
@@ -85,22 +92,23 @@ import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const executionStore = useExecutionStore()
const commandStore = useCommandStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const isTopMenuHovered = ref(false)
@@ -108,13 +116,12 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
@@ -132,14 +139,19 @@ const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
const openCustomNodeManager = async () => {
try {
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
} catch (error) {
try {
toastErrorHandler(error)
} catch (toastError) {
console.error(error)
console.error(toastError)
}
}
}
</script>
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
}
</style>

View File

@@ -30,6 +30,17 @@
/>
<ComfyRunButton />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
</Panel>
</div>
@@ -43,17 +54,24 @@ import {
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -250,6 +268,16 @@ watch(isDragging, (dragging) => {
isMouseOverDropZone.value = false
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',

View File

@@ -4,7 +4,6 @@
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady">
<template v-if="showUI" #workflow-tabs>
<TryVueNodeBanner />
<div
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-9.5 w-full"
@@ -161,7 +160,6 @@ import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import TryVueNodeBanner from '../topbar/TryVueNodeBanner.vue'
import SelectionRectangle from './SelectionRectangle.vue'
const emit = defineEmits<{

View File

@@ -48,12 +48,11 @@
/>
</template>
<template #body>
<!-- Loading state -->
<div v-if="loading">
<Divider type="dashed" class="m-2" />
<div v-if="loading && !displayAssets.length">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<!-- Empty state -->
<div v-else-if="!displayAssets.length">
<div v-else-if="!loading && !displayAssets.length">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -66,7 +65,6 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<!-- Content -->
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<VirtualGrid
:items="mediaAssetsWithKey"
@@ -167,6 +165,7 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { Divider } from 'primevue'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'

View File

@@ -1,79 +0,0 @@
<template>
<Toast
group="vue-nodes-migration"
position="bottom-center"
class="w-auto"
@close="handleClose"
>
<template #message>
<div class="flex flex-auto items-center justify-between gap-4">
<span class="whitespace-nowrap">{{
t('vueNodesMigration.message')
}}</span>
<Button
class="whitespace-nowrap"
size="small"
:label="t('vueNodesMigration.button')"
text
@click="switchBack"
/>
</div>
</template>
</Toast>
<Toast
group="vue-nodes-check-main-menu"
position="bottom-center"
class="w-auto"
>
<template #message>
<div class="flex flex-auto items-center justify-between gap-4">
<span class="whitespace-nowrap">{{
t('vueNodesMigrationMainMenu.message')
}}</span>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
const { t } = useI18n()
const toast = useToast()
const isDismissed = useVueNodesMigrationDismissed()
const switchBack = async () => {
await disableVueNodes()
toast.removeGroup('vue-nodes-migration')
isDismissed.value = true
showMainMenuToast()
}
const handleClose = () => {
isDismissed.value = true
showMainMenuToast()
}
const disableVueNodes = async () => {
await useSettingStore().set('Comfy.VueNodes.Enabled', false)
useTelemetry()?.trackUiButtonClicked({
button_id: `vue_nodes_migration_toast_switch_back_clicked`
})
}
const showMainMenuToast = () => {
useToastStore().add({
group: 'vue-nodes-check-main-menu',
severity: 'info',
life: 5000
})
}
</script>

View File

@@ -1,74 +0,0 @@
<template>
<div
v-if="showVueNodesBanner"
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
>
<div class="flex items-center text-sm text-white">
<i class="icon-[lucide--rocket]"></i>
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
<span class="pl-1.5 hidden md:inline">{{
$t('vueNodesBanner.desc')
}}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"
>
{{ $t('vueNodesBanner.tryItOut') }}
</Button>
</div>
<Button
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4 text-white"
unstyled
@click="handleDismiss"
>
<i class="w-5 h-5 icon-[lucide--x]"></i>
</Button>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const STORAGE_KEY = 'vueNodesBannerDismissed'
const settingStore = useSettingStore()
const bannerDismissed = useLocalStorage(STORAGE_KEY, false)
const vueNodesEnabled = computed(() => {
try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch {
return false
}
})
const showVueNodesBanner = computed(() => {
if (vueNodesEnabled.value) {
return false
}
if (bannerDismissed.value) {
return false
}
return true
})
const handleDismiss = (): void => {
bannerDismissed.value = true
}
const handleTryItOut = async (): Promise<void> => {
try {
await settingStore.set('Comfy.VueNodes.Enabled', true)
} catch (error) {
console.error('Failed to enable Nodes 2.0:', error)
} finally {
handleDismiss()
}
}
</script>

View File

@@ -69,6 +69,7 @@ export interface VueNodeData {
}
color?: string
bgcolor?: string
shape?: number
}
export interface GraphNodeManager {
@@ -234,7 +235,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined
bgcolor: node.bgcolor || undefined,
shape: node.shape
}
}
@@ -571,6 +573,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? propertyEvent.newValue
: undefined
})
break
case 'shape':
vueNodeData.set(nodeId, {
...currentData,
shape:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: undefined
})
}
}
},

View File

@@ -4,7 +4,6 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -13,7 +12,6 @@ import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -22,10 +20,6 @@ function useVueNodeLifecycleIndividual() {
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
let hasShownMigrationToast = false
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph
@@ -83,24 +77,12 @@ function useVueNodeLifecycleIndividual() {
// Watch for Vue nodes enabled state changes
watch(
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
(enabled, wasEnabled) => {
(enabled) => {
if (enabled) {
initializeNodeManager()
ensureCorrectLayoutScale(
comfyApp.canvas?.graph?.extra.workflowRendererVersion
)
if (
wasEnabled === false &&
!isVueNodeToastDismissed.value &&
!hasShownMigrationToast
) {
hasShownMigrationToast = true
useToastStore().add({
group: 'vue-nodes-migration',
severity: 'info',
life: 0
})
}
}
},
{ immediate: true }

View File

@@ -1,7 +1,7 @@
import { computed } from 'vue'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/i18n'
/**
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
@@ -23,7 +23,7 @@ import { useI18n } from 'vue-i18n'
* ```
*/
export function useExternalLink() {
const { locale } = useI18n()
const locale = computed(() => String(i18n.global.locale.value))
const isChinese = computed(() => {
return locale.value === 'zh' || locale.value === 'zh-TW'

View File

@@ -41,11 +41,11 @@ export function useTemplateFiltering(
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'description', weight: 0.2 },
{ name: 'tags', weight: 0.1 },
{ name: 'models', weight: 0.1 }
{ name: 'description', weight: 0.1 },
{ name: 'tags', weight: 0.2 },
{ name: 'models', weight: 0.3 }
],
threshold: 0.4,
threshold: 0.33,
includeScore: true,
includeMatches: true
}

View File

@@ -1,8 +0,0 @@
import { createSharedComposable, useLocalStorage } from '@vueuse/core'
// Browser storage events don't fire in the same tab, so separate
// useLocalStorage() calls create isolated reactive refs. Use shared
// composable to ensure all components use the same ref instance.
export const useVueNodesMigrationDismissed = createSharedComposable(() =>
useLocalStorage('comfy.vueNodesMigration.dismissed', false)
)

View File

@@ -389,6 +389,13 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'enable-manager-legacy-ui',
name: 'Use legacy Manager UI',
tooltip: 'Uses the legacy ComfyUI-Manager UI instead of the new UI.',
type: 'boolean',
defaultValue: false
},
{
id: 'disable-all-custom-nodes',
name: 'Disable loading all custom nodes.',

View File

@@ -1,13 +1,26 @@
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { t } from '@/i18n'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import { MaskEditorDialogOld } from './maskEditorOld'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { ClipspaceDialog } from './clipspace'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { MaskEditorDialogOld } from './maskEditorOld'
const warnLegacyMaskEditorDeprecation = () => {
const warningMessage = t('toastMessages.legacyMaskEditorDeprecated')
console.warn(`[Comfy.MaskEditor] ${warningMessage}`)
useToastStore().add({
severity: 'warn',
summary: 'Alert',
detail: warningMessage,
life: 4096
})
}
function openMaskEditor(node: LGraphNode): void {
if (!node) {
@@ -27,6 +40,7 @@ function openMaskEditor(node: LGraphNode): void {
if (useNewEditor) {
useMaskEditor().openMaskEditor(node)
} else {
warnLegacyMaskEditorDeprecation()
// Use old editor
ComfyApp.copyToClipspace(node)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
@@ -122,6 +136,7 @@ app.registerExtension({
'Comfy.MaskEditor.UseNewEditor'
)
if (!useNewEditor) {
warnLegacyMaskEditorDeprecation()
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()

View File

@@ -14,13 +14,15 @@ app.registerExtension({
static collapsable: boolean
static title_mode: number
override color = LGraphCanvas.node_colors.yellow.color
override bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
override isVirtualNode: boolean
constructor(title: string) {
super(title)
this.color = LGraphCanvas.node_colors.yellow.color
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
if (!this.properties) {
this.properties = { text: '' }
}
@@ -53,12 +55,14 @@ app.registerExtension({
class MarkdownNoteNode extends LGraphNode {
static override title = 'Markdown Note'
override color = LGraphCanvas.node_colors.yellow.color
override bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
constructor(title: string) {
super(title)
this.color = LGraphCanvas.node_colors.yellow.color
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
if (!this.properties) {
this.properties = { text: '' }
}

View File

@@ -8566,9 +8566,11 @@ export class LGraphCanvas
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else {
// Non-node children (nested groups, reroutes)
child.move(deltaX, deltaY)
} else if (!(child instanceof LGraphGroup)) {
// Non-node, non-group children (reroutes, etc.)
// Skip groups here - they're already in allItems and will be
// processed in the main loop of moveChildNodesInGroupVueMode
child.move(deltaX, deltaY, true)
}
}
}

View File

@@ -495,6 +495,7 @@ export class LGraphNode
}
set shape(v: RenderShape | 'default' | 'box' | 'round' | 'circle' | 'card') {
const oldValue = this._shape
switch (v) {
case 'default':
this._shape = undefined
@@ -514,6 +515,14 @@ export class LGraphNode
default:
this._shape = v
}
if (oldValue !== this._shape) {
this.graph?.trigger('node:property:changed', {
nodeId: this.id,
property: 'shape',
oldValue,
newValue: this._shape
})
}
}
/**
@@ -851,13 +860,12 @@ export class LGraphNode
}
if (info.widgets_values) {
const widgetsWithValue = this.widgets
.values()
.filter((w) => w.serialize !== false)
.filter((_w, idx) => idx < info.widgets_values!.length)
widgetsWithValue.forEach(
(widget, i) => (widget.value = info.widgets_values![i])
)
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
}
}

View File

@@ -794,6 +794,7 @@
"dark": "Dark",
"light": "Light",
"manageExtensions": "Manage Extensions",
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"queue": "Queue Panel"
@@ -1307,6 +1308,10 @@
"disable-metadata": {
"name": "Disable saving prompt metadata in files."
},
"enable-manager-legacy-ui": {
"name": "Use legacy Manager UI",
"tooltip": "Uses the legacy ComfyUI-Manager UI instead of the new UI."
},
"disable-all-custom-nodes": {
"name": "Disable loading all custom nodes."
},
@@ -1649,6 +1654,7 @@
"noTemplatesToExport": "No templates to export",
"failedToFetchLogs": "Failed to fetch server logs",
"migrateToLitegraphReroute": "Reroute nodes will be removed in future versions. Click to migrate to litegraph-native reroute.",
"legacyMaskEditorDeprecated": "The legacy mask editor is deprecated and will be removed soon.",
"userNotAuthenticated": "User not authenticated",
"failedToFetchBalance": "Failed to fetch balance: {error}",
"failedToCreateCustomer": "Failed to create customer: {error}",
@@ -2273,4 +2279,4 @@
"inputsNoneTooltip": "Node has no inputs",
"nodeState": "Node state"
}
}
}

View File

@@ -53,6 +53,17 @@
"Comfy_EnableWorkflowViewRestore": {
"name": "Save and restore canvas position and zoom level in workflows"
},
"Comfy_Execution_PreviewMethod": {
"name": "Live preview method",
"tooltip": "Live preview method during image generation. \"default\" uses the server CLI setting.",
"options": {
"default": "default",
"none": "none",
"auto": "auto",
"latent2rgb": "latent2rgb",
"taesd": "taesd"
}
},
"Comfy_FloatRoundingPrecision": {
"name": "Float widget rounding decimal places [0 = auto].",
"tooltip": "(requires page reload)"

View File

@@ -813,6 +813,17 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 64,
versionAdded: '1.4.12'
},
{
id: 'Comfy.Execution.PreviewMethod',
category: ['Comfy', 'Execution', 'PreviewMethod'],
name: 'Live preview method',
tooltip:
'Live preview method during image generation. "default" uses the server CLI setting.',
type: 'combo',
options: ['default', 'none', 'auto', 'latent2rgb', 'taesd'],
defaultValue: 'default',
versionAdded: '1.36.0'
},
{
id: 'LiteGraph.Canvas.MaximumFps',
name: 'Maximum FPS',

View File

@@ -90,7 +90,7 @@
</div>
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<div class="mt-2 text-center text-xs text-muted-foreground">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>

View File

@@ -29,24 +29,16 @@
</p>
</div>
<!-- Loading State -->
<Skeleton
v-if="isLoading && !imageError"
border-radius="5px"
width="100%"
height="100%"
/>
<div v-if="showLoader && !imageError" class="size-full">
<Skeleton border-radius="5px" width="100%" height="100%" />
</div>
<!-- Main Image -->
<img
v-if="!imageError"
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
:class="
cn(
'block size-full object-contain pointer-events-none',
isLoading && 'invisible'
)
"
class="block size-full object-contain pointer-events-none"
@load="handleImageLoad"
@error="handleImageError"
/>
@@ -91,7 +83,7 @@
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-base-foreground">
<span v-else-if="showLoader" class="text-base-foreground">
{{ $t('g.loading') }}...
</span>
<span v-else>
@@ -117,6 +109,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
@@ -126,7 +119,6 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
/** Array of image URLs to display */
@@ -149,10 +141,19 @@ const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const isLoading = ref(false)
const showLoader = ref(false)
const currentImageEl = ref<HTMLImageElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
showLoader.value = true
},
250,
// Make sure it doesnt run on component mount
{ immediate: false }
)
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
@@ -169,17 +170,19 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
imageError.value = false
isLoading.value = newUrls.length > 0
if (newUrls.length > 0) startDelayedLoader()
},
{ deep: true }
{ deep: true, immediate: true }
)
// Event handlers
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
isLoading.value = false
stopDelayedLoader()
showLoader.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
@@ -187,7 +190,8 @@ const handleImageLoad = (event: Event) => {
}
const handleImageError = () => {
isLoading.value = false
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
@@ -230,8 +234,7 @@ const setCurrentIndex = (index: number) => {
if (currentIndex.value === index) return
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
startDelayedLoader()
imageError.value = false
}
}

View File

@@ -8,10 +8,10 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-component-node-background lg-node absolute pb-1',
'bg-component-node-background lg-node absolute',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
'rounded-2xl touch-none flex flex-col',
shapeClass,
'touch-none flex flex-col',
'border-1 border-solid border-component-node-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
@@ -21,16 +21,17 @@
outlineClass,
cursorClass,
{
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
[`${beforeShapeClass} before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0`]:
bypassed,
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
: 'pointer-events-none',
!isCollapsed && ' pb-1'
)
"
:style="[
@@ -140,7 +141,8 @@ import { st } from '@/i18n'
import {
LGraphCanvas,
LGraphEventMode,
LiteGraph
LiteGraph,
RenderShape
} from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -295,19 +297,26 @@ const handleContextMenu = (event: MouseEvent) => {
}
onMounted(() => {
// Set initial DOM size from layout store, but respect intrinsic content minimum
if (size.value && nodeContainerRef.value) {
nodeContainerRef.value.style.setProperty(
'--node-width',
`${size.value.width}px`
)
nodeContainerRef.value.style.setProperty(
'--node-height',
`${size.value.height}px`
)
}
initSizeStyles()
})
/**
* Set initial DOM size from layout store, but respect intrinsic content minimum.
* Important: nodes can mount in a collapsed state, and the collapse watcher won't
* run initially. Match the collapsed runtime behavior by writing to the correct
* CSS variables on mount.
*/
function initSizeStyles() {
const el = nodeContainerRef.value
const { width, height } = size.value
if (!el) return
const suffix = isCollapsed.value ? '-x' : ''
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
el.style.setProperty(`--node-height${suffix}`, `${height}px`)
}
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
@@ -325,6 +334,7 @@ const { startResize } = useNodeResize((result, element) => {
})
const handleResizePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (nodeData.flags?.pinned) return
startResize(event)
}
@@ -383,6 +393,28 @@ const cursorClass = computed(() => {
)
})
const shapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'rounded-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
default:
return 'rounded-2xl'
}
})
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'before:rounded-none'
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
default:
return 'before:rounded-2xl'
}
})
// Event handlers
const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value)

View File

@@ -6,9 +6,9 @@
v-else
:class="
cn(
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-0',
'lg-node-header py-2 pl-2 pr-3 text-sm w-full min-w-0',
'text-node-component-header bg-node-component-header-surface',
collapsed && 'rounded-2xl'
headerShapeClass
)
"
:style="headerStyle"
@@ -38,7 +38,6 @@
</IconButton>
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div v-if="isApiNode" class="icon-[lucide--dollar-sign] size-4" />
<!-- Node Title -->
@@ -76,13 +75,16 @@
v-tooltip.top="enterSubgraphTooltipConfig"
type="transparent"
data-testid="subgraph-enter-button"
class="size-5"
class="ml-2 text-node-component-header h-5"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i
class="icon-[lucide--picture-in-picture] size-5 text-node-component-header-icon"
></i>
<div
class="min-w-max rounded-sm bg-node-component-surface px-1 py-0.5 text-xs flex items-center gap-1"
>
{{ $t('g.edit') }}
<i class="icon-[lucide--scaling] size-5"></i>
</div>
</IconButton>
</div>
</div>
@@ -97,7 +99,7 @@ import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
@@ -202,6 +204,28 @@ const nodeBadges = computed<NodeBadgeProps[]>(() =>
)
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
const headerShapeClass = computed(() => {
if (collapsed) {
switch (nodeData?.shape) {
case RenderShape.BOX:
return 'rounded-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
default:
return 'rounded-2xl'
}
}
switch (nodeData?.shape) {
case RenderShape.BOX:
return 'rounded-t-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-tr-none'
default:
return 'rounded-t-2xl'
}
})
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false

View File

@@ -15,6 +15,7 @@
:style="{
'grid-template-rows': gridTemplateRows
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
@@ -78,6 +79,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
// Import widget components directly
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
@@ -99,12 +101,20 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { bringNodeToFront } = useNodeZIndex()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
event.stopPropagation()
forwardEventToCanvas(event)
}
function handleBringToFront() {
if (nodeData?.id != null) {
bringNodeToFront(String(nodeData.id))
}
}
// Error boundary implementation
const renderError = ref<string | null>(null)

View File

@@ -134,6 +134,10 @@ function useNodeEventHandlersIndividual() {
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
canvasStore.updateSelectedItems()
// Bring node to front when selected (unless pinned)
if (!node.flags?.pinned) {
bringNodeToFront(nodeId)
}
return
}
@@ -141,6 +145,10 @@ function useNodeEventHandlersIndividual() {
canvasStore.canvas.deselect(node)
} else {
canvasStore.canvas.select(node)
// Bring node to front when selected (unless pinned)
if (!node.flags?.pinned) {
bringNodeToFront(nodeId)
}
}
canvasStore.updateSelectedItems()

View File

@@ -26,6 +26,8 @@ export function useNodePointerInteractions(
return true
}
let hasDraggingStarted = false
const startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels
@@ -57,7 +59,7 @@ export function useNodePointerInteractions(
startPosition.value = { x: event.clientX, y: event.clientY }
startDrag(event, nodeId)
safeDragStart(event, nodeId)
}
function onPointermove(event: PointerEvent) {
@@ -78,7 +80,7 @@ export function useNodePointerInteractions(
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
startDrag(event, nodeId)
safeDragStart(event, nodeId)
return
}
// Check if we should start dragging (pointer moved beyond threshold)
@@ -102,6 +104,14 @@ export function useNodePointerInteractions(
layoutStore.isDraggingVueNodes.value = false
}
function safeDragStart(event: PointerEvent, nodeId: string) {
try {
startDrag(event, nodeId)
} finally {
hasDraggingStarted = true
}
}
function safeDragEnd(event: PointerEvent) {
try {
const nodeId = toValue(nodeIdRef)
@@ -109,6 +119,7 @@ export function useNodePointerInteractions(
} catch (error) {
console.error('Error during endDrag:', error)
} finally {
hasDraggingStarted = false
cleanupDragState()
}
}
@@ -123,9 +134,12 @@ export function useNodePointerInteractions(
}
const wasDragging = layoutStore.isDraggingVueNodes.value
if (wasDragging) {
if (hasDraggingStarted || wasDragging) {
safeDragEnd(event)
return
if (wasDragging) {
return
}
}
// Skip selection handling for right-click (button 2) - context menu handles its own selection

View File

@@ -1,6 +1,7 @@
import { storeToRefs } from 'pinia'
import { toValue } from 'vue'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -13,13 +14,14 @@ import type {
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { createSharedComposable } from '@vueuse/core'
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
function useNodeDragIndividual() {
const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = useTransformState()
@@ -37,6 +39,10 @@ function useNodeDragIndividual() {
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
// For groups: track the last applied canvas delta to compute frame delta
let lastCanvasDelta: Point | null = null
let selectedGroups: LGraphGroup[] | null = null
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
if (!layout) return
@@ -67,6 +73,10 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
}
// Capture selected groups (filter from selectedItems which only contains selected items)
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
mutations.setSource(LayoutSource.Vue)
}
@@ -127,6 +137,21 @@ function useNodeDragIndividual() {
mutations.moveNode(otherNodeId, newOtherPosition)
}
}
// Move selected groups using frame delta (difference from last frame)
// This matches LiteGraph's behavior which uses delta-based movement
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
group.move(frameDelta.x, frameDelta.y, true)
}
}
lastCanvasDelta = canvasDelta
})
}
@@ -195,6 +220,8 @@ function useNodeDragIndividual() {
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
selectedGroups = null
lastCanvasDelta = null
// Stop tracking shift key state
stopShiftSync?.()

View File

@@ -12,6 +12,37 @@ import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
/**
* Workaround for Chrome GPU bug:
* When Chrome is maximized with GPU acceleration and high DPR, calling
* drawImage(canvas) + drawImage(img) in the same frame causes severe
* performance degradation (FPS drops to 2-10, memory spikes ~18GB).
*
* Solution: Defer image rendering using queueMicrotask to separate
* the two drawImage calls into different tasks.
*
* Note: As tested, requestAnimationFrame delays rendering to the next frame,
* causing visible image flickering. queueMicrotask executes within the same
* frame, avoiding flicker while still separating the drawImage calls.
*/
let deferredImageRenders: Array<() => void> = []
let deferredRenderScheduled = false
function scheduleDeferredImageRender() {
if (deferredRenderScheduled) return
deferredRenderScheduled = true
queueMicrotask(() => {
const renders = deferredImageRenders
deferredImageRenders = []
deferredRenderScheduled = false
for (const render of renders) {
render()
}
})
}
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
@@ -124,13 +155,31 @@ const renderPreview = (
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
// Defer image rendering to work around Chrome GPU bug
const transform = ctx.getTransform()
const filter = ctx.filter
const drawParams = {
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
x: imgX + cell_padding,
y: imgY + cell_padding,
w: imgWidth - cell_padding * 2,
h: imgHeight - cell_padding * 2
}
deferredImageRenders.push(() => {
ctx.save()
ctx.setTransform(transform)
ctx.filter = filter
ctx.drawImage(
drawParams.img,
drawParams.x,
drawParams.y,
drawParams.w,
drawParams.h
)
ctx.restore()
})
scheduleDeferredImageRender()
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
@@ -167,7 +216,16 @@ const renderPreview = (
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Defer image rendering to work around Chrome GPU bug
const transform = ctx.getTransform()
deferredImageRenders.push(() => {
ctx.save()
ctx.setTransform(transform)
ctx.drawImage(img, x, y, w, h)
ctx.restore()
})
scheduleDeferredImageRender()
// Draw image size text below the image
if (allowImageSizeDraw) {

View File

@@ -369,6 +369,15 @@ const zNodeBadgeMode = z.enum(
Object.values(NodeBadgeMode) as [string, ...string[]]
)
const zPreviewMethod = z.enum([
'default',
'none',
'auto',
'latent2rgb',
'taesd'
])
export type PreviewMethod = z.infer<typeof zPreviewMethod>
const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
@@ -431,6 +440,7 @@ const zSettings = z.object({
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
'Comfy.Execution.PreviewMethod': zPreviewMethod,
'Comfy.Workflow.WorkflowTabsPosition': z.enum(['Sidebar', 'Topbar']),
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
'Comfy.WidgetControlMode': z.enum(['before', 'after']),

View File

@@ -41,7 +41,8 @@ import type {
StatusWsMessageStatus,
SystemStats,
User,
UserDataFullInfo
UserDataFullInfo,
PreviewMethod
} from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -88,6 +89,11 @@ interface QueuePromptRequestBody {
* ```
*/
api_key_comfy_org?: string
/**
* Override the preview method for this prompt execution.
* 'default' uses the server's CLI setting.
*/
preview_method?: PreviewMethod
}
front?: boolean
number?: number
@@ -103,6 +109,11 @@ interface QueuePromptOptions {
* Format: Colon-separated path of node IDs (e.g., "123:456:789")
*/
partialExecutionTargets?: NodeExecutionId[]
/**
* Override the preview method for this prompt execution.
* 'default' uses the server's CLI setting and is not sent to backend.
*/
previewMethod?: PreviewMethod
}
/** Dictionary of Frontend-generated API calls */
@@ -772,7 +783,11 @@ export class ComfyApi extends EventTarget {
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
extra_pnginfo: { workflow }
extra_pnginfo: { workflow },
...(options?.previewMethod &&
options.previewMethod !== 'default' && {
preview_method: options.previewMethod
})
}
}

View File

@@ -1344,6 +1344,9 @@ export class ComfyApp {
try {
while (this.queueItems.length) {
const { number, batchCount, queueNodeIds } = this.queueItems.pop()!
const previewMethod = useSettingStore().get(
'Comfy.Execution.PreviewMethod'
)
for (let i = 0; i < batchCount; i++) {
// Allow widgets to run callbacks before a prompt has been queued
@@ -1358,7 +1361,8 @@ export class ComfyApp {
api.authToken = comfyOrgAuthToken
api.apiKey = comfyOrgApiKey ?? undefined
const res = await api.queuePrompt(number, p, {
partialExecutionTargets: queueNodeIds
partialExecutionTargets: queueNodeIds,
previewMethod
})
delete api.authToken
delete api.apiKey
@@ -1469,7 +1473,21 @@ export class ComfyApp {
}
}
// Use parameters as fallback when no workflow exists
if (prompt) {
try {
const promptObj =
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
if (this.isApiJson(promptObj)) {
this.loadApiJson(promptObj, fileName)
return
}
} catch (err) {
console.error('Failed to parse prompt:', err)
}
// Fall through to parameters as a last resort
}
// Use parameters strictly as the final fallback
if (parameters) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
@@ -1482,18 +1500,25 @@ export class ComfyApp {
return
}
if (prompt) {
const promptObj = typeof prompt === 'string' ? JSON.parse(prompt) : prompt
this.loadApiJson(promptObj, fileName)
return
}
this.showErrorOnFileLoad(file)
}
// @deprecated
isApiJson(data: unknown) {
return _.isObject(data) && Object.values(data).every((v) => v.class_type)
isApiJson(data: unknown): data is ComfyApiWorkflow {
if (!_.isObject(data) || Array.isArray(data)) {
return false
}
if (Object.keys(data).length === 0) return false
return Object.values(data).every((node) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return false
}
const { class_type: classType, inputs } = node as Record<string, unknown>
const inputsIsRecord = _.isObject(inputs) && !Array.isArray(inputs)
return typeof classType === 'string' && inputsIsRecord
})
}
loadApiJson(apiData: ComfyApiWorkflow, fileName: string) {

View File

@@ -17,7 +17,6 @@
<GlobalToast />
<RerouteMigrationToast />
<VueNodesMigrationToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<MenuHamburger />
</template>
@@ -44,7 +43,6 @@ import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDi
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import VueNodesMigrationToast from '@/components/toast/VueNodesMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'

View File

@@ -1,7 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useExternalLink } from '@/composables/useExternalLink'
// Mock the environment utilities
vi.mock('@/utils/envUtil', () => ({
@@ -9,22 +6,27 @@ vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn()
}))
// Mock vue-i18n
const mockLocale = ref('en')
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: mockLocale
}))
// Provide a minimal i18n instance for the composable
const i18n = vi.hoisted(() => ({
global: {
locale: {
value: 'en'
}
}
}))
vi.mock('@/i18n', () => ({
i18n
}))
// Import after mocking to get the mocked versions
import { useExternalLink } from '@/composables/useExternalLink'
import { electronAPI, isElectron } from '@/utils/envUtil'
describe('useExternalLink', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to default state
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(false)
})
@@ -53,7 +55,7 @@ describe('useExternalLink', () => {
describe('buildDocsUrl', () => {
it('should build basic docs URL without locale', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog')
@@ -61,7 +63,7 @@ describe('useExternalLink', () => {
})
it('should build docs URL with Chinese (zh) locale when requested', () => {
mockLocale.value = 'zh'
i18n.global.locale.value = 'zh'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
@@ -69,7 +71,7 @@ describe('useExternalLink', () => {
})
it('should build docs URL with Chinese (zh-TW) locale when requested', () => {
mockLocale.value = 'zh-TW'
i18n.global.locale.value = 'zh-TW'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
@@ -77,7 +79,7 @@ describe('useExternalLink', () => {
})
it('should not include locale for English when requested', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
@@ -92,7 +94,7 @@ describe('useExternalLink', () => {
})
it('should add platform suffix when requested', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'darwin'
@@ -104,7 +106,7 @@ describe('useExternalLink', () => {
})
it('should add platform suffix with trailing slash', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'win32'
@@ -116,7 +118,7 @@ describe('useExternalLink', () => {
})
it('should combine locale and platform', () => {
mockLocale.value = 'zh'
i18n.global.locale.value = 'zh'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'darwin'
@@ -133,7 +135,7 @@ describe('useExternalLink', () => {
})
it('should not add platform when not desktop', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(false)
const { buildDocsUrl } = useExternalLink()

View File

@@ -208,11 +208,6 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
@@ -265,11 +260,6 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)

View File

@@ -60,7 +60,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
size: { width: 200, height: 100 },
size: computed(() => ({ width: 200, height: 100 })),
zIndex: 0,
startDrag: vi.fn(),
handleDrag: vi.fn(),
@@ -201,4 +201,32 @@ describe('LGraphNode', () => {
expect(wrapper.classes()).toContain('border-node-stroke-executing')
})
it('should initialize height CSS vars for collapsed nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe('')
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe(
'100px'
)
})
it('should initialize height CSS vars for expanded nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: false }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe(
'100px'
)
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe('')
})
})

View File

@@ -6,7 +6,6 @@
"lib": [
"ES2023",
"ES2023.Array",
"ESNext.Iterator",
"DOM",
"DOM.Iterable"
],