Compare commits
27 Commits
refactor/e
...
core/1.34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d9be45e2 | ||
|
|
56fde56960 | ||
|
|
37e8f0fe9f | ||
|
|
c5ebb479a2 | ||
|
|
72eee5bfe9 | ||
|
|
0c56b7b178 | ||
|
|
ea94d4e547 | ||
|
|
2212f50afb | ||
|
|
16628f3798 | ||
|
|
5007190c3a | ||
|
|
9516100185 | ||
|
|
56985acc25 | ||
|
|
55bcb79ed5 | ||
|
|
0e19ed2f53 | ||
|
|
29fb0e0d1d | ||
|
|
3f6111947b | ||
|
|
02b3509c48 | ||
|
|
92de66ccc0 | ||
|
|
2290cef175 | ||
|
|
9e4025a341 | ||
|
|
d1a95e2fc7 | ||
|
|
dfcb83479e | ||
|
|
a7d365062b | ||
|
|
c071c0b80e | ||
|
|
39037da56c | ||
|
|
bcd20507b8 | ||
|
|
eab11fbafd |
92
browser_tests/assets/groups/nested-groups-1-inner-node.json
Normal 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
|
||||
}
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 31 KiB |
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 109 KiB |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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.',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '' }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"ES2023.Array",
|
||||
"ESNext.Iterator",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
|
||||