Merge branch 'main' into sno-fix-playwright-babel-config

This commit is contained in:
snomiao
2025-09-15 06:58:58 +00:00
151 changed files with 9341 additions and 659 deletions

View File

@@ -40,7 +40,7 @@ jobs:
- name: Get new version
id: get-version
run: |
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version')
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].dependencies."@comfyorg/comfyui-electron-types".version')
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request

View File

@@ -13,6 +13,7 @@ import { ComfyActionbar } from '../helpers/actionbar'
import { LocationMock } from '../helpers/locationMock'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { SettingDialog } from './components/SettingDialog'
import {
@@ -146,6 +147,7 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly locationMock: LocationMock
public readonly vueNodes: VueNodeHelpers
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -175,6 +177,7 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.locationMock = new LocationMock(page)
this.vueNodes = new VueNodeHelpers(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {
@@ -1433,7 +1436,7 @@ export class ComfyPage {
}
async closeDialog() {
await this.page.locator('.p-dialog-close-button').click()
await this.page.locator('.p-dialog-close-button').click({ force: true })
await expect(this.page.locator('.p-dialog')).toBeHidden()
}

View File

@@ -0,0 +1,110 @@
/**
* Vue Node Test Helpers
*/
import type { Locator, Page } from '@playwright/test'
export class VueNodeHelpers {
constructor(private page: Page) {}
/**
* Get locator for all Vue node components in the DOM
*/
get nodes(): Locator {
return this.page.locator('[data-node-id]')
}
/**
* Get locator for selected Vue node components (using visual selection indicators)
*/
get selectedNodes(): Locator {
return this.page.locator(
'[data-node-id].outline-black, [data-node-id].outline-white'
)
}
/**
* Get total count of Vue nodes in the DOM
*/
async getNodeCount(): Promise<number> {
return await this.nodes.count()
}
/**
* Get count of selected Vue nodes
*/
async getSelectedNodeCount(): Promise<number> {
return await this.selectedNodes.count()
}
/**
* Get all Vue node IDs currently in the DOM
*/
async getNodeIds(): Promise<string[]> {
return await this.nodes.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute('data-node-id'))
.filter((id): id is string => id !== null)
)
}
/**
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
}
/**
* Select multiple Vue nodes by IDs using Ctrl+click
*/
async selectNodes(nodeIds: string[]): Promise<void> {
if (nodeIds.length === 0) return
// Select first node normally
await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click
for (let i = 1; i < nodeIds.length; i++) {
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
modifiers: ['Control']
})
}
}
/**
* Clear all selections by clicking empty space
*/
async clearSelection(): Promise<void> {
await this.page.mouse.click(50, 50)
}
/**
* Delete selected Vue nodes using Delete key
*/
async deleteSelected(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Delete')
}
/**
* Delete selected Vue nodes using Backspace key
*/
async deleteSelectedWithBackspace(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Backspace')
}
/**
* Wait for Vue nodes to be rendered
*/
async waitForNodes(expectedCount?: number): Promise<void> {
if (expectedCount !== undefined) {
await this.page.waitForFunction(
(count) => document.querySelectorAll('[data-node-id]').length >= count,
expectedCount
)
} else {
await this.page.waitForSelector('[data-node-id]')
}
}
}

View File

@@ -46,7 +46,7 @@ test.describe('Node Help', () => {
// Click the help button in the selection toolbox
const helpButton = comfyPage.selectionToolbox.locator(
'button:has(.pi-question-circle)'
'button[data-testid="info-button"]'
)
await expect(helpButton).toBeVisible()
await helpButton.click()
@@ -164,7 +164,7 @@ test.describe('Node Help', () => {
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -194,7 +194,7 @@ test.describe('Node Help', () => {
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -228,7 +228,7 @@ test.describe('Node Help', () => {
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -276,7 +276,7 @@ test.describe('Node Help', () => {
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -348,7 +348,7 @@ This is documentation for a custom node.
}
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
if (await helpButton.isVisible()) {
await helpButton.click()
@@ -389,7 +389,7 @@ This is documentation for a custom node.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -456,7 +456,7 @@ This is English documentation.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -479,7 +479,7 @@ This is English documentation.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -522,7 +522,7 @@ This is English documentation.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -538,7 +538,7 @@ This is English documentation.
// Click help button again
const helpButton2 = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton2.click()

View File

@@ -190,7 +190,9 @@ test.describe('Remote COMBO Widget', () => {
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator('.selection-toolbox .pi-refresh')
comfyPage.page.locator(
'.selection-toolbox button[data-testid="refresh-button"]'
)
).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,177 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Selection Toolbox - More Options Submenus', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: any) => {
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(500)
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await comfyPage.page.click('[data-testid="more-options-button"]')
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click({ force: true })
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(2000)
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialShape = await nodeRef.getProperty<number>('shape')
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).click()
await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({
timeout: 5000
})
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
const newShape = await nodeRef.getProperty<number>('shape')
expect(newShape).not.toBe(initialShape)
expect(newShape).toBe(1)
})
test('changes node color via Color submenu swatch', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialColor = await nodeRef.getProperty<string | undefined>('color')
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Color', { exact: true }).click()
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
await blueSwatch.first().click()
await comfyPage.nextFrame()
const newColor = await nodeRef.getProperty<string | undefined>('color')
expect(newColor).toBe('#223')
if (initialColor) {
expect(newColor).not.toBe(initialColor)
}
})
test('renames a node using Rename action', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
await expect(input).toBeVisible()
await input.fill('RenamedNode')
await input.press('Enter')
await comfyPage.nextFrame()
const newTitle = await nodeRef.getProperty<string>('title')
expect(newTitle).toBe('RenamedNode')
})
test('closes More Options menu when clicking outside', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
test('closes More Options menu when clicking the button again (toggle)', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await comfyPage.page.evaluate(() => {
const btn = document.querySelector('[data-testid="more-options-button"]')
if (btn) {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
detail: 1
})
btn.dispatchEvent(event)
}
})
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(500)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
})

View File

@@ -0,0 +1,141 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Vue Nodes - Delete Key Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
// Enable Vue nodes rendering
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setup()
})
test('Can select all and delete Vue nodes with Delete key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Select all Vue nodes
await comfyPage.ctrlA()
// Verify all Vue nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialNodeCount)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify all Vue nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(0)
})
test('Can select specific Vue node and delete it', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Get first Vue node ID and select it
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Verify selection
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(1)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify one Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Can select and delete Vue node with Backspace key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Select first Vue node
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Delete with Backspace key instead of Delete
await comfyPage.vueNodes.deleteSelectedWithBackspace()
// Verify Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Delete key does not delete node when typing in Vue node widgets', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
// Find a text input widget in a Vue node
const textWidget = comfyPage.page
.locator('input[type="text"], textarea')
.first()
// Click on text widget to focus it
await textWidget.click()
await textWidget.fill('test text')
// Press Delete while focused on widget - should delete text, not node
await textWidget.press('Delete')
// Node count should remain the same
const finalNodeCount = await comfyPage.getGraphNodesCount()
expect(finalNodeCount).toBe(initialNodeCount)
})
test('Delete key does not delete node when nothing is selected', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Ensure no Vue nodes are selected
await comfyPage.vueNodes.clearSelection()
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(0)
// Press Delete key - should not crash and should handle gracefully
await comfyPage.page.keyboard.press('Delete')
// Vue node count should remain the same
const nodeCount = await comfyPage.vueNodes.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
})
test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Multi-select first two Vue nodes using Ctrl+click
const nodeIds = await comfyPage.vueNodes.getNodeIds()
const nodesToSelect = nodeIds.slice(0, 2)
await comfyPage.vueNodes.selectNodes(nodesToSelect)
// Verify expected nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(nodesToSelect.length)
// Delete selected Vue nodes
await comfyPage.vueNodes.deleteSelected()
// Verify expected nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length)
})
})

View File

@@ -264,7 +264,13 @@ test.describe('Animated image widget', () => {
expect(filename).toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
// FIXME: This test keeps flip-flopping because it relies on animated webp timing,
// which is inherently unreliable in CI environments. The test asset is an animated
// webp with 2 frames, and the test depends on animation frame timing to verify that
// animated webp images are properly displayed (as opposed to being treated as static webp).
// While the underlying functionality works (animated webp are correctly distinguished
// from static webp), the test is flaky due to timing dependencies with webp animation frames.
test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

View File

@@ -114,7 +114,7 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.69",
"@comfyorg/comfyui-electron-types": "^0.4.72",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",

18
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: ^0.4.69
version: 0.4.69
specifier: ^0.4.72
version: 0.4.72
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
@@ -1038,8 +1038,8 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@comfyorg/comfyui-electron-types@0.4.69':
resolution: {integrity: sha512-emEapJvbbx8lXiJ/84gmk+fYU73MmqkQKgBDQkyDwctcOb+eNe347PaH/+0AIjX8A/DtFHfnwgh9J8k3RVdqZA==}
'@comfyorg/comfyui-electron-types@0.4.72':
resolution: {integrity: sha512-Ecf0XYOKDqqIcnjSWL8GHLo6MOsuwqs0I1QgWc3Hv+BZm+qUE4vzOXCyhfFoTIGHLZFTwe37gnygPPKFzMu00Q==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -6741,8 +6741,8 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.0.6:
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -7908,7 +7908,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@comfyorg/comfyui-electron-types@0.4.69': {}
'@comfyorg/comfyui-electron-types@0.4.72': {}
'@csstools/color-helpers@5.1.0': {}
@@ -9331,7 +9331,7 @@ snapshots:
storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.0.6
vue-component-type-helpers: 3.0.7
'@swc/helpers@0.5.17':
dependencies:
@@ -14598,7 +14598,7 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.0.6: {}
vue-component-type-helpers@3.0.7: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -961,9 +961,7 @@ audio.comfy-audio.empty-audio-widget {
/* Uses default styling - no overrides needed */
}
/* Smooth transitions between LOD levels */
.lg-node {
transition: min-height 0.2s ease;
/* Disable text selection on all nodes */
user-select: none;
-webkit-user-select: none;

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" class="" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_704_2695)">
<path d="M6.05048 2C5.52055 7.29512 9.23033 10.4722 14 9.94267" stroke="#9C9EAB" stroke-width="1.3"/>
<path d="M6.5 5.5L10 2" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
<path d="M8 8L12.5 3.5" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="square"/>
<path d="M10.5 9.5L14 6" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
<path d="M7.99992 14.6667C11.6818 14.6667 14.6666 11.6819 14.6666 8.00004C14.6666 4.31814 11.6818 1.33337 7.99992 1.33337C4.31802 1.33337 1.33325 4.31814 1.33325 8.00004C1.33325 11.6819 4.31802 14.6667 7.99992 14.6667Z" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_704_2695">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 938 B

View File

@@ -0,0 +1,41 @@
/**
* Utility functions for downloading files
*/
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
/**
* Download a file from a URL by creating a temporary anchor element
* @param url - The URL of the file to download (must be a valid URL string)
* @param filename - Optional filename override (will use URL filename or default if not provided)
* @throws {Error} If the URL is invalid or empty
*/
export const downloadFile = (url: string, filename?: string): void => {
if (!url || typeof url !== 'string' || url.trim().length === 0) {
throw new Error('Invalid URL provided for download')
}
const link = document.createElement('a')
link.href = url
link.download =
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
// Trigger download
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
* Extract filename from a URL's query parameters
* @param url - The URL to extract filename from
* @returns The extracted filename or null if not found
*/
const extractFilenameFromUrl = (url: string): string | null => {
try {
const urlObj = new URL(url, window.location.origin)
return urlObj.searchParams.get('filename')
} catch {
return null
}
}

View File

@@ -38,7 +38,7 @@ import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWorkflowStore } from '@/stores/workflowStore'

View File

@@ -40,6 +40,7 @@ import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue
import CustomFormValue from '@/components/common/CustomFormValue.vue'
import FormColorPicker from '@/components/common/FormColorPicker.vue'
import FormImageUpload from '@/components/common/FormImageUpload.vue'
import FormRadioGroup from '@/components/common/FormRadioGroup.vue'
import InputKnob from '@/components/common/InputKnob.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import UrlInput from '@/components/common/UrlInput.vue'
@@ -66,6 +67,7 @@ function getFormAttrs(item: FormItem) {
}
switch (item.type) {
case 'combo':
case 'radio':
attrs['options'] =
typeof item.options === 'function'
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
@@ -97,6 +99,8 @@ function getFormComponent(item: FormItem): Component {
return InputKnob
case 'combo':
return Select
case 'radio':
return FormRadioGroup
case 'image':
return FormImageUpload
case 'color':

View File

@@ -0,0 +1,245 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import RadioButton from 'primevue/radiobutton'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import type { SettingOption } from '@/types/settingTypes'
import FormRadioGroup from './FormRadioGroup.vue'
describe('FormRadioGroup', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props: any, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
modelValue: 'md',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
modelValue: 'Option A',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: 1 },
{ name: 'Second Option', id: 2 },
{ name: 'Third Option', id: 3 }
]
const wrapper = mountComponent({
modelValue: 2,
options,
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe(1)
expect(radioButtons[1].props('value')).toBe(2)
expect(radioButtons[2].props('value')).toBe(3)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
modelValue: 'complex',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
})
it('handles empty options array', () => {
const wrapper = mountComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [
{ label: 'Option 1', val: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(2)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
expect(labels[1].text()).toBe('Option 2')
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
modelValue: 'A',
options,
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
modelValue: 'Yes',
options,
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
'Option 2-label'
)
})
})
})

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex flex-row gap-4">
<div
v-for="option in normalizedOptions"
:key="option.value"
class="flex items-center"
>
<RadioButton
:input-id="`${id}-${option.value}`"
:name="id"
:value="option.value"
:model-value="modelValue"
:aria-describedby="`${option.text}-label`"
@update:model-value="$emit('update:modelValue', $event)"
/>
<label :for="`${id}-${option.value}`" class="ml-2 cursor-pointer">
{{ option.text }}
</label>
</div>
</div>
</template>
<script setup lang="ts">
import RadioButton from 'primevue/radiobutton'
import { computed } from 'vue'
import type { SettingOption } from '@/types/settingTypes'
const props = defineProps<{
modelValue: any
options: (SettingOption | string)[]
optionLabel?: string
optionValue?: string
id?: string
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const normalizedOptions = computed<SettingOption[]>(() => {
if (!props.options) return []
return props.options.map((option) => {
if (typeof option === 'string') {
return { text: option, value: option }
}
if ('text' in option) {
return {
text: option.text,
value: option.value ?? option.text
}
}
// Handle optionLabel/optionValue
return {
text: option[props.optionLabel || 'text'] || 'Unknown',
value: option[props.optionValue || 'value']
}
})
})
</script>

View File

@@ -113,7 +113,12 @@ const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
.sort((a, b) => a.label.localeCompare(b.label))
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group)
settings: flattenTree<SettingParams>(group).sort((a, b) => {
const sortOrderA = a.sortOrder ?? 0
const sortOrderB = b.sortOrder ?? 0
return sortOrderB - sortOrderA
})
}))
}

View File

@@ -16,8 +16,8 @@ import { computed } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()

View File

@@ -40,13 +40,12 @@
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
v-for="nodeData in nodesToRender"
v-for="nodeData in allNodes"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
@@ -113,11 +112,13 @@ import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -127,7 +128,6 @@ import { newUserService } from '@/services/newUserService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -183,12 +183,12 @@ const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const nodesToRender = viewportCulling.nodesToRender
const allNodes = viewportCulling.allNodes
const handleTransformUpdate = () => {
viewportCulling.handleTransformUpdate(
vueNodeLifecycle.detectChangesInRAF.value
)
viewportCulling.handleTransformUpdate()
// TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value()
}
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
@@ -205,6 +205,9 @@ const selectedNodeIds = computed(
)
provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes
useExecutionStateProvider()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})

View File

@@ -127,9 +127,9 @@ import { useI18n } from 'vue-i18n'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useZoomControls } from '@/composables/useZoomControls'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -0,0 +1,500 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExtensionService } from '@/services/extensionService'
// Mock the composables and services
vi.mock('@/composables/graph/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
handleWheel: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useSelectionToolboxPosition', () => ({
useSelectionToolboxPosition: vi.fn(() => ({
visible: { value: true }
})),
resetMoreOptionsState: vi.fn()
}))
vi.mock('@/composables/element/useRetriggerableAnimation', () => ({
useRetriggerableAnimation: vi.fn(() => ({
shouldAnimate: { value: false }
}))
}))
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
useMinimap: vi.fn(() => ({
containerStyles: {
value: {
backgroundColor: '#ffffff'
}
}
}))
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: vi.fn(() => ({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
}))
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true),
isImageNode: vi.fn(() => false),
isLoad3dNode: vi.fn(() => false)
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
isOutputNode: vi.fn(() => false),
filterOutputNodes: vi.fn((nodes) => nodes.filter(() => false))
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Load3D.3DViewerEnable') return true
return null
})
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
getCommand: vi.fn(() => ({ id: 'test-command', title: 'Test Command' }))
})
}))
let nodeDefMock = {
type: 'TestNode',
title: 'Test Node'
} as unknown
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
fromLGraphNode: vi.fn(() => nodeDefMock)
})
}))
describe('SelectionToolbox', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
info: 'Node Info',
bookmark: 'Save to Library',
frameNodes: 'Frame Nodes',
moreOptions: 'More Options',
refreshNode: 'Refresh Node'
}
}
}
})
const mockProvide = {
isVisible: { value: true },
selectedItems: []
}
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
// Mock the canvas to avoid "getCanvas: canvas is null" errors
canvasStore.canvas = {
setDirty: vi.fn(),
state: {
selectionChanged: false
}
} as any
vi.resetAllMocks()
})
const mountComponent = (props = {}) => {
return mount(SelectionToolbox, {
props,
global: {
plugins: [i18n, PrimeVue],
provide: {
[Symbol.for('SelectionOverlay')]: mockProvide
},
stubs: {
Panel: {
template:
'<div class="panel selection-toolbox absolute left-1/2 rounded-lg"><slot /></div>',
props: ['pt', 'style', 'class']
},
InfoButton: { template: '<div class="info-button" />' },
ColorPickerButton: {
template:
'<button data-testid="color-picker-button" class="color-picker-button" />'
},
FrameNodes: { template: '<div class="frame-nodes" />' },
PublishButton: {
template:
'<button data-testid="add-to-library" class="bookmark-button" />'
},
BypassButton: {
template:
'<button data-testid="bypass-button" class="bypass-button" />'
},
PinButton: { template: '<div class="pin-button" />' },
Load3DViewerButton: {
template: '<div class="load-3d-viewer-button" />'
},
MaskEditorButton: { template: '<div class="mask-editor-button" />' },
DeleteButton: {
template:
'<button data-testid="delete-button" class="delete-button" />'
},
RefreshSelectionButton: {
template: '<div class="refresh-button" />'
},
ExecuteButton: { template: '<div class="execute-button" />' },
ConvertToSubgraphButton: {
template:
'<button data-testid="convert-to-subgraph-button" class="convert-to-subgraph-button" />'
},
ExtensionCommandButton: {
template: '<div class="extension-command-button" />'
},
MoreOptions: {
template:
'<button data-testid="more-options-button" class="more-options" />'
},
VerticalDivider: { template: '<div class="vertical-divider" />' }
}
}
})
}
describe('Button Visibility Logic', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
})
it('should show info button only for single selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.info-button').exists()).toBe(false)
})
it('should not show info button when node definition is not found', () => {
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
// mock nodedef and return null
nodeDefMock = null
// remount component
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(false)
})
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
true
)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(
wrapper2.find('[data-testid="color-picker-button"]').exists()
).toBe(true)
})
it('should show frame nodes only for multiple selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
})
it('should show bypass button for appropriate selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
})
it('should show common buttons for all selections', () => {
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
expect(
wrapper.find('[data-testid="convert-to-subgraph-button"]').exists()
).toBe(true)
expect(wrapper.find('[data-testid="more-options-button"]').exists()).toBe(
true
)
})
it('should show mask editor only for single image nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
// Single image node
isImageNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [{ type: 'ImageNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
// Single non-image node
isImageNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})
it('should show Color picker button only for single Load3D nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [{ type: 'Load3DNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
// Single non-Load3D node
isLoad3dNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})
it('should show ExecuteButton only when output nodes are selected', async () => {
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(
mockNodeFilterUtil,
'filterOutputNodes'
)
// With output node selected
isOutputNodeSpy.mockReturnValue(true)
filterOutputNodesSpy.mockReturnValue([{ type: 'SaveImage' }] as any)
canvasStore.selectedItems = [
{ type: 'SaveImage', constructor: { nodeData: { output_node: true } } }
] as any
const wrapper = mountComponent()
expect(wrapper.find('.execute-button').exists()).toBe(true)
// Without output node selected
isOutputNodeSpy.mockReturnValue(false)
filterOutputNodesSpy.mockReturnValue([])
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.execute-button').exists()).toBe(false)
// No selection at all
canvasStore.selectedItems = []
wrapper2.unmount()
const wrapper3 = mountComponent()
expect(wrapper3.find('.execute-button').exists()).toBe(false)
})
})
describe('Divider Visibility Logic', () => {
it('should show dividers between button groups when both groups have buttons', () => {
// Setup single node to show info + other buttons
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const dividers = wrapper.findAll('.vertical-divider')
expect(dividers.length).toBeGreaterThan(0)
})
it('should not show dividers when adjacent groups are empty', () => {
// No selection should show minimal buttons and dividers
canvasStore.selectedItems = []
const wrapper = mountComponent()
const buttons = wrapper.find('.panel').element.children
expect(buttons.length).toBeGreaterThan(0) // At least MoreOptions should show
})
})
describe('Extension Commands', () => {
it('should render extension command buttons when available', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: {
value: new Map([
['test-command', { id: 'test-command', title: 'Test Command' }]
])
},
invokeExtensions: vi.fn(() => ['test-command'])
} as any)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
})
it('should not render extension commands when none available', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
})
})
describe('Container Styling', () => {
it('should apply minimap container styles', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.classes()).toContain('selection-toolbox')
expect(panel.classes()).toContain('absolute')
expect(panel.classes()).toContain('left-1/2')
expect(panel.classes()).toContain('rounded-lg')
})
it('should handle animation class conditionally', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.exists()).toBe(true)
})
})
describe('Event Handling', () => {
it('should handle wheel events', async () => {
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
const handleWheelSpy = vi.fn()
mockCanvasInteractions.mockReturnValue({
handleWheel: handleWheelSpy
} as any)
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
await panel.trigger('wheel')
expect(handleWheelSpy).toHaveBeenCalled()
})
})
describe('No Selection State', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
})
it('should still show MoreOptions when no items selected', () => {
canvasStore.selectedItems = []
const wrapper = mountComponent()
expect(wrapper.find('.more-options').exists()).toBe(true)
})
it('should hide most buttons when no items selected', () => {
canvasStore.selectedItems = []
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(false)
expect(wrapper.find('.color-picker-button').exists()).toBe(false)
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
expect(wrapper.find('.bookmark-button').exists()).toBe(false)
})
})
})

View File

@@ -8,28 +8,37 @@
<Panel
v-if="visible"
class="rounded-lg selection-toolbox pointer-events-auto"
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
}"
@wheel="canvasInteractions.handleWheel"
>
<ExecuteButton />
<ColorPickerButton />
<BypassButton />
<PinButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<PublishSubgraphButton />
<DeleteButton />
<RefreshSelectionButton />
<DeleteButton v-if="showDelete" />
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
<InfoButton v-if="showInfoButton" />
<ColorPickerButton v-if="showColorPicker" />
<FrameNodes v-if="showFrameNodes" />
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
<PublishSubgraphButton v-if="showPublishSubgraph" />
<MaskEditorButton v-if="showMaskEditor" />
<VerticalDivider
v-if="showAnyPrimaryActions && showAnyControlActions"
/>
<BypassButton v-if="showBypass" />
<RefreshSelectionButton v-if="showRefresh" />
<Load3DViewerButton v-if="showLoad3DViewer" />
<ExtensionCommandButton
v-for="command in extensionToolboxCommands"
:key="command.id"
:command="command"
/>
<HelpButton />
<ExecuteButton v-if="showExecute" />
<MoreOptions />
</Panel>
</Transition>
</div>
@@ -45,22 +54,29 @@ import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/Convert
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewerButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import MoreOptions from './selectionToolbox/MoreOptions.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const minimap = useMinimap()
const containerStyles = minimap.containerStyles
const toolboxRef = ref<HTMLElement | undefined>()
const { visible } = useSelectionToolboxPosition(toolboxRef)
@@ -80,6 +96,44 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
.map((commandId) => commandStore.getCommand(commandId))
.filter((command): command is ComfyCommandImpl => command !== undefined)
})
const {
hasAnySelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
isSingleImageNode,
hasAny3DNodeSelected,
hasOutputNodesSelected,
nodeDef
} = useSelectionState()
const showInfoButton = computed(() => !!nodeDef.value)
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)
const showFrameNodes = computed(() => hasMultipleSelection.value)
const showPublishSubgraph = computed(() => isSingleSubgraph.value)
const showBypass = computed(
() =>
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
)
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
const showMaskEditor = computed(() => isSingleImageNode.value)
const showDelete = computed(() => hasAnySelection.value)
const showRefresh = computed(() => hasAnySelection.value)
const showExecute = computed(() => hasOutputNodesSelected.value)
const showAnyPrimaryActions = computed(
() =>
showColorPicker.value ||
showConvertToSubgraph.value ||
showFrameNodes.value ||
showPublishSubgraph.value
)
const showAnyControlActions = computed(() => showBypass.value)
</script>
<style scoped>

View File

@@ -24,8 +24,11 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
import {
useCanvasStore,
useTitleEditorStore
} from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const settingStore = useSettingStore()

View File

@@ -139,9 +139,9 @@ import { Button, InputNumber, type InputNumberInputEvent } from 'primevue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const { t } = useI18n()

View File

@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node',
mode: LGraphEventMode.ALWAYS
}
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
}))
describe('BypassButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let commandStore: ReturnType<typeof useCommandStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
selectionToolbox: {
bypassButton: {
tooltip: 'Toggle bypass mode'
}
}
}
}
})
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
commandStore = useCommandStore()
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(BypassButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:ban': true
}
}
})
}
it('should render bypass button', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
it('should have correct test id', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="bypass-button"]')
expect(button.exists()).toBe(true)
})
it('should execute bypass command when clicked', async () => {
canvasStore.selectedItems = [mockLGraphNode] as any
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
await wrapper.find('button').trigger('click')
expect(executeSpy).toHaveBeenCalledWith(
'Comfy.Canvas.ToggleSelectedNodes.Bypass'
)
})
it('should show normal styling when node is not bypassed', () => {
const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS }
canvasStore.selectedItems = [normalNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).not.toContain(
'dark-theme:[&:not(:active)]:!bg-[#262729]'
)
})
it('should show bypassed styling when node is bypassed', async () => {
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
canvasStore.selectedItems = [bypassedNode] as any
vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
// Click to trigger the reactivity update
await wrapper.find('button').trigger('click')
await wrapper.vm.$nextTick()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
it('should handle multiple selected items', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})

View File

@@ -1,6 +1,5 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
@@ -8,12 +7,11 @@
severity="secondary"
text
data-testid="bypass-button"
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
class="hover:dark-theme:bg-charcoal-300 hover:bg-[#E7E6E6]"
@click="toggleBypass"
>
<template #icon>
<i-game-icons:detour />
<i-lucide:ban class="w-4 h-4" />
</template>
</Button>
</template>
@@ -23,9 +21,11 @@ import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const toggleBypass = async () => {
await commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
}
</script>

View File

@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/stores/workflowStore'
// Mock the litegraph module
@@ -95,17 +95,6 @@ describe('ColorPickerButton', () => {
expect(wrapper.find('button').exists()).toBe(true)
})
it('should not render when nothing is selected', () => {
// Keep selectedItems empty
canvasStore.selectedItems = []
const wrapper = createWrapper()
// The button exists but is hidden with v-show
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('button').attributes('style')).toContain(
'display: none'
)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()

View File

@@ -1,21 +1,25 @@
<template>
<div class="relative">
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
v-tooltip.top="{
value: localizedCurrentColorName ?? t('color.noColor'),
showDelay: 512
showDelay: 1000
}"
data-testid="color-picker-button"
severity="secondary"
text
@click="() => (showColorPicker = !showColorPicker)"
>
<template #icon>
<div class="flex items-center gap-1">
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
<i class="pi pi-chevron-down" :style="{ fontSize: '0.5rem' }" />
</div>
</template>
<div class="flex items-center gap-1 px-0">
<i
class="w-4 h-4 pi pi-circle-fill"
:style="{ color: currentColor ?? '' }"
/>
<i
class="w-4 h-4 pi pi-chevron-down py-1"
:style="{ fontSize: '0.5rem' }"
/>
</div>
</Button>
<div
v-if="showColorPicker"
@@ -46,16 +50,19 @@
<script setup lang="ts">
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { Raw, computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ColorOption as CanvasColorOption } from '@/lib/litegraph/src/litegraph'
import type {
ColorOption as CanvasColorOption,
Positionable
} from '@/lib/litegraph/src/litegraph'
import {
LGraphCanvas,
LiteGraph,
isColorable
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
@@ -140,13 +147,17 @@ const localizedCurrentColorName = computed(() => {
)
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
})
const updateColorSelectionFromNode = (
newSelectedItems: Raw<Positionable[]>
) => {
showColorPicker.value = false
selectedColorOption.value = null
currentColorOption.value = getItemsColorOption(newSelectedItems)
}
watch(
() => canvasStore.selectedItems,
(newSelectedItems) => {
showColorPicker.value = false
selectedColorOption.value = null
currentColorOption.value = getItemsColorOption(newSelectedItems)
updateColorSelectionFromNode(newSelectedItems)
},
{ immediate: true }
)

View File

@@ -6,11 +6,12 @@
showDelay: 1000
}"
severity="secondary"
data-testid="convert-to-subgraph-button"
text
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
>
<template #icon>
<i-lucide:expand />
<i-lucide:expand class="w-4 h-4" />
</template>
</Button>
<Button
@@ -20,6 +21,7 @@
showDelay: 1000
}"
severity="secondary"
data-testid="convert-to-subgraph-button"
text
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
>
@@ -34,25 +36,15 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { isSingleSubgraph, hasAnySelection } = useSelectionState()
const isUnpackVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
const isConvertVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||
canvasStore.nodeSelected
)
})
const isUnpackVisible = isSingleSubgraph
const isConvertVisible = computed(
() => hasAnySelection.value && !isSingleSubgraph.value
)
</script>

View File

@@ -5,9 +5,11 @@
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="danger"
severity="secondary"
text
icon-class="w-4 h-4"
icon="pi pi-trash"
data-testid="delete-button"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
</template>
@@ -17,14 +19,15 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { Positionable } from '@/lib/litegraph/src/interfaces'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { selectedItems } = useSelectionState()
const isDeletable = computed(() =>
canvasStore.selectedItems.some((x) => x.removable !== false)
selectedItems.value.some((x: Positionable) => x.removable !== false)
)
</script>

View File

@@ -0,0 +1,128 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
// Mock the stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn()
}))
// Mock the utils
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn((node) => !!node?.type)
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
isOutputNode: vi.fn((node) => !!node?.constructor?.nodeData?.output_node)
}))
// Mock the composables
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: vi.fn(() => ({
selectedNodes: {
value: []
}
}))
}))
describe('ExecuteButton', () => {
let mockCanvas: any
let mockCanvasStore: any
let mockCommandStore: any
let mockSelectedNodes: any[]
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
selectionToolbox: {
executeButton: {
tooltip: 'Execute selected nodes'
}
}
}
}
})
beforeEach(async () => {
setActivePinia(createPinia())
// Reset mocks
mockCanvas = {
setDirty: vi.fn()
}
mockSelectedNodes = []
mockCanvasStore = {
getCanvas: vi.fn(() => mockCanvas),
selectedItems: []
}
mockCommandStore = {
execute: vi.fn()
}
// Setup store mocks
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}
} as any)
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(ExecuteButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:play': { template: '<div class="play-icon" />' }
}
}
})
}
describe('Rendering', () => {
it('should be able to render', () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})
describe('Click Handler', () => {
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
await button.trigger('click')
expect(mockCommandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'
)
expect(mockCommandStore.execute).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,20 +1,16 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: isDisabled
? t('selectionToolbox.executeButton.disabledTooltip')
: t('selectionToolbox.executeButton.tooltip'),
value: t('selectionToolbox.executeButton.tooltip'),
showDelay: 1000
}"
:severity="isDisabled ? 'secondary' : 'success'"
class="dark-theme:bg-[#0B8CE9] bg-[#31B9F4] size-8 !p-0"
text
:disabled="isDisabled"
@mouseenter="() => handleMouseEnter()"
@mouseleave="() => handleMouseLeave()"
@click="handleClick"
>
<i-lucide:play />
<i-lucide:play class="fill-path-white w-4 h-4" />
</Button>
</template>
@@ -23,26 +19,24 @@ import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isOutputNode } from '@/utils/nodeFilterUtil'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { selectedNodes } = useSelectionState()
const canvas = canvasStore.getCanvas()
const buttonHovered = ref(false)
const selectedOutputNodes = computed(
() =>
canvasStore.selectedItems.filter(
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
) as LGraphNode[]
const selectedOutputNodes = computed(() =>
selectedNodes.value.filter(isLGraphNode).filter(isOutputNode)
)
const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
function outputNodeStokeStyle(this: LGraphNode) {
if (
this.selected &&
@@ -70,3 +64,9 @@ const handleClick = async () => {
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
}
</script>
<style scoped>
:deep.fill-path-white > path {
fill: white;
stroke: unset;
}
</style>

View File

@@ -7,6 +7,7 @@
}"
severity="secondary"
text
icon-class="w-4 h-4"
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
@click="() => commandStore.execute(command.id)"
/>

View File

@@ -0,0 +1,22 @@
<template>
<Button
v-tooltip.top="{
value: $t('g.frameNodes'),
showDelay: 1000
}"
class="frame-nodes-button"
text
severity="secondary"
@click="frameNodes"
>
<i-lucide:frame class="w-4 h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useFrameNodes } from '@/composables/graph/useFrameNodes'
const { frameNodes } = useFrameNodes()
</script>

View File

@@ -1,49 +0,0 @@
<template>
<Button
v-show="nodeDef"
v-tooltip.top="{
value: $t('g.help'),
showDelay: 1000
}"
class="help-button"
text
icon="pi pi-question-circle"
severity="secondary"
@click="showHelp"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useCanvasStore } from '@/stores/graphStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
if (canvasStore.selectedItems.length !== 1) return null
const item = canvasStore.selectedItems[0]
if (!isLGraphNode(item)) return null
return nodeDefStore.fromLGraphNode(item)
})
const showHelp = () => {
const def = nodeDef.value
if (!def) return
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
}
nodeHelpStore.openHelp(def)
}
</script>

View File

@@ -0,0 +1,149 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
// NOTE: The component import must come after mocks so they take effect.
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node'
}
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: () => ({
id: 'node-library'
})
}))
const openHelpMock = vi.fn()
const closeHelpMock = vi.fn()
const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null }
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: () => ({
openHelp: (def: any) => {
nodeHelpState.currentHelpNode = def
openHelpMock(def)
},
closeHelp: () => {
nodeHelpState.currentHelpNode = null
closeHelpMock()
},
get currentHelpNode() {
return nodeHelpState.currentHelpNode
},
get isHelpOpen() {
return nodeHelpState.currentHelpNode !== null
}
})
}))
const toggleSidebarTabMock = vi.fn((id: string) => {
sidebarState.activeSidebarTabId =
sidebarState.activeSidebarTabId === id ? null : id
})
const sidebarState: { activeSidebarTabId: string | null } = {
activeSidebarTabId: 'other-tab'
}
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
get activeSidebarTabId() {
return sidebarState.activeSidebarTabId
},
toggleSidebarTab: toggleSidebarTabMock
})
}))
describe('InfoButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let nodeDefStore: ReturnType<typeof useNodeDefStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
info: 'Node Info'
}
}
}
})
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefStore = useNodeDefStore()
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(InfoButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:info': true,
Button: {
template:
'<button class="help-button" severity="secondary"><slot /></button>',
props: ['severity', 'text', 'class'],
emits: ['click']
}
}
}
})
}
it('should handle click without errors', async () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
await button.trigger('click')
expect(button.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).toContain('help-button')
expect(button.attributes('severity')).toBe('secondary')
})
it('should have correct tooltip', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,22 @@
<template>
<Button
v-tooltip.top="{
value: $t('g.info'),
showDelay: 1000
}"
data-testid="info-button"
text
severity="secondary"
@click="toggleHelp"
>
<i-lucide:info class="w-4 h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useSelectionState } from '@/composables/graph/useSelectionState'
const { showNodeHelp: toggleHelp } = useSelectionState()
</script>

View File

@@ -1,6 +1,5 @@
<template>
<Button
v-show="is3DNode"
v-tooltip.top="{
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
showDelay: 1000
@@ -8,29 +7,18 @@
severity="secondary"
text
icon="pi pi-pencil"
icon-class="w-4 h-4"
@click="open3DViewer"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const is3DNode = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isLoad3dNode) && enable3DViewer
})
const open3DViewer = () => {
void commandStore.execute('Comfy.3DViewer.Open3DViewer')

View File

@@ -7,28 +7,21 @@
}"
severity="secondary"
text
icon="pi pi-pencil"
@click="openMaskEditor"
/>
>
<i-comfy:mask class="!w-4 !h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isSingleImageNode = computed(() => {
const { selectedItems } = canvasStore
const item = selectedItems[0]
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
})
const { isSingleImageNode } = useSelectionState()
const openMaskEditor = () => {
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')

View File

@@ -0,0 +1,59 @@
<template>
<div
v-if="option.type === 'divider'"
class="h-px bg-gray-200 dark-theme:bg-zinc-700 my-1"
/>
<div
v-else
role="button"
class="flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer"
@click="handleClick"
>
<i v-if="option.icon" :class="[option.icon, 'w-4 h-4']" />
<span class="flex-1">{{ option.label }}</span>
<span v-if="option.shortcut" class="text-xs opacity-60">
{{ option.shortcut }}
</span>
<i-lucide:chevron-right
v-if="option.hasSubmenu"
:size="14"
class="opacity-60"
/>
<Badge
v-if="option.badge"
:severity="option.badge === 'new' ? 'info' : 'secondary'"
:value="t(option.badge)"
:class="{
'bg-[#31B9F4] dark-theme:bg-[#0B8CE9] rounded-4xl':
option.badge === 'new',
'bg-[#9C9EAB] dark-theme:bg-[#000] rounded-4xl':
option.badge === 'deprecated',
'text-white uppercase text-[9px] h-4 px-1 gap-2.5': true
}"
/>
</div>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import { useI18n } from 'vue-i18n'
import type { MenuOption } from '@/composables/graph/useMoreOptionsMenu'
const { t } = useI18n()
interface Props {
option: MenuOption
}
interface Emits {
(e: 'click', option: MenuOption, event: Event): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const handleClick = (event: Event) => {
emit('click', props.option, event)
}
</script>

View File

@@ -0,0 +1,316 @@
<template>
<div class="relative inline-flex items-center">
<Button
ref="buttonRef"
v-tooltip.top="{
value: $t('g.moreOptions'),
showDelay: 1000
}"
data-testid="more-options-button"
text
class="h-8 w-8 px-0"
severity="secondary"
@click="toggle"
>
<i-lucide:more-vertical class="w-4 h-4" />
</Button>
<Popover
ref="popover"
:append-to="'body'"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="pt"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<div class="flex flex-col p-2 min-w-48">
<MenuOptionItem
v-for="(option, index) in menuOptions"
:key="option.label || `divider-${index}`"
:option="option"
@click="handleOptionClick"
/>
</div>
</Popover>
<SubmenuPopover
v-for="option in menuOptionsWithSubmenu"
:key="`submenu-${option.label}`"
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
:option="option"
:container-styles="containerStyles"
@submenu-click="handleSubmenuClick"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import {
forceCloseMoreOptionsSignal,
moreOptionsOpen,
moreOptionsRestorePending,
restoreMoreOptionsSignal
} from '@/composables/canvas/useSelectionToolboxPosition'
import {
type MenuOption,
type SubMenuOption,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'
const popover = ref<InstanceType<typeof Popover>>()
const buttonRef = ref<InstanceType<typeof Button> | HTMLElement | null>(null)
// Track open state ourselves so we can restore after drag/move
const isOpen = ref(false)
const wasOpenBeforeHide = ref(false)
// Track why the popover was hidden so we only auto-reopen after drag.
type HideReason = 'manual' | 'drag'
const lastProgrammaticHideReason = ref<HideReason | null>(null)
const submenuRefs = ref<Record<string, InstanceType<typeof SubmenuPopover>>>({})
const currentSubmenu = ref<string | null>(null)
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
const minimap = useMinimap()
const containerStyles = minimap.containerStyles
function getButtonEl(): HTMLElement | null {
const el = (buttonRef.value as any)?.$el || buttonRef.value
return el instanceof HTMLElement ? el : null
}
let lastLogTs = 0
const LOG_INTERVAL = 120 // ms
let overlayElCache: HTMLElement | null = null
function resolveOverlayEl(): HTMLElement | null {
// Prefer cached element (cleared on hide)
if (overlayElCache && overlayElCache.isConnected) return overlayElCache
// PrimeVue Popover root element (component instance $el)
const direct = (popover.value as any)?.$el
if (direct instanceof HTMLElement) {
overlayElCache = direct
return direct
}
// Fallback: try to locate a recent popover root near the button (same z-index class + absolute)
const btn = getButtonEl()
if (btn) {
const candidates = Array.from(
document.querySelectorAll('div.absolute.z-50')
) as HTMLElement[]
// Heuristic: pick the one closest (vertically) below the button
const rect = btn.getBoundingClientRect()
let best: { el: HTMLElement; dist: number } | null = null
for (const el of candidates) {
const r = el.getBoundingClientRect()
const dist = Math.abs(r.top - rect.bottom)
if (!best || dist < best.dist) best = { el, dist }
}
if (best && best.el) {
overlayElCache = best.el
return best.el
}
}
return null
}
const repositionPopover = () => {
if (!isOpen.value) return
const btn = getButtonEl()
const overlayEl = resolveOverlayEl()
if (!btn || !overlayEl) return
const rect = btn.getBoundingClientRect()
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
const left = rect.left + rect.width / 2
const top = rect.bottom + marginY
try {
overlayEl.style.position = 'fixed'
overlayEl.style.left = `${left}px`
overlayEl.style.top = `${top}px`
overlayEl.style.transform = 'translate(-50%, 0)'
} catch (e) {
console.warn('[MoreOptions] Failed to set overlay style', e)
return
}
const now = performance.now()
if (now - lastLogTs > LOG_INTERVAL) {
lastLogTs = now
}
}
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, {
autoStart: false
})
function openPopover(triggerEvent?: Event): boolean {
const el = getButtonEl()
if (!el || !el.isConnected) return false
bump()
popover.value?.show(triggerEvent ?? new Event('reopen'), el)
isOpen.value = true
moreOptionsOpen.value = true
moreOptionsRestorePending.value = false
return true
}
function closePopover(reason: HideReason = 'manual') {
lastProgrammaticHideReason.value = reason
popover.value?.hide()
isOpen.value = false
moreOptionsOpen.value = false
stopSync()
hideAll()
if (reason !== 'drag') {
wasOpenBeforeHide.value = false
// Natural hide: cancel any pending restore
moreOptionsRestorePending.value = false
} else {
if (!moreOptionsRestorePending.value) {
wasOpenBeforeHide.value = true
moreOptionsRestorePending.value = true
}
}
}
let restoreAttempts = 0
function attemptRestore() {
if (isOpen.value) return
if (!wasOpenBeforeHide.value && !moreOptionsRestorePending.value) return
// Try immediately
if (openPopover(new Event('reopen'))) {
wasOpenBeforeHide.value = false
restoreAttempts = 0
return
}
// Defer with limited retries (layout / mount race)
if (restoreAttempts >= 5) return
restoreAttempts++
requestAnimationFrame(() => attemptRestore())
}
const toggle = (event: Event) => {
if (isOpen.value) closePopover('manual')
else openPopover(event)
}
const hide = (reason: HideReason = 'manual') => closePopover(reason)
const hideAll = () => {
hideAllSubmenus(
menuOptionsWithSubmenu.value,
submenuRefs.value,
currentSubmenu
)
}
const handleOptionClick = (option: MenuOption, event: Event) => {
if (!option.hasSubmenu && option.action) {
option.action()
hide()
} else if (option.hasSubmenu) {
event.stopPropagation()
const submenuKey = `submenu-${option.label}`
const submenu = submenuRefs.value[submenuKey]
if (submenu) {
void toggleSubmenu(
option,
event,
submenu,
currentSubmenu,
menuOptionsWithSubmenu.value,
submenuRefs.value
)
}
}
}
const handleSubmenuClick = (subOption: SubMenuOption) => {
subOption.action()
hide('manual')
}
const setSubmenuRef = (key: string, el: any) => {
if (el) {
submenuRefs.value[key] = el
} else {
delete submenuRefs.value[key]
}
}
const pt = computed(() => ({
root: {
class: 'absolute z-50 w-[300px] px-[12]'
},
content: {
class: [
'mt-2 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: containerStyles.value.backgroundColor
}
}
}))
// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
const onPopoverShow = () => {
overlayElCache = resolveOverlayEl()
// Delay first reposition slightly to ensure DOM fully painted
requestAnimationFrame(() => repositionPopover())
startSync()
}
const onPopoverHide = () => {
if (lastProgrammaticHideReason.value == null) {
isOpen.value = false
hideAll()
wasOpenBeforeHide.value = false
moreOptionsOpen.value = false
moreOptionsRestorePending.value = false
}
overlayElCache = null
stopSync()
lastProgrammaticHideReason.value = null
}
// Watch for forced close (drag start)
watch(
() => forceCloseMoreOptionsSignal.value,
() => {
if (isOpen.value) hide('drag')
else
wasOpenBeforeHide.value =
wasOpenBeforeHide.value || moreOptionsRestorePending.value
}
)
watch(
() => restoreMoreOptionsSignal.value,
() => attemptRestore()
)
onMounted(() => {
if (moreOptionsRestorePending.value && !isOpen.value) {
requestAnimationFrame(() => attemptRestore())
}
})
onUnmounted(() => {
stopSync()
})
</script>

View File

@@ -1,25 +0,0 @@
<template>
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-thumbtack"
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
</script>

View File

@@ -1,17 +1,22 @@
<template>
<Button
v-show="isRefreshable"
severity="info"
v-tooltip.top="t('g.refreshNode')"
severity="secondary"
text
icon="pi pi-refresh"
data-testid="refresh-button"
@click="refreshSelected"
/>
>
<i-lucide:refresh-cw class="w-4 h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
const { t } = useI18n()
const { isRefreshable, refreshSelected } = useRefreshableSelection()
</script>

View File

@@ -21,8 +21,8 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()

View File

@@ -0,0 +1,127 @@
<template>
<Popover
ref="popover"
:auto-z-index="true"
:base-z-index="1100"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="submenuPt"
>
<div
:class="
isColorSubmenu
? 'flex flex-col gap-1 p-2'
: 'flex flex-col p-2 min-w-40'
"
>
<div
v-for="subOption in option.submenu"
:key="subOption.label"
:class="
isColorSubmenu
? 'w-7 h-7 flex items-center justify-center hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
: 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
"
:title="subOption.label"
@click="handleSubmenuClick(subOption)"
>
<div
v-if="subOption.color"
class="w-5 h-5 rounded-full border border-gray-300 dark-theme:border-zinc-600"
:style="{ backgroundColor: subOption.color }"
/>
<template v-else-if="!subOption.color">
<i-lucide:check
v-if="isShapeSelected(subOption)"
class="w-4 h-4 flex-shrink-0"
/>
<div v-else class="w-4 flex-shrink-0" />
<span>{{ subOption.label }}</span>
</template>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import {
type MenuOption,
type SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
interface Props {
option: MenuOption
containerStyles: {
width: string
height: string
backgroundColor: string
border: string
borderRadius: string
}
}
interface Emits {
(e: 'submenu-click', subOption: SubMenuOption): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { getCurrentShape } = useNodeCustomization()
const popover = ref<InstanceType<typeof Popover>>()
const show = (event: Event, target?: HTMLElement) => {
popover.value?.show(event, target)
}
const hide = () => {
popover.value?.hide()
}
defineExpose({
show,
hide
})
const handleSubmenuClick = (subOption: SubMenuOption) => {
emit('submenu-click', subOption)
}
const isShapeSelected = (subOption: SubMenuOption): boolean => {
if (subOption.color) return false
const currentShape = getCurrentShape()
if (!currentShape) return false
return currentShape.localizedName === subOption.label
}
const isColorSubmenu = computed(() => {
return (
props.option.submenu &&
props.option.submenu.length > 0 &&
props.option.submenu.every((item) => item.color && !item.icon)
)
})
const submenuPt = computed(() => ({
root: {
class: 'absolute z-[60]'
},
content: {
class: [
'text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: props.containerStyles.backgroundColor
}
}
}))
</script>

View File

@@ -0,0 +1,3 @@
<template>
<div class="h-6 w-px bg-gray-300/10 dark-theme:bg-gray-600/10 self-center" />
</template>

View File

@@ -30,9 +30,9 @@ import {
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { type DomWidgetState } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
const { widgetState } = defineProps<{

View File

@@ -45,8 +45,8 @@ import {
type LiteGraphCanvasEvent
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'

View File

@@ -1,7 +1,7 @@
import { onUnmounted, ref } from 'vue'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
interface CanvasTransformSyncOptions {
/**

View File

@@ -4,8 +4,8 @@ import {
type Positionable,
Reroute
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import {
collectFromNodes,
traverseNodesDepthFirst

View File

@@ -1,4 +1,4 @@
import { ref, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
@@ -6,14 +6,44 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { computeUnionBounds } from '@/utils/mathUtil'
/**
* Manages the position of the selection toolbox independently.
* Uses CSS custom properties for performant transform updates.
*/
// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore
export const moreOptionsOpen = ref(false)
export const forceCloseMoreOptionsSignal = ref(0)
export const restoreMoreOptionsSignal = ref(0)
export const moreOptionsRestorePending = ref(false)
let moreOptionsWasOpenBeforeDrag = false
let moreOptionsSelectionSignature: string | null = null
function buildSelectionSignature(
store: ReturnType<typeof useCanvasStore>
): string | null {
const c = store.canvas
if (!c) return null
const items = Array.from(c.selectedItems)
if (items.length !== 1) return null
const item = items[0]
if (isLGraphNode(item)) return `N:${item.id}`
if (isLGraphGroup(item)) return `G:${item.id}`
return null
}
function currentSelectionMatchesSignature(
store: ReturnType<typeof useCanvasStore>
) {
if (!moreOptionsSelectionSignature) return false
return buildSelectionSignature(store) === moreOptionsSelectionSignature
}
export function useSelectionToolboxPosition(
toolboxRef: Ref<HTMLElement | undefined>
) {
@@ -105,10 +135,17 @@ export function useSelectionToolboxPosition(
() => canvasStore.getCanvas().state.selectionChanged,
(changed) => {
if (changed) {
if (moreOptionsRestorePending.value || moreOptionsSelectionSignature) {
moreOptionsRestorePending.value = false
moreOptionsWasOpenBeforeDrag = false
if (!moreOptionsOpen.value) {
moreOptionsSelectionSignature = null
} else {
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
}
}
updateSelectionBounds()
canvasStore.getCanvas().state.selectionChanged = false
// Start transform sync if we have selection
if (visible.value) {
startSync()
} else {
@@ -118,24 +155,77 @@ export function useSelectionToolboxPosition(
},
{ immediate: true }
)
watch(
() => moreOptionsOpen.value,
(v) => {
if (v) {
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
} else if (!canvasStore.canvas?.state?.draggingItems) {
moreOptionsSelectionSignature = null
if (moreOptionsRestorePending.value)
moreOptionsRestorePending.value = false
}
}
)
// Watch for dragging state
watch(
() => canvasStore.canvas?.state?.draggingItems,
(dragging) => {
if (dragging) {
// Hide during node dragging
visible.value = false
if (moreOptionsOpen.value) {
const currentSig = buildSelectionSignature(canvasStore)
if (currentSig !== moreOptionsSelectionSignature) {
moreOptionsSelectionSignature = null
}
moreOptionsWasOpenBeforeDrag = true
moreOptionsOpen.value = false
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
if (moreOptionsRestorePending.value) {
forceCloseMoreOptionsSignal.value++
} else {
moreOptionsWasOpenBeforeDrag = false
}
} else {
moreOptionsRestorePending.value = false
moreOptionsWasOpenBeforeDrag = false
}
} else {
// Update after dragging ends
requestAnimationFrame(() => {
updateSelectionBounds()
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
const shouldRestore =
moreOptionsWasOpenBeforeDrag &&
visible.value &&
moreOptionsRestorePending.value &&
selectionMatches
if (shouldRestore) {
restoreMoreOptionsSignal.value++
} else {
moreOptionsRestorePending.value = false
}
moreOptionsWasOpenBeforeDrag = false
})
}
}
)
onUnmounted(() => {
resetMoreOptionsState()
})
return {
visible
}
}
// External cleanup utility to be called when SelectionToolbox component unmounts
function resetMoreOptionsState() {
moreOptionsOpen.value = false
moreOptionsRestorePending.value = false
moreOptionsWasOpenBeforeDrag = false
moreOptionsSelectionSignature = null
}

View File

@@ -2,7 +2,7 @@ import { type CSSProperties, ref, watch } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { Size, Vector2 } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSettingStore } from '@/stores/settingStore'
export interface PositionConfig {

View File

@@ -1,7 +1,7 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
/**

View File

@@ -0,0 +1,22 @@
// call nextTick on all changeTracker
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/stores/workflowStore'
/**
* Composable for refreshing nodes in the graph
* */
export function useCanvasRefresh() {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const refreshCanvas = () => {
canvasStore.canvas?.emitBeforeChange()
canvasStore.canvas?.setDirty(true, true)
canvasStore.canvas?.graph?.afterChange()
canvasStore.canvas?.emitAfterChange()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return {
refreshCanvas
}
}

View File

@@ -0,0 +1,30 @@
import { computed } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { useTitleEditorStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
/**
* Composable encapsulating logic for framing currently selected nodes into a group.
*/
export function useFrameNodes() {
const settingStore = useSettingStore()
const titleEditorStore = useTitleEditorStore()
const { hasMultipleSelection } = useSelectionState()
const canFrame = computed(() => hasMultipleSelection.value)
const frameNodes = () => {
const { canvas } = app
if (!canvas.selectedItems?.size) return
const group = new LGraphGroup()
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
group.resizeTo(canvas.selectedItems, padding)
canvas.graph?.add(group)
titleEditorStore.titleEditorTarget = group
}
return { frameNodes, canFrame }
}

View File

@@ -53,9 +53,11 @@ export interface VueNodeData {
mode: number
selected: boolean
executing: boolean
subgraphId?: string | null
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
hasErrors?: boolean
flags?: {
collapsed?: boolean
}
@@ -166,6 +168,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Extract safe data from LiteGraph node for Vue consumption
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const safeWidgets = node.widgets?.map((widget) => {
try {
@@ -201,13 +208,22 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
}
})
const nodeType =
node.type ||
node.constructor?.comfyClass ||
node.constructor?.title ||
node.constructor?.name ||
'Unknown'
return {
id: String(node.id),
title: node.title || 'Untitled',
type: node.type || 'Unknown',
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
subgraphId,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
@@ -610,7 +626,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract safe data for Vue
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
// Set up reactive tracking state
@@ -655,7 +671,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
initializeVueNodeLayout
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately

View File

@@ -0,0 +1,199 @@
import { useI18n } from 'vue-i18n'
import {
LGraphEventMode,
type LGraphGroup,
type LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useCanvasRefresh } from './useCanvasRefresh'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
/**
* Composable for group-related menu operations
*/
export function useGroupMenuOptions() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const canvasRefresh = useCanvasRefresh()
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
label: 'Fit Group To Nodes',
icon: 'icon-[lucide--move-diagonal-2]',
action: () => {
try {
groupContext.recomputeInsideNodes()
} catch (e) {
console.warn('Failed to recompute group nodes:', e)
return
}
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
groupContext.resizeTo(groupContext.children, padding)
groupContext.graph?.change()
canvasStore.canvas?.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
})
const getGroupShapeOptions = (
groupContext: LGraphGroup,
bump: () => void
): MenuOption => ({
label: t('contextMenu.Shape'),
icon: 'icon-[lucide--box]',
hasSubmenu: true,
submenu: shapeOptions.map((shape) => ({
label: shape.localizedName,
action: () => {
const nodes = (groupContext.nodes || []) as LGraphNode[]
nodes.forEach((node) => (node.shape = shape.value))
canvasRefresh.refreshCanvas()
bump()
}
}))
})
const getGroupColorOptions = (
groupContext: LGraphGroup,
bump: () => void
): MenuOption => ({
label: t('contextMenu.Color'),
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () => {
groupContext.color = isLightTheme.value
? colorOption.value.light
: colorOption.value.dark
canvasRefresh.refreshCanvas()
bump()
}
}))
})
const getGroupModeOptions = (
groupContext: LGraphGroup,
bump: () => void
): MenuOption[] => {
const options: MenuOption[] = []
try {
groupContext.recomputeInsideNodes()
} catch (e) {
console.warn('Failed to recompute group nodes for mode options:', e)
return options
}
const groupNodes = (groupContext.nodes || []) as LGraphNode[]
if (!groupNodes.length) return options
// Check if all nodes have the same mode
let allSame = true
for (let i = 1; i < groupNodes.length; i++) {
if (groupNodes[i].mode !== groupNodes[0].mode) {
allSame = false
break
}
}
const createModeAction = (label: string, mode: LGraphEventMode) => ({
label: t(`selectionToolbox.${label}`),
icon:
mode === LGraphEventMode.BYPASS
? 'icon-[lucide--ban]'
: mode === LGraphEventMode.NEVER
? 'icon-[lucide--zap-off]'
: 'icon-[lucide--play]',
action: () => {
groupNodes.forEach((n) => {
n.mode = mode
})
canvasStore.canvas?.setDirty(true, true)
groupContext.graph?.change()
workflowStore.activeWorkflow?.changeTracker?.checkState()
bump()
}
})
if (allSame) {
const current = groupNodes[0].mode
switch (current) {
case LGraphEventMode.ALWAYS:
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
break
case LGraphEventMode.NEVER:
options.push(
createModeAction(
'Set Group Nodes to Always',
LGraphEventMode.ALWAYS
)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
break
case LGraphEventMode.BYPASS:
options.push(
createModeAction(
'Set Group Nodes to Always',
LGraphEventMode.ALWAYS
)
)
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
break
default:
options.push(
createModeAction(
'Set Group Nodes to Always',
LGraphEventMode.ALWAYS
)
)
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
break
}
} else {
options.push(
createModeAction('Set Group Nodes to Always', LGraphEventMode.ALWAYS)
)
options.push(
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
)
options.push(
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
)
}
return options
}
return {
getFitGroupToNodesOption,
getGroupShapeOptions,
getGroupColorOptions,
getGroupModeOptions
}
}

View File

@@ -0,0 +1,108 @@
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useCommandStore } from '@/stores/commandStore'
import type { MenuOption } from './useMoreOptionsMenu'
/**
* Composable for image-related menu operations
*/
export function useImageMenuOptions() {
const { t } = useI18n()
const openMaskEditor = () => {
const commandStore = useCommandStore()
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
const openImage = (node: any) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
const url = new URL(img.src)
url.searchParams.delete('preview')
window.open(url.toString(), '_blank')
}
const copyImage = async (node: any) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.drawImage(img, 0, 0)
try {
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (!blob) {
console.warn('Failed to create image blob')
return
}
// Check if clipboard API is available
if (!navigator.clipboard?.write) {
console.warn('Clipboard API not available')
return
}
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
])
} catch (error) {
console.error('Failed to copy image to clipboard:', error)
}
}
const saveImage = (node: any) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
try {
const url = new URL(img.src)
url.searchParams.delete('preview')
downloadFile(url.toString())
} catch (error) {
console.error('Failed to save image:', error)
}
}
const getImageMenuOptions = (node: any): MenuOption[] => {
if (!node?.imgs?.length) return []
return [
{
label: t('contextMenu.Open in Mask Editor'),
action: () => openMaskEditor()
},
{
label: t('contextMenu.Open Image'),
icon: 'icon-[lucide--external-link]',
action: () => openImage(node)
},
{
label: t('contextMenu.Copy Image'),
icon: 'icon-[lucide--copy]',
action: () => copyImage(node)
},
{
label: t('contextMenu.Save Image'),
icon: 'icon-[lucide--download]',
action: () => saveImage(node)
}
]
}
return {
getImageMenuOptions
}
}

View File

@@ -0,0 +1,186 @@
import { computed, ref } from 'vue'
import { type LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { useGroupMenuOptions } from './useGroupMenuOptions'
import { useImageMenuOptions } from './useImageMenuOptions'
import { useNodeMenuOptions } from './useNodeMenuOptions'
import { useSelectionMenuOptions } from './useSelectionMenuOptions'
import { useSelectionState } from './useSelectionState'
export interface MenuOption {
label?: string
icon?: string
shortcut?: string
hasSubmenu?: boolean
type?: 'divider'
action?: () => void
submenu?: SubMenuOption[]
badge?: BadgeVariant
}
export interface SubMenuOption {
label: string
icon?: string
action: () => void
color?: string
}
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
}
/**
* Composable for managing the More Options menu configuration
* Refactored to use smaller, focused composables for better maintainability
*/
export function useMoreOptionsMenu() {
const {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
hasSubgraphs: hasSubgraphsComputed,
hasImageNode,
hasOutputNodesSelected,
hasMultipleSelection,
computeSelectionFlags
} = useSelectionState()
const { getImageMenuOptions } = useImageMenuOptions()
const {
getNodeInfoOption,
getAdjustSizeOption,
getNodeVisualOptions,
getPinOption,
getBypassOption,
getRunBranchOption
} = useNodeMenuOptions()
const {
getFitGroupToNodesOption,
getGroupShapeOptions,
getGroupColorOptions,
getGroupModeOptions
} = useGroupMenuOptions()
const {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions,
getDeleteOption,
getAlignmentOptions
} = useSelectionMenuOptions()
const hasSubgraphs = hasSubgraphsComputed
const hasMultipleNodes = hasMultipleSelection
// Internal version to force menu rebuild after state mutations
const optionsVersion = ref(0)
const bump = () => {
optionsVersion.value++
}
const menuOptions = computed((): MenuOption[] => {
// Reference selection flags to ensure re-computation when they change
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
optionsVersion.value
const states = computeSelectionFlags()
// Detect single group selection context (and no nodes explicitly selected)
const selectedGroups = selectedItems.value.filter(
isLGraphGroup
) as LGraphGroup[]
const groupContext: LGraphGroup | null =
selectedGroups.length === 1 && selectedNodes.value.length === 0
? selectedGroups[0]
: null
const hasSubgraphsSelected = hasSubgraphs.value
const options: MenuOption[] = []
// Section 1: Basic selection operations (Rename, Copy, Duplicate)
options.push(...getBasicSelectionOptions())
options.push({ type: 'divider' })
// Section 2: Node Info & Size Adjustment
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
}
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))
} else {
options.push(getAdjustSizeOption())
}
// Section 3: Collapse/Shape/Color
if (groupContext) {
// Group context: Shape, Color, Divider
options.push(getGroupShapeOptions(groupContext, bump))
options.push(getGroupColorOptions(groupContext, bump))
options.push({ type: 'divider' })
} else {
// Node context: Expand/Minimize, Shape, Color, Divider
options.push(...getNodeVisualOptions(states, bump))
options.push({ type: 'divider' })
}
// Section 4: Image operations (if image node)
if (hasImageNode.value && selectedNodes.value.length > 0) {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
}
// Section 5: Subgraph operations
options.push(...getSubgraphOptions(hasSubgraphsSelected))
// Section 6: Multiple nodes operations
if (hasMultipleNodes.value) {
options.push(...getMultipleNodesOptions())
}
// Section 7: Divider
options.push({ type: 'divider' })
// Section 8: Pin/Unpin (non-group only)
if (!groupContext) {
options.push(getPinOption(states, bump))
}
// Section 9: Alignment (if multiple nodes)
if (hasMultipleNodes.value) {
options.push(...getAlignmentOptions())
}
// Section 10: Mode operations
if (groupContext) {
// Group mode operations
options.push(...getGroupModeOptions(groupContext, bump))
} else {
// Bypass option for nodes
options.push(getBypassOption(states, bump))
}
// Section 11: Run Branch (if output nodes)
if (hasOutputNodesSelected.value) {
options.push(getRunBranchOption())
}
// Section 12: Final divider and Delete
options.push({ type: 'divider' })
options.push(getDeleteOption())
return options
})
// Computed property to get only menu items with submenus
const menuOptionsWithSubmenu = computed(() =>
menuOptions.value.filter((option) => option.hasSubmenu && option.submenu)
)
return {
menuOptions,
menuOptionsWithSubmenu,
bump,
hasSubgraphs
}
}

View File

@@ -0,0 +1,106 @@
import { useI18n } from 'vue-i18n'
import type { Direction } from '@/lib/litegraph/src/interfaces'
import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useCanvasRefresh } from './useCanvasRefresh'
interface AlignOption {
name: string
localizedName: string
value: Direction
icon: string
}
interface DistributeOption {
name: string
localizedName: string
value: boolean // true for horizontal, false for vertical
icon: string
}
/**
* Composable for handling node alignment and distribution
*/
export function useNodeArrangement() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const canvasRefresh = useCanvasRefresh()
const alignOptions: AlignOption[] = [
{
name: 'top',
localizedName: t('contextMenu.Top'),
value: 'top',
icon: 'icon-[lucide--align-start-vertical]'
},
{
name: 'bottom',
localizedName: t('contextMenu.Bottom'),
value: 'bottom',
icon: 'icon-[lucide--align-end-vertical]'
},
{
name: 'left',
localizedName: t('contextMenu.Left'),
value: 'left',
icon: 'icon-[lucide--align-start-horizontal]'
},
{
name: 'right',
localizedName: t('contextMenu.Right'),
value: 'right',
icon: 'icon-[lucide--align-end-horizontal]'
}
]
const distributeOptions: DistributeOption[] = [
{
name: 'horizontal',
localizedName: t('contextMenu.Horizontal'),
value: true,
icon: 'icon-[lucide--align-center-horizontal]'
},
{
name: 'vertical',
localizedName: t('contextMenu.Vertical'),
value: false,
icon: 'icon-[lucide--align-center-vertical]'
}
]
const applyAlign = (alignOption: AlignOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
isLGraphNode(item)
)
if (selectedNodes.length === 0) {
return
}
alignNodes(selectedNodes, alignOption.value)
canvasRefresh.refreshCanvas()
}
const applyDistribute = (distributeOption: DistributeOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
isLGraphNode(item)
)
if (selectedNodes.length < 2) {
return
}
distributeNodes(selectedNodes, distributeOption.value)
canvasRefresh.refreshCanvas()
}
return {
alignOptions,
distributeOptions,
applyAlign,
applyDistribute
}
}

View File

@@ -0,0 +1,167 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
LGraphCanvas,
LGraphNode,
LiteGraph,
RenderShape,
isColorable
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useCanvasRefresh } from './useCanvasRefresh'
interface ColorOption {
name: string
localizedName: string
value: {
dark: string
light: string
}
}
interface ShapeOption {
name: string
localizedName: string
value: RenderShape
}
/**
* Composable for handling node color and shape customization
*/
export function useNodeCustomization() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const canvasRefresh = useCanvasRefresh()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const toLightThemeColor = (color: string) =>
adjustColor(color, { lightness: 0.5 })
// Color options
const NO_COLOR_OPTION: ColorOption = {
name: 'noColor',
localizedName: t('color.noColor'),
value: {
dark: LiteGraph.NODE_DEFAULT_BGCOLOR,
light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR)
}
}
const colorOptions: ColorOption[] = [
NO_COLOR_OPTION,
...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({
name,
localizedName: t(`color.${name}`),
value: {
dark: color.bgcolor,
light: toLightThemeColor(color.bgcolor)
}
}))
]
// Shape options
const shapeOptions: ShapeOption[] = [
{
name: 'default',
localizedName: t('shape.default'),
value: RenderShape.ROUND
},
{
name: 'box',
localizedName: t('shape.box'),
value: RenderShape.BOX
},
{
name: 'card',
localizedName: t('shape.CARD'),
value: RenderShape.CARD
}
]
const applyColor = (colorOption: ColorOption | null) => {
const colorName = colorOption?.name ?? NO_COLOR_OPTION.name
const canvasColorOption =
colorName === NO_COLOR_OPTION.name
? null
: LGraphCanvas.node_colors[colorName]
for (const item of canvasStore.selectedItems) {
if (isColorable(item)) {
item.setColorOption(canvasColorOption)
}
}
canvasRefresh.refreshCanvas()
}
const applyShape = (shapeOption: ShapeOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
(item): item is LGraphNode => item instanceof LGraphNode
)
if (selectedNodes.length === 0) {
return
}
selectedNodes.forEach((node) => {
node.shape = shapeOption.value
})
canvasRefresh.refreshCanvas()
}
const getCurrentColor = (): ColorOption | null => {
const selectedItems = Array.from(canvasStore.selectedItems)
if (selectedItems.length === 0) return null
// Get color from first colorable item
const firstColorableItem = selectedItems.find((item) => isColorable(item))
if (!firstColorableItem || !isColorable(firstColorableItem)) return null
// Get the current color option from the colorable item
const currentColorOption = firstColorableItem.getColorOption()
const currentBgColor = currentColorOption?.bgcolor ?? null
// Find matching color option
return (
colorOptions.find(
(option) =>
option.value.dark === currentBgColor ||
option.value.light === currentBgColor
) ?? NO_COLOR_OPTION
)
}
const getCurrentShape = (): ShapeOption | null => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
(item): item is LGraphNode => item instanceof LGraphNode
)
if (selectedNodes.length === 0) return null
const firstNode = selectedNodes[0]
const currentShape = firstNode.shape ?? RenderShape.ROUND
return (
shapeOptions.find((option) => option.value === currentShape) ??
shapeOptions[0]
)
}
return {
colorOptions,
shapeOptions,
applyColor,
applyShape,
getCurrentColor,
getCurrentShape,
isLightTheme
}
}

View File

@@ -0,0 +1,128 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
import { useSelectedNodeActions } from './useSelectedNodeActions'
import type { NodeSelectionState } from './useSelectionState'
/**
* Composable for node-related menu operations
*/
export function useNodeMenuOptions() {
const { t } = useI18n()
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
useNodeCustomization()
const {
adjustNodeSize,
toggleNodeCollapse,
toggleNodePin,
toggleNodeBypass,
runBranch
} = useSelectedNodeActions()
const shapeSubmenu = computed(() =>
shapeOptions.map((shape) => ({
label: shape.localizedName,
action: () => applyShape(shape)
}))
)
const colorSubmenu = computed(() => {
return colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () =>
applyColor(colorOption.name === 'noColor' ? null : colorOption)
}))
})
const getAdjustSizeOption = (): MenuOption => ({
label: t('contextMenu.Adjust Size'),
icon: 'icon-[lucide--move-diagonal-2]',
action: adjustNodeSize
})
const getNodeVisualOptions = (
states: NodeSelectionState,
bump: () => void
): MenuOption[] => [
{
label: states.collapsed
? t('contextMenu.Expand Node')
: t('contextMenu.Minimize Node'),
icon: states.collapsed
? 'icon-[lucide--maximize-2]'
: 'icon-[lucide--minimize-2]',
action: () => {
toggleNodeCollapse()
bump()
}
},
{
label: t('contextMenu.Shape'),
icon: 'icon-[lucide--box]',
hasSubmenu: true,
submenu: shapeSubmenu.value,
action: () => {}
},
{
label: t('contextMenu.Color'),
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: colorSubmenu.value,
action: () => {}
}
]
const getPinOption = (
states: NodeSelectionState,
bump: () => void
): MenuOption => ({
label: states.pinned ? t('contextMenu.Unpin') : t('contextMenu.Pin'),
icon: states.pinned ? 'icon-[lucide--pin-off]' : 'icon-[lucide--pin]',
action: () => {
toggleNodePin()
bump()
}
})
const getBypassOption = (
states: NodeSelectionState,
bump: () => void
): MenuOption => ({
label: states.bypassed
? t('contextMenu.Remove Bypass')
: t('contextMenu.Bypass'),
icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]',
shortcut: 'Ctrl+B',
action: () => {
toggleNodeBypass()
bump()
}
})
const getRunBranchOption = (): MenuOption => ({
label: t('contextMenu.Run Branch'),
icon: 'icon-[lucide--play]',
action: runBranch
})
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
label: t('contextMenu.Node Info'),
icon: 'icon-[lucide--info]',
action: showNodeHelp
})
return {
getNodeInfoOption,
getAdjustSizeOption,
getNodeVisualOptions,
getPinOption,
getBypassOption,
getRunBranchOption,
colorSubmenu
}
}

View File

@@ -0,0 +1,68 @@
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
/**
* Composable for handling node information and utility operations
*/
export function useSelectedNodeActions() {
const { getSelectedNodes, toggleSelectedNodesMode } =
useSelectedLiteGraphItems()
const commandStore = useCommandStore()
const workflowStore = useWorkflowStore()
const adjustNodeSize = () => {
const selectedNodes = getSelectedNodes()
selectedNodes.forEach((node) => {
const optimalSize = node.computeSize()
node.setSize([optimalSize[0], optimalSize[1]])
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeCollapse = () => {
const selectedNodes = getSelectedNodes()
selectedNodes.forEach((node) => {
node.collapse()
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodePin = () => {
const selectedNodes = getSelectedNodes()
selectedNodes.forEach((node) => {
node.pin(!node.pinned)
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeBypass = () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true)
}
const runBranch = async () => {
const selectedNodes = getSelectedNodes()
const selectedOutputNodes = filterOutputNodes(selectedNodes)
if (selectedOutputNodes.length === 0) return
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
}
return {
adjustNodeSize,
toggleNodeCollapse,
toggleNodePin,
toggleNodeBypass,
runBranch
}
}

View File

@@ -0,0 +1,147 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useFrameNodes } from './useFrameNodes'
import { BadgeVariant, type MenuOption } from './useMoreOptionsMenu'
import { useNodeArrangement } from './useNodeArrangement'
import { useSelectionOperations } from './useSelectionOperations'
import { useSubgraphOperations } from './useSubgraphOperations'
/**
* Composable for selection-related menu operations
*/
export function useSelectionMenuOptions() {
const { t } = useI18n()
const {
copySelection,
duplicateSelection,
deleteSelection,
renameSelection
} = useSelectionOperations()
const { alignOptions, distributeOptions, applyAlign, applyDistribute } =
useNodeArrangement()
const { convertToSubgraph, unpackSubgraph, addSubgraphToLibrary } =
useSubgraphOperations()
const { frameNodes } = useFrameNodes()
const alignSubmenu = computed(() =>
alignOptions.map((align) => ({
label: align.localizedName,
icon: align.icon,
action: () => applyAlign(align)
}))
)
const distributeSubmenu = computed(() =>
distributeOptions.map((distribute) => ({
label: distribute.localizedName,
icon: distribute.icon,
action: () => applyDistribute(distribute)
}))
)
const getBasicSelectionOptions = (): MenuOption[] => [
{
label: t('contextMenu.Rename'),
action: renameSelection
},
{
label: t('contextMenu.Copy'),
shortcut: 'Ctrl+C',
action: copySelection
},
{
label: t('contextMenu.Duplicate'),
shortcut: 'Ctrl+D',
action: duplicateSelection
}
]
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
if (hasSubgraphs) {
return [
{
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
action: addSubgraphToLibrary
},
{
label: t('contextMenu.Unpack Subgraph'),
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
]
} else {
return [
{
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
]
}
}
const getMultipleNodesOptions = (): MenuOption[] => {
const convertToGroupNodes = () => {
const commandStore = useCommandStore()
void commandStore.execute(
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
)
}
return [
{
label: t('contextMenu.Convert to Group Node'),
icon: 'icon-[lucide--group]',
action: convertToGroupNodes,
badge: BadgeVariant.DEPRECATED
},
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
}
]
}
const getAlignmentOptions = (): MenuOption[] => [
{
label: t('contextMenu.Align Selected To'),
icon: 'icon-[lucide--align-start-horizontal]',
hasSubmenu: true,
submenu: alignSubmenu.value,
action: () => {}
},
{
label: t('contextMenu.Distribute Nodes'),
icon: 'icon-[lucide--align-center-horizontal]',
hasSubmenu: true,
submenu: distributeSubmenu.value,
action: () => {}
}
]
const getDeleteOption = (): MenuOption => ({
label: t('contextMenu.Delete'),
icon: 'icon-[lucide--trash-2]',
shortcut: 'Delete',
action: deleteSelection
})
return {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions,
getDeleteOption,
getAlignmentOptions,
alignSubmenu,
distributeSubmenu
}
}

View File

@@ -0,0 +1,168 @@
// import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' // Unused for now
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
useCanvasStore,
useTitleEditorStore
} from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
/**
* Composable for handling basic selection operations like copy, paste, duplicate, delete, rename
*/
export function useSelectionOperations() {
// const { getSelectedNodes } = useSelectedLiteGraphItems() // Unused for now
const canvasStore = useCanvasStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const titleEditorStore = useTitleEditorStore()
const workflowStore = useWorkflowStore()
const copySelection = () => {
const canvas = app.canvas
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
toastStore.add({
severity: 'warn',
summary: t('g.nothingToCopy'),
detail: t('g.selectItemsToCopy'),
life: 3000
})
return
}
canvas.copyToClipboard()
toastStore.add({
severity: 'success',
summary: t('g.copied'),
detail: t('g.itemsCopiedToClipboard'),
life: 2000
})
}
const pasteSelection = () => {
const canvas = app.canvas
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const duplicateSelection = () => {
const canvas = app.canvas
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
toastStore.add({
severity: 'warn',
summary: t('g.nothingToDuplicate'),
detail: t('g.selectItemsToDuplicate'),
life: 3000
})
return
}
// Copy current selection
canvas.copyToClipboard()
// Clear selection to avoid confusion
canvas.selectedItems.clear()
canvasStore.updateSelectedItems()
// Paste to create duplicates
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const deleteSelection = () => {
const canvas = app.canvas
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
toastStore.add({
severity: 'warn',
summary: t('g.nothingToDelete'),
detail: t('g.selectItemsToDelete'),
life: 3000
})
return
}
canvas.deleteSelected()
canvas.setDirty(true, true)
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const renameSelection = async () => {
const selectedItems = Array.from(canvasStore.selectedItems)
// Handle single node selection
if (selectedItems.length === 1) {
const item = selectedItems[0]
// For nodes, use the title editor
if (item instanceof LGraphNode) {
titleEditorStore.titleEditorTarget = item
return
}
// For other items like groups, use prompt dialog
const currentTitle = 'title' in item ? (item.title as string) : ''
const newTitle = await dialogService.prompt({
title: t('g.rename'),
message: t('g.enterNewName'),
defaultValue: currentTitle
})
if (newTitle && newTitle !== currentTitle) {
if ('title' in item) {
// Type-safe assignment for items with title property
const titledItem = item as { title: string }
titledItem.title = newTitle
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
}
return
}
// Handle multiple selections - batch rename
if (selectedItems.length > 1) {
const baseTitle = await dialogService.prompt({
title: t('g.batchRename'),
message: t('g.enterBaseName'),
defaultValue: 'Item'
})
if (baseTitle) {
selectedItems.forEach((item, index) => {
if ('title' in item) {
// Type-safe assignment for items with title property
const titledItem = item as { title: string }
titledItem.title = `${baseTitle} ${index + 1}`
}
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return
}
toastStore.add({
severity: 'warn',
summary: t('g.nothingToRename'),
detail: t('g.selectItemsToRename'),
life: 3000
})
}
return {
copySelection,
pasteSelection,
duplicateSelection,
deleteSelection,
renameSelection
}
}

View File

@@ -0,0 +1,146 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import {
LGraphEventMode,
LGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
collapsed: boolean
pinned: boolean
bypassed: boolean
}
/**
* Centralized computed selection state + shared helper actions to avoid duplication
* between selection toolbox, context menus, and other UI affordances.
*/
export function useSelectionState() {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const { selectedItems } = storeToRefs(canvasStore)
const selectedNodes = computed(() => {
return selectedItems.value.filter((i: unknown) =>
isLGraphNode(i)
) as LGraphNode[]
})
const nodeDef = computed(() => {
if (selectedNodes.value.length !== 1) return null
return nodeDefStore.fromLGraphNode(selectedNodes.value[0])
})
const hasAnySelection = computed(() => selectedItems.value.length > 0)
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
const isSingleNode = computed(
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
)
const isSingleSubgraph = computed(
() =>
isSingleNode.value &&
(selectedItems.value[0] as LGraphNode)?.isSubgraphNode?.()
)
const isSingleImageNode = computed(
() =>
isSingleNode.value && isImageNode(selectedItems.value[0] as LGraphNode)
)
const hasSubgraphs = computed(() =>
selectedItems.value.some((i: unknown) => i instanceof SubgraphNode)
)
const hasAny3DNodeSelected = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
return (
selectedNodes.value.length === 1 &&
selectedNodes.value.some(isLoad3dNode) &&
enable3DViewer
)
})
const hasImageNode = computed(() => isSingleImageNode.value)
const hasOutputNodesSelected = computed(
() => filterOutputNodes(selectedNodes.value).length > 0
)
// Helper function to compute selection flags (reused by both computed and function)
const computeSelectionStatesFromNodes = (
nodes: LGraphNode[]
): NodeSelectionState => {
if (!nodes.length)
return { collapsed: false, pinned: false, bypassed: false }
return {
collapsed: nodes.some((n) => n.flags?.collapsed),
pinned: nodes.some((n) => n.pinned),
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
}
}
const selectedNodesStates = computed<NodeSelectionState>(() =>
computeSelectionStatesFromNodes(selectedNodes.value)
)
// On-demand computation (non-reactive) so callers can fetch fresh flags
const computeSelectionFlags = (): NodeSelectionState =>
computeSelectionStatesFromNodes(selectedNodes.value)
/** Toggle node help sidebar/panel for the single selected node (if any). */
const showNodeHelp = () => {
const def = nodeDef.value
if (!def) return
const isSidebarActive =
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
const currentHelpNode: any = nodeHelpStore.currentHelpNode
const isSameNodeHelpOpen =
isSidebarActive &&
nodeHelpStore.isHelpOpen &&
currentHelpNode &&
currentHelpNode.nodePath === def.nodePath
if (isSameNodeHelpOpen) {
nodeHelpStore.closeHelp()
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
return
}
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
nodeHelpStore.openHelp(def)
}
return {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
hasAny3DNodeSelected,
hasAnySelection,
hasSingleSelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
isSingleImageNode,
hasSubgraphs,
hasImageNode,
hasOutputNodesSelected,
selectedNodesStates,
computeSelectionFlags
}
}

View File

@@ -0,0 +1,131 @@
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling subgraph-related operations
*/
export function useSubgraphOperations() {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const nodeOutputStore = useNodeOutputStore()
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const convertToSubgraph = () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) {
return null
}
const res = graph.convertToSubgraph(canvas.selectedItems)
if (!res) {
return
}
const { node } = res
canvas.select(node)
canvasStore.updateSelectedItems()
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const unpackSubgraph = () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) {
return
}
const selectedItems = Array.from(canvas.selectedItems)
const subgraphNodes = selectedItems.filter(
(item): item is SubgraphNode => item instanceof SubgraphNode
)
if (subgraphNodes.length === 0) {
return
}
subgraphNodes.forEach((subgraphNode) => {
// Revoke any image previews for the subgraph
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
// Unpack the subgraph
graph.unpackSubgraph(subgraphNode)
})
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const addSubgraphToLibrary = async () => {
const selectedItems = Array.from(canvasStore.selectedItems)
// Handle single node selection like BookmarkButton.vue
if (selectedItems.length === 1) {
const item = selectedItems[0]
if (isLGraphNode(item)) {
const nodeDef = nodeDefStore.fromLGraphNode(item)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
return
}
}
}
// Handle multiple nodes - convert to subgraph first then bookmark
const selectedNodes = getSelectedNodes()
if (selectedNodes.length === 0) {
return
}
// Check if selection contains subgraph nodes
const hasSubgraphs = selectedNodes.some(
(node) => node instanceof SubgraphNode
)
if (!hasSubgraphs) {
// Convert regular nodes to subgraph first
convertToSubgraph()
return
}
// For subgraph nodes, bookmark them
let bookmarkedCount = 0
for (const node of selectedNodes) {
if (node instanceof SubgraphNode) {
const nodeDef = nodeDefStore.fromLGraphNode(node)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
bookmarkedCount++
}
}
}
}
const isSubgraphSelected = (): boolean => {
const selectedItems = Array.from(canvasStore.selectedItems)
return selectedItems.some((item) => item instanceof SubgraphNode)
}
const hasSelectableNodes = (): boolean => {
return getSelectedNodes().length > 0
}
return {
convertToSubgraph,
unpackSubgraph,
addSubgraphToLibrary,
isSubgraphSelected,
hasSelectableNodes
}
}

View File

@@ -0,0 +1,163 @@
import { nextTick } from 'vue'
import type { MenuOption } from './useMoreOptionsMenu'
/**
* Composable for handling submenu positioning logic
*/
export function useSubmenuPositioning() {
/**
* Toggle submenu visibility with proper positioning
* @param option - Menu option with submenu
* @param event - Click event
* @param submenu - PrimeVue Popover reference
* @param currentSubmenu - Currently open submenu name
* @param menuOptionsWithSubmenu - All menu options with submenus
* @param submenuRefs - References to all submenu popovers
*/
const toggleSubmenu = async (
option: MenuOption,
event: Event,
submenu: any, // Component instance with show/hide methods
currentSubmenu: { value: string | null },
menuOptionsWithSubmenu: MenuOption[],
submenuRefs: Record<string, any> // Component instances
): Promise<void> => {
if (!option.label || !option.hasSubmenu) return
// Check if this submenu is currently open
const isCurrentlyOpen = currentSubmenu.value === option.label
// Hide all submenus first
menuOptionsWithSubmenu.forEach((opt) => {
const sm = submenuRefs[`submenu-${opt.label}`]
if (sm) {
sm.hide()
}
})
currentSubmenu.value = null
// If it wasn't open before, show it now
if (!isCurrentlyOpen) {
currentSubmenu.value = option.label
await nextTick()
const menuItem = event.currentTarget as HTMLElement
const menuItemRect = menuItem.getBoundingClientRect()
// Find the parent popover content element that contains this menu item
const mainPopoverContent = menuItem.closest(
'[data-pc-section="content"]'
) as HTMLElement
if (mainPopoverContent) {
const mainPopoverRect = mainPopoverContent.getBoundingClientRect()
// Create a temporary positioned element as the target
const tempTarget = createPositionedTarget(
mainPopoverRect.right + 8,
menuItemRect.top,
`submenu-target-${option.label}`
)
// Create event using the temp target
const tempEvent = createMouseEvent(
mainPopoverRect.right + 8,
menuItemRect.top
)
// Show submenu relative to temp target
submenu.show(tempEvent, tempTarget)
// Clean up temp target after a delay
cleanupTempTarget(tempTarget, 100)
} else {
// Fallback: position to the right of the menu item
const tempTarget = createPositionedTarget(
menuItemRect.right + 8,
menuItemRect.top,
`submenu-fallback-target-${option.label}`
)
// Create event using the temp target
const tempEvent = createMouseEvent(
menuItemRect.right + 8,
menuItemRect.top
)
// Show submenu relative to temp target
submenu.show(tempEvent, tempTarget)
// Clean up temp target after a delay
cleanupTempTarget(tempTarget, 100)
}
}
}
/**
* Create a temporary positioned DOM element for submenu targeting
*/
const createPositionedTarget = (
left: number,
top: number,
id: string
): HTMLElement => {
const tempTarget = document.createElement('div')
tempTarget.style.position = 'absolute'
tempTarget.style.left = `${left}px`
tempTarget.style.top = `${top}px`
tempTarget.style.width = '1px'
tempTarget.style.height = '1px'
tempTarget.style.pointerEvents = 'none'
tempTarget.style.visibility = 'hidden'
tempTarget.id = id
document.body.appendChild(tempTarget)
return tempTarget
}
/**
* Create a mouse event with specific coordinates
*/
const createMouseEvent = (clientX: number, clientY: number): MouseEvent => {
return new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX,
clientY
})
}
/**
* Clean up temporary target element after delay
*/
const cleanupTempTarget = (target: HTMLElement, delay: number): void => {
setTimeout(() => {
if (target.parentNode) {
target.parentNode.removeChild(target)
}
}, delay)
}
/**
* Hide all submenus
*/
const hideAllSubmenus = (
menuOptionsWithSubmenu: MenuOption[],
submenuRefs: Record<string, any>, // Component instances
currentSubmenu: { value: string | null }
): void => {
menuOptionsWithSubmenu.forEach((option) => {
const submenu = submenuRefs[`submenu-${option.label}`]
if (submenu) {
submenu.hide()
}
})
currentSubmenu.value = null
}
return {
toggleSubmenu,
hideAllSubmenus
}
}

View File

@@ -1,18 +1,16 @@
/**
* Viewport Culling Composable
* Vue Nodes Viewport Culling
*
* Handles viewport culling optimization for Vue nodes including:
* - Transform state synchronization
* - Visible node calculation with screen space transforms
* - Adaptive margin computation based on zoom level
* - Performance optimizations for large graphs
* Principles:
* 1. Query DOM directly using data attributes (no cache to maintain)
* 2. Set display none on element to avoid cascade resolution overhead
* 3. Only run when transform changes (event driven)
*/
import { type Ref, computed, readonly, ref } from 'vue'
import { type Ref, computed } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useTransformState } from '@/renderer/core/layout/useTransformState'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app as comfyApp } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
interface NodeManager {
getNode: (id: string) => any
@@ -25,188 +23,84 @@ export function useViewportCulling(
nodeManager: Ref<NodeManager | null>
) {
const canvasStore = useCanvasStore()
const { syncWithCanvas } = useTransformState()
// Transform tracking for performance optimization
const lastScale = ref(1)
const lastOffsetX = ref(0)
const lastOffsetY = ref(0)
// Current transform state
const currentTransformState = computed(() => ({
scale: lastScale.value,
offsetX: lastOffsetX.value,
offsetY: lastOffsetY.value
}))
/**
* Computed property that returns nodes visible in the current viewport
* Implements sophisticated culling algorithm with adaptive margins
*/
const nodesToRender = computed(() => {
if (!isVueNodesEnabled.value) {
return []
}
// Access trigger to force re-evaluation after nodeManager initialization
void nodeDataTrigger.value
if (!comfyApp.graph) {
return []
}
const allNodes = Array.from(vueNodeData.value.values())
// Apply viewport culling - check if node bounds intersect with viewport
// TODO: use quadtree
if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) {
const canvas = canvasStore.canvas
const manager = nodeManager.value
// Ensure transform is synced before checking visibility
syncWithCanvas(comfyApp.canvas)
const ds = canvas.ds
// Work in screen space - viewport is simply the canvas element size
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
// Add margin that represents a constant distance in canvas space
// Convert canvas units to screen pixels by multiplying by scale
const canvasMarginDistance = 200 // Fixed margin in canvas units
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const filtered = allNodes.filter((nodeData) => {
const node = manager.getNode(nodeData.id)
if (!node) return false
// Transform node position to screen space (same as DOM widgets)
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Check if node bounds intersect with expanded viewport (in screen space)
const isVisible = !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
return isVisible
})
return filtered
}
return allNodes
const allNodes = computed(() => {
if (!isVueNodesEnabled.value) return []
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
return Array.from(vueNodeData.value.values())
})
/**
* Handle transform updates with performance optimization
* Only syncs when transform actually changes to avoid unnecessary reflows
* Update visibility of all nodes based on viewport
* Queries DOM directly - no cache maintenance needed
*/
const handleTransformUpdate = (detectChangesInRAF: () => void) => {
// Skip all work if Vue nodes are disabled
if (!isVueNodesEnabled.value) {
return
}
// Sync transform state only when it changes (avoids reflows)
if (comfyApp.canvas?.ds) {
const currentScale = comfyApp.canvas.ds.scale
const currentOffsetX = comfyApp.canvas.ds.offset[0]
const currentOffsetY = comfyApp.canvas.ds.offset[1]
if (
currentScale !== lastScale.value ||
currentOffsetX !== lastOffsetX.value ||
currentOffsetY !== lastOffsetY.value
) {
syncWithCanvas(comfyApp.canvas)
lastScale.value = currentScale
lastOffsetX.value = currentOffsetX
lastOffsetY.value = currentOffsetY
}
}
// Detect node changes during transform updates
detectChangesInRAF()
// Trigger reactivity for nodesToRender
void nodesToRender.value.length
}
/**
* Calculate if a specific node is visible in viewport
* Useful for individual node visibility checks
*/
const isNodeVisible = (nodeData: VueNodeData): boolean => {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) {
return true // Default to visible if culling not available
}
const updateVisibility = () => {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
const canvas = canvasStore.canvas
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return false
syncWithCanvas(comfyApp.canvas)
const manager = nodeManager.value
const ds = canvas.ds
// Viewport bounds
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
const canvasMarginDistance = 200
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const margin = 500 * ds.scale
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Get all node elements at once
const nodeElements = document.querySelectorAll('[data-node-id]')
return !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
// Update each element's visibility
for (const element of nodeElements) {
const nodeId = element.getAttribute('data-node-id')
if (!nodeId) continue
const node = manager.getNode(nodeId)
if (!node) continue
// Calculate if node is outside viewport
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
const isNodeOutsideViewport =
screen_x + screen_width < -margin ||
screen_x > viewport_width + margin ||
screen_y + screen_height < -margin ||
screen_y > viewport_height + margin
// Setting display none directly avoid potential cascade resolution
if (element instanceof HTMLElement) {
element.style.display = isNodeOutsideViewport ? 'none' : ''
}
}
}
// RAF throttling for smooth updates during continuous panning
let rafId: number | null = null
/**
* Get viewport bounds information for debugging
* Handle transform update - called by TransformPane event
* Uses RAF to batch updates for smooth performance
*/
const getViewportInfo = () => {
if (!canvasStore.canvas || !comfyApp.canvas) {
return null
const handleTransformUpdate = () => {
if (!isVueNodesEnabled.value) return
// Cancel previous RAF if still pending
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
const canvas = canvasStore.canvas
const ds = canvas.ds
return {
viewport_width: canvas.canvas.width,
viewport_height: canvas.canvas.height,
scale: ds.scale,
offset: [ds.offset[0], ds.offset[1]],
margin_distance: 200,
margin_x: 200 * ds.scale,
margin_y: 200 * ds.scale
}
// Schedule update in next animation frame
rafId = requestAnimationFrame(() => {
updateVisibility()
rafId = null
})
}
return {
nodesToRender,
allNodes,
handleTransformUpdate,
isNodeVisible,
getViewportInfo,
// Transform state
currentTransformState: readonly(currentTransformState),
lastScale: readonly(lastScale),
lastOffsetX: readonly(lastOffsetX),
lastOffsetY: readonly(lastOffsetY)
updateVisibility
}
}

View File

@@ -16,13 +16,13 @@ import type {
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
import { app as comfyApp } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
const canvasStore = useCanvasStore()

View File

@@ -1,6 +1,6 @@
import { useEventListener } from '@vueuse/core'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Adds a handler on copy that serializes selected nodes to JSON

View File

@@ -12,9 +12,13 @@ import {
LGraphGroup,
LGraphNode,
LiteGraph,
SubgraphNode
SubgraphNode,
type Point
} from '@/lib/litegraph/src/litegraph'
import { type Point } from '@/lib/litegraph/src/litegraph'
import {
useCanvasStore,
useTitleEditorStore
} from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
@@ -22,7 +26,6 @@ import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'

View File

@@ -5,7 +5,7 @@ import {
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSettingStore } from '@/stores/settingStore'
/**

View File

@@ -2,9 +2,9 @@ import { useEventListener } from '@vueuse/core'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'

View File

@@ -1,7 +1,7 @@
import { computed, ref, watchEffect } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
interface RefreshableItem {

View File

@@ -6,9 +6,20 @@
"noWorkflowsFound": "No workflows found.",
"comingSoon": "Coming Soon",
"download": "Download",
"downloadImage": "Download image",
"editOrMaskImage": "Edit or mask image",
"removeImage": "Remove image",
"viewImageOfTotal": "View image {index} of {total}",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
"galleryImage": "Gallery image",
"galleryThumbnail": "Gallery thumbnail",
"errorLoadingImage": "Error loading image",
"failedToDownloadImage": "Failed to download image",
"calculatingDimensions": "Calculating dimensions",
"import": "Import",
"loadAllFolders": "Load All Folders",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
@@ -30,7 +41,9 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"help": "Help",
"info": "Node Info",
"bookmark": "Save to Library",
"moreOptions": "More Options",
"loading": "Loading",
"loadingPanel": "Loading {panel} panel...",
"preview": "PREVIEW",
@@ -157,7 +170,8 @@
"nodeContentError": "Node Content Error",
"nodeHeaderError": "Node Header Error",
"nodeSlotsError": "Node Slots Error",
"nodeWidgetsError": "Node Widgets Error"
"nodeWidgetsError": "Node Widgets Error",
"frameNodes": "Frame Nodes"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -311,7 +325,37 @@
"Save Selected as Template": "Save Selected as Template",
"Node Templates": "Node Templates",
"Manage": "Manage",
"Search": "Search"
"Search": "Search",
"Open in Mask Editor": "Open in Mask Editor",
"Open Image": "Open Image",
"Copy Image": "Copy Image",
"Save Image": "Save Image",
"Rename": "Rename",
"Copy": "Copy",
"Duplicate": "Duplicate",
"Paste": "Paste",
"Node Info": "Node Info",
"Adjust Size": "Adjust Size",
"Minimize Node": "Minimize Node",
"Expand Node": "Expand Node",
"Shape": "Shape",
"Color": "Color",
"Add Subgraph to Library": "Add Subgraph to Library",
"Unpack Subgraph": "Unpack Subgraph",
"Convert to Subgraph": "Convert to Subgraph",
"Align Selected To": "Align Selected To",
"Distribute Nodes": "Distribute Nodes",
"Remove Bypass": "Remove Bypass",
"Run Branch": "Run Branch",
"Delete": "Delete",
"Top": "Top",
"Bottom": "Bottom",
"Left": "Left",
"Right": "Right",
"Horizontal": "Horizontal",
"Vertical": "Vertical",
"new": "new",
"deprecated": "deprecated"
},
"icon": {
"bookmark": "Bookmark",
@@ -462,6 +506,14 @@
"revertChanges": "Revert Changes",
"restart": "Restart"
},
"shape": {
"default": "Default",
"round": "Round",
"CARD": "Card",
"circle": "Circle",
"arrow": "Arrow",
"box": "Box"
},
"sideToolbar": {
"themeToggle": "Toggle Theme",
"helpCenter": "Help Center",
@@ -1758,7 +1810,10 @@
"executeButton": {
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
"disabledTooltip": "No output nodes selected"
}
},
"Set Group Nodes to Never": "Set Group Nodes to Never",
"Bypass Group Nodes": "Bypass Group Nodes",
"Set Group Nodes to Always": "Set Group Nodes to Always"
},
"chatHistory": {
"cancelEdit": "Cancel",

View File

@@ -1,8 +1,25 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
/**
* Injection key for providing selected node IDs to Vue node components.
* Contains a reactive Set of selected node IDs (as strings).
*/
export const SelectedNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('selectedNodeIds')
/**
* Injection key for providing executing node IDs to Vue node components.
* Contains a reactive Set of currently executing node IDs (as strings).
*/
export const ExecutingNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('executingNodeIds')
/**
* Injection key for providing node progress states to Vue node components.
* Contains a reactive Record of node IDs to their current progress state.
*/
export const NodeProgressStatesKey: InjectionKey<
Ref<Record<string, NodeProgressState>>
> = Symbol('nodeProgressStates')

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import TransformPane from '../TransformPane.vue'
import TransformPane from '../transform/TransformPane.vue'
// Mock the transform state composable
const mockTransformState = {

View File

@@ -36,9 +36,19 @@ import {
type Point,
type RerouteId,
type RerouteLayout,
type Size,
type SlotLayout
} from '@/renderer/core/layout/types'
import {
REROUTE_RADIUS,
boundsIntersect,
pointInBounds
} from '@/renderer/core/layout/utils/layoutMath'
import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils'
import {
type NodeLayoutMap,
layoutToYNode,
yNodeToLayout
} from '@/renderer/core/layout/utils/mappers'
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
type YEventChange = {
@@ -48,9 +58,6 @@ type YEventChange = {
const logger = log.getLogger('LayoutStore')
// Constants
const REROUTE_RADIUS = 8
// Utility functions
function asRerouteId(id: string | number): RerouteId {
return Number(id)
@@ -60,15 +67,6 @@ function asLinkId(id: string | number): LinkId {
return Number(id)
}
interface NodeLayoutData {
id: NodeId
position: Point
size: Size
zIndex: number
visible: boolean
bounds: Bounds
}
interface LinkData {
id: LinkId
sourceNodeId: NodeId
@@ -91,15 +89,6 @@ interface TypedYMap<T> {
}
class LayoutStoreImpl implements LayoutStore {
private static readonly NODE_DEFAULTS: NodeLayoutData = {
id: 'unknown-node',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
private static readonly REROUTE_DEFAULTS: RerouteData = {
id: 0,
position: { x: 0, y: 0 },
@@ -109,7 +98,7 @@ class LayoutStoreImpl implements LayoutStore {
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
private ynodes: Y.Map<NodeLayoutMap> // Maps nodeId -> NodeLayoutMap containing NodeLayout data
private ylinks: Y.Map<Y.Map<unknown>> // Maps linkId -> Y.Map containing link data
private yreroutes: Y.Map<Y.Map<unknown>> // Maps rerouteId -> Y.Map containing reroute data
private yoperations: Y.Array<LayoutOperation> // Operation log
@@ -155,7 +144,7 @@ class LayoutStoreImpl implements LayoutStore {
this.rerouteSpatialIndex = new SpatialIndexManager()
// Listen for Yjs changes and trigger Vue reactivity
this.ynodes.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
this.ynodes.observe((event: Y.YMapEvent<NodeLayoutMap>) => {
this.version++
// Trigger all affected node refs
@@ -184,16 +173,6 @@ class LayoutStoreImpl implements LayoutStore {
})
}
private getNodeField<K extends keyof NodeLayoutData>(
ynode: Y.Map<unknown>,
field: K,
defaultValue: NodeLayoutData[K] = LayoutStoreImpl.NODE_DEFAULTS[field]
): NodeLayoutData[K] {
const typedNode = ynode as TypedYMap<NodeLayoutData>
const value = typedNode.get(field)
return value ?? defaultValue
}
private getLinkField<K extends keyof LinkData>(
ylink: Y.Map<unknown>,
field: K
@@ -227,7 +206,7 @@ class LayoutStoreImpl implements LayoutStore {
get: () => {
track()
const ynode = this.ynodes.get(nodeId)
const layout = ynode ? this.yNodeToLayout(ynode) : null
const layout = ynode ? yNodeToLayout(ynode) : null
return layout
},
set: (newLayout: NodeLayout | null) => {
@@ -242,7 +221,7 @@ class LayoutStoreImpl implements LayoutStore {
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor,
previousLayout: this.yNodeToLayout(existing)
previousLayout: yNodeToLayout(existing)
})
}
} else {
@@ -260,7 +239,7 @@ class LayoutStoreImpl implements LayoutStore {
actor: this.currentActor
})
} else {
const existingLayout = this.yNodeToLayout(existing)
const existingLayout = yNodeToLayout(existing)
// Check what properties changed
if (
@@ -330,8 +309,8 @@ class LayoutStoreImpl implements LayoutStore {
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = this.yNodeToLayout(ynode)
if (layout && this.boundsIntersect(layout.bounds, bounds)) {
const layout = yNodeToLayout(ynode)
if (layout && boundsIntersect(layout.bounds, bounds)) {
result.push(nodeId)
}
}
@@ -352,7 +331,7 @@ class LayoutStoreImpl implements LayoutStore {
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = this.yNodeToLayout(ynode)
const layout = yNodeToLayout(ynode)
if (layout) {
result.set(nodeId, layout)
}
@@ -378,7 +357,7 @@ class LayoutStoreImpl implements LayoutStore {
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = this.yNodeToLayout(ynode)
const layout = yNodeToLayout(ynode)
if (layout) {
nodes.push([nodeId, layout])
}
@@ -389,7 +368,7 @@ class LayoutStoreImpl implements LayoutStore {
nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex)
for (const [nodeId, layout] of nodes) {
if (this.pointInBounds(point, layout.bounds)) {
if (pointInBounds(point, layout.bounds)) {
return nodeId
}
}
@@ -561,16 +540,6 @@ class LayoutStoreImpl implements LayoutStore {
return this.rerouteLayouts.get(rerouteId) || null
}
/**
* Helper to create internal key for link segment
*/
private makeLinkSegmentKey(
linkId: LinkId,
rerouteId: RerouteId | null
): string {
return `${linkId}:${rerouteId ?? 'final'}`
}
/**
* Update link segment layout data
*/
@@ -579,7 +548,7 @@ class LayoutStoreImpl implements LayoutStore {
rerouteId: RerouteId | null,
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
): void {
const key = this.makeLinkSegmentKey(linkId, rerouteId)
const key = makeLinkSegmentKey(linkId, rerouteId)
const existing = this.linkSegmentLayouts.get(key)
// Short-circuit if bounds and centerPos unchanged (prevents spatial index churn)
@@ -629,7 +598,7 @@ class LayoutStoreImpl implements LayoutStore {
* Delete link segment layout data
*/
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void {
const key = this.makeLinkSegmentKey(linkId, rerouteId)
const key = makeLinkSegmentKey(linkId, rerouteId)
const deleted = this.linkSegmentLayouts.delete(key)
if (deleted) {
// Remove from spatial index
@@ -693,7 +662,7 @@ class LayoutStoreImpl implements LayoutStore {
rerouteId: segmentLayout.rerouteId
}
}
} else if (this.pointInBounds(point, segmentLayout.bounds)) {
} else if (pointInBounds(point, segmentLayout.bounds)) {
// Fallback to bounding box test
return {
linkId: segmentLayout.linkId,
@@ -733,7 +702,7 @@ class LayoutStoreImpl implements LayoutStore {
// Check precise bounds for candidates
for (const key of candidateSlotKeys) {
const slotLayout = this.slotLayouts.get(key)
if (slotLayout && this.pointInBounds(point, slotLayout.bounds)) {
if (slotLayout && pointInBounds(point, slotLayout.bounds)) {
return slotLayout
}
}
@@ -969,7 +938,7 @@ class LayoutStoreImpl implements LayoutStore {
}
}
this.ynodes.set(layout.id, this.layoutToYNode(layout))
this.ynodes.set(layout.id, layoutToYNode(layout))
// Add to spatial index
this.spatialIndex.insert(layout.id, layout.bounds)
@@ -987,7 +956,7 @@ class LayoutStoreImpl implements LayoutStore {
return
}
const size = this.getNodeField(ynode, 'size')
const size = yNodeToLayout(ynode).size
const newBounds = {
x: operation.position.x,
y: operation.position.y,
@@ -1016,7 +985,7 @@ class LayoutStoreImpl implements LayoutStore {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
const position = this.getNodeField(ynode, 'position')
const position = yNodeToLayout(ynode).position
const newBounds = {
x: position.x,
y: position.y,
@@ -1053,7 +1022,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: CreateNodeOperation,
change: LayoutChange
): void {
const ynode = this.layoutToYNode(operation.layout)
const ynode = layoutToYNode(operation.layout)
this.ynodes.set(operation.nodeId, ynode)
// Add to spatial index
@@ -1187,7 +1156,7 @@ class LayoutStoreImpl implements LayoutStore {
* Update node bounds helper
*/
private updateNodeBounds(
ynode: Y.Map<unknown>,
ynode: NodeLayoutMap,
position: Point,
size: { width: number; height: number }
): void {
@@ -1335,27 +1304,6 @@ class LayoutStoreImpl implements LayoutStore {
}
// Helper methods
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
const ynode = new Y.Map<unknown>()
ynode.set('id', layout.id)
ynode.set('position', layout.position)
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
return ynode
}
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
return {
id: this.getNodeField(ynode, 'id'),
position: this.getNodeField(ynode, 'position'),
size: this.getNodeField(ynode, 'size'),
zIndex: this.getNodeField(ynode, 'zIndex'),
visible: this.getNodeField(ynode, 'visible'),
bounds: this.getNodeField(ynode, 'bounds')
}
}
private notifyChange(change: LayoutChange): void {
this.changeListeners.forEach((listener) => {
@@ -1367,24 +1315,6 @@ class LayoutStoreImpl implements LayoutStore {
})
}
private pointInBounds(point: Point, bounds: Bounds): boolean {
return (
point.x >= bounds.x &&
point.x <= bounds.x + bounds.width &&
point.y >= bounds.y &&
point.y <= bounds.y + bounds.height
)
}
private boundsIntersect(a: Bounds, b: Bounds): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
)
}
// CRDT-specific methods
getOperationsSince(timestamp: number): LayoutOperation[] {
const operations: LayoutOperation[] = []

View File

@@ -13,10 +13,10 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useTransformState } from '@/renderer/core/layout/useTransformState'
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface TransformPaneProps {
canvas?: LGraphCanvas

View File

@@ -1,6 +1,6 @@
import { onUnmounted, ref } from 'vue'
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
interface CanvasTransformSyncOptions {
/**

View File

@@ -0,0 +1,21 @@
import type { Bounds, Point } from '@/renderer/core/layout/types'
export const REROUTE_RADIUS = 8
export function pointInBounds(point: Point, bounds: Bounds): boolean {
return (
point.x >= bounds.x &&
point.x <= bounds.x + bounds.width &&
point.y >= bounds.y &&
point.y <= bounds.y + bounds.height
)
}
export function boundsIntersect(a: Bounds, b: Bounds): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
)
}

View File

@@ -0,0 +1,11 @@
import type { LinkId, RerouteId } from '@/renderer/core/layout/types'
/**
* Creates a unique key for identifying link segments in spatial indexes
*/
export function makeLinkSegmentKey(
linkId: LinkId,
rerouteId: RerouteId | null
): string {
return `${linkId}:${rerouteId ?? 'final'}`
}

View File

@@ -0,0 +1,45 @@
import * as Y from 'yjs'
import type { NodeLayout } from '@/renderer/core/layout/types'
export type NodeLayoutMap = Y.Map<NodeLayout[keyof NodeLayout]>
export const NODE_LAYOUT_DEFAULTS: NodeLayout = {
id: 'unknown-node',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
export function layoutToYNode(layout: NodeLayout): NodeLayoutMap {
const ynode = new Y.Map<NodeLayout[keyof NodeLayout]>() as NodeLayoutMap
ynode.set('id', layout.id)
ynode.set('position', layout.position)
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
return ynode
}
function getOr<K extends keyof NodeLayout>(
map: NodeLayoutMap,
key: K,
fallback: NodeLayout[K]
): NodeLayout[K] {
const v = map.get(key)
return (v ?? fallback) as NodeLayout[K]
}
export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
return {
id: getOr(ynode, 'id', NODE_LAYOUT_DEFAULTS.id),
position: getOr(ynode, 'position', NODE_LAYOUT_DEFAULTS.position),
size: getOr(ynode, 'size', NODE_LAYOUT_DEFAULTS.size),
zIndex: getOr(ynode, 'zIndex', NODE_LAYOUT_DEFAULTS.zIndex),
visible: getOr(ynode, 'visible', NODE_LAYOUT_DEFAULTS.visible),
bounds: getOr(ynode, 'bounds', NODE_LAYOUT_DEFAULTS.bounds)
}
}

View File

@@ -2,7 +2,7 @@ import { useRafFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'

View File

@@ -0,0 +1,258 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview relative group flex flex-col items-center"
tabindex="0"
role="region"
:aria-label="$t('g.imagePreview')"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
>
<!-- Image Wrapper -->
<div
class="relative rounded-[5px] overflow-hidden w-full max-w-[352px] bg-[#262729]"
>
<!-- Error State -->
<div
v-if="imageError"
class="w-full h-[352px] flex flex-col items-center justify-center text-white text-center bg-gray-800/50"
>
<i-lucide:image-off class="w-12 h-12 mb-2 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ currentImageUrl }}</p>
</div>
<!-- Loading State -->
<Skeleton
v-else-if="isLoading"
class="w-full h-[352px]"
border-radius="5px"
/>
<!-- Main Image -->
<img
v-else
:src="currentImageUrl"
:alt="imageAltText"
class="w-full h-[352px] object-cover block"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages"
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@click="handleEditMask"
>
<i-lucide:venetian-mask class="w-4 h-4" />
</button>
<!-- Download Button -->
<button
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
:title="$t('g.downloadImage')"
:aria-label="$t('g.downloadImage')"
@click="handleDownload"
>
<i-lucide:download class="w-4 h-4" />
</button>
<!-- Close Button -->
<button
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
:title="$t('g.removeImage')"
:aria-label="$t('g.removeImage')"
@click="handleRemove"
>
<i-lucide:x class="w-4 h-4" />
</button>
</div>
<!-- Multiple Images Navigation -->
<div
v-if="hasMultipleImages"
class="absolute bottom-2 left-2 right-2 flex justify-center gap-1"
>
<button
v-for="(_, index) in imageUrls"
:key="index"
:class="getNavigationDotClass(index)"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@click="setCurrentIndex(index)"
/>
</div>
</div>
<!-- Image Dimensions -->
<div class="text-white text-xs text-center mt-2">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-gray-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
interface ImagePreviewProps {
/** Array of image URLs to display */
readonly imageUrls: readonly string[]
/** Optional node ID for context-aware actions */
readonly nodeId?: string
}
const props = defineProps<ImagePreviewProps>()
const { t } = useI18n()
const commandStore = useCommandStore()
const nodeOutputStore = useNodeOutputStore()
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const isLoading = ref(false)
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
}
// Reset loading and error states when URLs change
actualDimensions.value = null
imageError.value = false
isLoading.value = false
},
{ deep: true }
)
// Event handlers
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
isLoading.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
const handleImageError = () => {
isLoading.value = false
imageError.value = true
actualDimensions.value = null
}
const handleEditMask = () => {
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
const handleDownload = () => {
try {
downloadFile(currentImageUrl.value)
} catch (error) {
useToast().add({
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadImage'),
life: 3000,
group: 'image-preview'
})
}
}
const handleRemove = () => {
if (!props.nodeId) return
nodeOutputStore.removeNodeOutputs(props.nodeId)
}
const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
imageError.value = false
}
}
const handleMouseEnter = () => {
isHovered.value = true
}
const handleMouseLeave = () => {
isHovered.value = false
}
const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
index === currentIndex.value ? 'bg-white' : 'bg-white/50 hover:bg-white/80'
]
}
const handleKeyDown = (event: KeyboardEvent) => {
if (props.imageUrls.length <= 1) return
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
setCurrentIndex(
currentIndex.value > 0
? currentIndex.value - 1
: props.imageUrls.length - 1
)
break
case 'ArrowRight':
event.preventDefault()
setCurrentIndex(
currentIndex.value < props.imageUrls.length - 1
? currentIndex.value + 1
: 0
)
break
case 'Home':
event.preventDefault()
setCurrentIndex(0)
break
case 'End':
event.preventDefault()
setCurrentIndex(props.imageUrls.length - 1)
break
}
}
</script>

View File

@@ -0,0 +1,86 @@
import { type ComponentMountingOptions, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useDomSlotRegistration } from '@/renderer/core/layout/slots/useDomSlotRegistration'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
// Mock composable used by InputSlot/OutputSlot so we can assert call params
vi.mock('@/renderer/core/layout/slots/useDomSlotRegistration', () => ({
useDomSlotRegistration: vi.fn(() => ({ remeasure: vi.fn() }))
}))
type InputSlotProps = ComponentMountingOptions<typeof InputSlot>['props']
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['props']
const mountInputSlot = (props: InputSlotProps) =>
mount(InputSlot, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
}),
createPinia()
]
},
props
})
const mountOutputSlot = (props: OutputSlotProps) =>
mount(OutputSlot, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
}),
createPinia()
]
},
props
})
describe('InputSlot/OutputSlot', () => {
beforeEach(() => {
vi.mocked(useDomSlotRegistration).mockClear()
})
it('InputSlot registers with correct options', () => {
mountInputSlot({
nodeId: 'node-1',
index: 3,
slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-1',
slotIndex: 3,
isInput: true
})
)
})
it('OutputSlot registers with correct options', () => {
mountOutputSlot({
nodeId: 'node-2',
index: 1,
slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-2',
slotIndex: 1,
isInput: false
})
)
})
})

View File

@@ -21,7 +21,7 @@
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>

View File

@@ -8,20 +8,19 @@
:class="
cn(
'bg-white dark-theme:bg-charcoal-100',
'min-w-[445px]',
'lg-node absolute rounded-2xl',
// border
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
!!executing && 'border-blue-500 dark-theme:border-blue-500',
!!error && 'border-red-700 dark-theme:border-red-300',
!!executing && 'border-blue-100 dark-theme:border-blue-100',
hasAnyError && 'border-error',
// hover
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
// Selected
'outline-transparent -outline-offset-2 outline-2',
!!isSelected && 'outline-black dark-theme:outline-white',
!!(isSelected && executing) &&
'outline-blue-500 dark-theme:outline-blue-500',
!!(isSelected && error) && 'outline-red-500 dark-theme:outline-red-500',
'outline-blue-100 dark-theme:outline-blue-100',
isSelected && hasAnyError && 'outline-error',
{
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
@@ -119,6 +118,7 @@
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
:image-urls="nodeImageUrls"
/>
</div>
</template>
@@ -126,14 +126,29 @@
</template>
<script setup lang="ts">
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
import {
computed,
inject,
onErrorCaptured,
onMounted,
provide,
ref,
toRef,
watch
} from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
@@ -149,13 +164,18 @@ interface LGraphNodeProps {
position?: { x: number; y: number }
size?: { width: number; height: number }
readonly?: boolean
executing?: boolean
progress?: number
error?: string | null
zoomLevel?: number
}
const props = defineProps<LGraphNodeProps>()
const {
nodeData,
position,
size,
error = null,
readonly = false,
zoomLevel = 1
} = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [
@@ -174,7 +194,7 @@ const emit = defineEmits<{
'update:title': [nodeId: string, newTitle: string]
}>()
useVueElementTracking(props.nodeData.id, 'node')
useVueElementTracking(nodeData.id, 'node')
// Inject selection state from parent
const selectedNodeIds = inject(SelectedNodeIdsKey)
@@ -184,13 +204,42 @@ if (!selectedNodeIds) {
)
}
// Inject transform state for coordinate conversion
const transformState = inject('transformState') as
| {
camera: { z: number }
canvasToScreen: (point: { x: number; y: number }) => {
x: number
y: number
}
screenToCanvas: (point: { x: number; y: number }) => {
x: number
y: number
}
}
| undefined
// Computed selection state - only this node re-evaluates when its selection changes
const isSelected = computed(() => {
return selectedNodeIds.value.has(props.nodeData.id)
return selectedNodeIds.value.has(nodeData.id)
})
// Use execution state composable
const { executing, progress } = useNodeExecutionState(nodeData.id)
// Direct access to execution store for error state
const executionStore = useExecutionStore()
const hasExecutionError = computed(
() => executionStore.lastExecutionErrorNodeId === nodeData.id
)
// Computed error states for styling
const hasAnyError = computed(
(): boolean => !!(hasExecutionError.value || nodeData.hasErrors || error)
)
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => props.zoomLevel ?? 1)
const zoomRef = toRef(() => zoomLevel)
const {
lodLevel,
shouldRenderWidgets,
@@ -218,8 +267,20 @@ const {
zIndex,
startDrag,
handleDrag: handleLayoutDrag,
endDrag
} = useNodeLayout(props.nodeData.id)
endDrag,
resize
} = useNodeLayout(nodeData.id)
onMounted(() => {
if (size && transformState) {
const scale = transformState.camera.z
const screenSize = {
width: size.width * scale,
height: size.height * scale
}
resize(screenSize)
}
})
// Drag state for styling
const isDragging = ref(false)
@@ -232,11 +293,11 @@ const lastX = ref(0)
const DRAG_THRESHOLD_PX = 4
// Track collapsed state
const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
const isCollapsed = ref(nodeData.flags?.collapsed ?? false)
// Watch for external changes to the collapsed state
watch(
() => props.nodeData.flags?.collapsed,
() => nodeData.flags?.collapsed,
(newCollapsed: boolean | undefined) => {
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
isCollapsed.value = newCollapsed
@@ -244,11 +305,10 @@ watch(
}
)
// Check if node has custom content
// Check if node has custom content (like image outputs)
const hasCustomContent = computed(() => {
// Currently all content is handled through widgets
// This remains false but provides extensibility point
return false
// Show custom content if node has image outputs
return nodeImageUrls.value.length > 0
})
// Computed classes and conditions for better reusability
@@ -258,7 +318,7 @@ const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
() => shouldRenderWidgets.value && props.nodeData.widgets?.length
() => shouldRenderWidgets.value && nodeData.widgets?.length
)
const shouldShowContent = computed(
@@ -267,7 +327,7 @@ const shouldShowContent = computed(
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
if (!props.nodeData) {
if (!nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
@@ -294,13 +354,13 @@ const handlePointerUp = (event: PointerEvent) => {
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
emit('node-click', event, props.nodeData, wasDragging)
emit('node-click', event, nodeData, wasDragging)
}
const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed
emit('update:collapsed', props.nodeData.id, isCollapsed.value)
emit('update:collapsed', nodeData.id, isCollapsed.value)
}
const handleSlotClick = (
@@ -308,14 +368,58 @@ const handleSlotClick = (
slotIndex: number,
isInput: boolean
) => {
if (!props.nodeData) {
if (!nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
return
}
emit('slot-click', event, props.nodeData, slotIndex, isInput)
emit('slot-click', event, nodeData, slotIndex, isInput)
}
const handleTitleUpdate = (newTitle: string) => {
emit('update:title', props.nodeData.id, newTitle)
emit('update:title', nodeData.id, newTitle)
}
const nodeOutputs = useNodeOutputStore()
const nodeImageUrls = ref<string[]>([])
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
// Construct proper locator ID using subgraph ID from VueNodeData
const locatorId = nodeData.subgraphId
? `${nodeData.subgraphId}:${nodeData.id}`
: nodeData.id
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
if (!rootGraph) {
nodeImageUrls.value = []
return
}
const node = getNodeByLocatorId(rootGraph, locatorId)
if (node && newOutputs?.images?.length) {
const urls = nodeOutputs.getNodeImageUrls(node)
if (urls) {
nodeImageUrls.value = urls
}
} else {
// Clear URLs if no outputs or no images
nodeImageUrls.value = []
}
}
const nodeOutputLocatorId = computed(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
watch(
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
(newOutputs) => {
onNodeOutputsUpdate(newOutputs)
},
{ deep: true }
)
// Provide nodeImageUrls to child components
provide('nodeImageUrls', nodeImageUrls)
</script>

View File

@@ -5,28 +5,42 @@
<div v-else class="lg-node-content">
<!-- Default slot for custom content -->
<slot>
<!-- This component serves as a placeholder for future extensibility -->
<!-- Currently all node content is rendered through the widget system -->
<ImagePreview
v-if="hasImages"
:image-urls="props.imageUrls || []"
:node-id="nodeId"
class="mt-2"
/>
</slot>
</div>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import ImagePreview from './ImagePreview.vue'
interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
imageUrls?: string[]
}
defineProps<NodeContentProps>()
const props = defineProps<NodeContentProps>()
const hasImages = computed(() => props.imageUrls && props.imageUrls.length > 0)
// Get node ID from nodeData or node prop
const nodeId = computed(() => {
return props.nodeData?.id?.toString() || props.node?.id?.toString()
})
// Error boundary implementation
const renderError = ref<string | null>(null)

View File

@@ -18,7 +18,7 @@
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-[1px] text-[#888682] dark-theme:text-[#5B5E7D]"
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
></i>
</button>
@@ -74,15 +74,23 @@ const isEditing = ref(false)
const nodeInfo = computed(() => props.nodeData || props.node)
// Local state for title to provide immediate feedback
const displayTitle = ref(nodeInfo.value?.title || 'Untitled')
const resolveTitle = (info: LGraphNode | VueNodeData | undefined) => {
const title = (info?.title ?? '').trim()
if (title.length > 0) return title
const type = (info?.type ?? '').trim()
return type.length > 0 ? type : 'Untitled'
}
// Watch for external changes to the node title
// Local state for title to provide immediate feedback
const displayTitle = ref(resolveTitle(nodeInfo.value))
// Watch for external changes to the node title or type
watch(
() => nodeInfo.value?.title,
(newTitle) => {
if (newTitle && newTitle !== displayTitle.value) {
displayTitle.value = newTitle
() => [nodeInfo.value?.title, nodeInfo.value?.type] as const,
() => {
const next = resolveTitle(nodeInfo.value)
if (next !== displayTitle.value) {
displayTitle.value = next
}
}
)
@@ -109,7 +117,5 @@ const handleTitleEdit = (newTitle: string) => {
const handleTitleCancel = () => {
isEditing.value = false
// Reset displayTitle to the current node title
displayTitle.value = nodeInfo.value?.title || 'Untitled'
}
</script>

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