Floating Selection Toolbox Improvements (#5218)

* WIP

* WIP: UI design for right click menu

* feat: add composable for node customization and information handling

* fix: correct v-show directive in MaskEditorButton and enhance MoreOptions functionality

* feat: add selection and subgraph operations composables for enhanced graph management

* fix: update computed properties to use 'void' for non-reactive calls and add MenuOptionItem component

* feat: add composables for More Options menu and submenu positioning logic

* feat: refactor MoreOptions component to use MenuOptionItem for menu rendering and streamline submenu handling

* feat: implement SubmenuPopover component for enhanced submenu functionality and selection handling

* feat: add 'More Options' label and enhance shape options in localization file

* refactor: simplify shape name handling by removing Pascal case conversion and using localized names

* refactor: enhance submenu handling by dynamically setting refs and improving key assignment

* feat: implement useNodeArrangement composable for node alignment and distribution functionality

* feat: enhance useMoreOptionsMenu with image node operations and alignment options

* feat: localize context menu options and enhance submenu handling

* refactor: improve type safety for title assignment in selection operations and enhance color option retrieval in node customization

* fix: adjust component order in SelectionToolbox for improved layout

* feat: update FrameNodes button visibility and tooltip, and add localization for frameNodes

* feat: enhance button visibility logic in SelectionToolbox based on selection types

* refactor: reorganize properties panel option in More Options menu for single nodes

* remove excessive logging and alerts

* fix component tests

* ad browser tests

* feat: enhance popover behavior in MoreOptions component to manage visibility state during selection overlay changes

* refactor: update visibility logic for buttons in SelectionToolbox and ExecuteButton components

* refactor: remove duplicate shape option and clean up shapeOptions array

* refactor: update help toggle logic in InfoButton and useMoreOptionsMenu to manage sidebar and help state

* refactor: streamline node info handling and integrate output node filtering in useNodeInfo and useMoreOptionsMenu

* Added useSelectionState composable consolidating all selection-derived state and the node help toggle

* Updated toolbox buttons (InfoButton, BookmarkButton, BypassButton, MaskEditorButton, ConvertToSubgraphButton, PinButton, DeleteButton, ColorPickerButton, ExecuteButton, FrameNodes, Load3DViewerButton) to remove duplicated selection logic and use useSelectionState

* Introduced HideReason ('manual' | 'drag') to differentiate drag-induced hides from manual/outside hides in MoreOptions

* refactor: enhance popover visibility handling during drag events using canvas state

* fix: update shape option name from 'default' to 'box' and add localization for 'box'

* refactor: streamline BypassButton logic and enhance MoreOptions menu with state bumping

* refactor: remove toast notifications from subgraph operations for cleaner logic

* refactor: ensure menu options re-compute when selection flags change

* feat: Enhance MoreOptions behavior with drag-and-drop support

* fix: Update mask icon class for consistent styling in MaskEditorButton

* refactor: Standardize icon sizes and classes across selection toolbox buttons

* refactor: Update layout and styling in SelectionToolbox and MoreOptions components

* refactor: Improve selection toolbox behavior with more options state management

* Refactor: Remove unused imports and conditionally add subgraph option in menu

* Enhance popover behavior: add show/hide event handlers and improve positioning logic

* Cleanup: Remove debug comments from popover functions for clarity

* Refactor: Clean up FrameNodes component and add MenuOptionBadge for better option display

* Cleanup: Remove debug comments from useSelectionToolboxPosition for clarity

* Add useFrameNodes composable for grouping selected nodes

* Refactor: Update shape options in useNodeCustomization and localize frame nodes label

* fix tests

* Cleanup: Remove packageManager entry from package.json

* Refactor: Replace ILucide icons with named imports from lucide-vue-next

* Refactor: Update shape selection and improve color picker behavior in selection toolbox

* Update test expectations [skip ci]

* feat: Enhance More Options Menu for group node management and update localization strings

* refactor: Comment out PublishButton

* refactor: Comment out test for bookmark button visibility in SelectionToolbox

* refactor: Update class names for dark theme compatibility in ExecuteButton and MenuOptionItem components

* refactor: Modularize menu options by creating dedicated composables for group, image, node, and selection operations

* refactor: Update selectors in tests to match design changes

* refactor: Update help button selector in Node Help tests

* refactor: Update getGroupColorOptions to accept groupContext and bump parameters

* Update test expectations [skip ci]

* refactor: Center KSampler node before interaction in More Options submenu tests

* refactor: Adjust KSampler node positioning and simplify button click in More Options submenu tests

* refactor: Rename comfyPageFixture import for clarity

* refactor: use gap-1 instead of the explicit gap-[4px]

* refactor: Replace app.canvas with canvasStore.getCanvas for  state management

* refactor: Simplify prop access by removing 'props.' prefix in MenuOptionItem component

* refactor: Remove explicit type annotation for item in buildSelectionSignature function

* refactor: Replace Lucide icons with string-based icon references in menu options

* refactor: Remove export from interface declarations for improved clarity

* refactor: Simplify class binding in BypassButton component for improved readability

* refactor: Update button class for consistent sizing in ExecuteButton component

* refactor: Update help button locator class for consistency in Node Help tests

* fix node help test

* refactor: Remove unused imports and simplify visibility conditions in selection toolbox components

* feat: Add 3D node selection logic and cleanup on unmount for selection toolbox

* refactor: Update help button locator to use consistent data-testid in Node Help tests

* fix: Correct help button locator syntax in Node Help tests

* refactor: Change resetMoreOptionsState to an internal function in useSelectionToolboxPosition

* test: Add Load3D node visibility logic for ColorPickerButton and remove redundant test case

* fix: Increase tooltip show delay for ColorPickerButton

* fix: Update selectedOutputNodes computation to filter by isLGraphNode

* fix: Remove unused nodeDef reference from InfoButton and submenu trigger from MenuOptionItem

* fix: Update showInfoButton logic to depend on nodeDef value

* refactor: Remove deprecated getBasicNodeOptions function for cleaner code

* refactor: Replace useNodeInfo with useSelectedNodeActions

* refactor: Integrate useNodeDefStore for improved node definition handling in SelectionToolbox and InfoButton tests

* refactor: Introduce useCanvasRefresh composable for consistent canvas refresh logic across node operations

* refactor: Remove irrelevant append-to attribute from Popover

* refactor: Use storeToRefs for selectedItems in useSelectionState and add tests for selection logic

* refactor: Update ExecuteButton to use hasOutputNodesSelected for visibility and remove unnecessary computed property

* refactor: move display of execution button tests to selectionToolbox

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Johnpaul Chiwetelu
2025-09-14 06:52:30 +01:00
committed by GitHub
parent f6a115e182
commit ac107b45ea
47 changed files with 3989 additions and 205 deletions

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: 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,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,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 { useExtensionService } from '@/services/extensionService'
import { useCanvasStore } from '@/stores/graphStore'
// 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 { 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

@@ -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 { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
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

@@ -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,10 +50,13 @@
<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,
@@ -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 { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
// Mock the stores
vi.mock('@/stores/graphStore', () => ({
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 { 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 '@/stores/graphStore'
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

@@ -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

@@ -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'
@@ -8,12 +8,42 @@ import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
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

@@ -0,0 +1,22 @@
// call nextTick on all changeTracker
import { useCanvasStore } from '@/stores/graphStore'
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 { app } from '@/scripts/app'
import { useTitleEditorStore } from '@/stores/graphStore'
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

@@ -0,0 +1,199 @@
import { useI18n } from 'vue-i18n'
import {
LGraphEventMode,
type LGraphGroup,
type LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
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,122 @@
import { useI18n } from 'vue-i18n'
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')
const a = document.createElement('a')
a.href = url.toString()
a.setAttribute(
'download',
new URLSearchParams(url.search).get('filename') ?? 'image.png'
)
a.style.display = 'none'
document.body.appendChild(a)
a.click()
requestAnimationFrame(() => {
if (document.body.contains(a)) {
document.body.removeChild(a)
}
})
} 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 '@/stores/graphStore'
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 '@/stores/graphStore'
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,165 @@
// import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' // Unused for now
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
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,144 @@
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 '@/stores/graphStore'
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) => 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) => 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 '@/stores/graphStore'
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

@@ -9,6 +9,7 @@
"import": "Import",
"loadAllFolders": "Load All Folders",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
@@ -30,7 +31,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 +160,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 +315,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 +496,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",
@@ -1753,7 +1795,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

@@ -0,0 +1,270 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { type Ref, ref } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
// Test interfaces
interface TestNodeConfig {
type?: string
mode?: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
}
interface TestNode {
type: string
mode: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
isSubgraphNode: () => boolean
}
type MockedItem = TestNode | { type: string; isNode: boolean }
// Mock all stores
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: vi.fn()
}))
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: vi.fn()
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
filterOutputNodes: vi.fn()
}))
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
return {
type: config.type || 'TestNode',
mode: config.mode || LGraphEventMode.ALWAYS,
flags: config.flags,
pinned: config.pinned,
removable: config.removable,
isSubgraphNode: () => false
}
}
// Mock comment/connection objects
const mockComment = { type: 'comment', isNode: false }
const mockConnection = { type: 'connection', isNode: false }
describe('useSelectionState', () => {
// Mock store instances
let mockSelectedItems: Ref<MockedItem[]>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Setup mock canvas store with proper ref
mockSelectedItems = ref([])
vi.mocked(useCanvasStore).mockReturnValue({
selectedItems: mockSelectedItems,
// Add minimal required properties for the store
$id: 'canvas',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node def store
vi.mocked(useNodeDefStore).mockReturnValue({
fromLGraphNode: vi.fn((node: TestNode) => {
if (node?.type === 'TestNode') {
return { nodePath: 'test.TestNode', name: 'TestNode' }
}
return null
}),
// Add minimal required properties for the store
$id: 'nodeDef',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock sidebar tab store
const mockToggleSidebarTab = vi.fn()
vi.mocked(useSidebarTabStore).mockReturnValue({
activeSidebarTabId: null,
toggleSidebarTab: mockToggleSidebarTab,
// Add minimal required properties for the store
$id: 'sidebarTab',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node help store
const mockOpenHelp = vi.fn()
const mockCloseHelp = vi.fn()
const mockNodeHelpStore = {
isHelpOpen: false,
currentHelpNode: null,
openHelp: mockOpenHelp,
closeHelp: mockCloseHelp,
// Add minimal required properties for the store
$id: 'nodeHelp',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
}
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
id: 'node-library-tab',
title: 'Node Library',
type: 'custom',
render: () => null
} as any)
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
const typedItem = item as { isNode?: boolean }
return typedItem?.isNode !== false
})
vi.mocked(isImageNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(filterOutputNodes).mockImplementation(
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
)
})
describe('Selection Detection', () => {
test('should return false when nothing selected', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(false)
})
test('should return true when items selected', () => {
// Update the mock data before creating the composable
const node1 = createTestNode()
const node2 = createTestNode()
mockSelectedItems.value = [node1, node2]
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
test('should pick only LGraphNodes from mixed selections', () => {
// Update the mock data before creating the composable
const graphNode = createTestNode()
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
const { selectedNodes } = useSelectionState()
expect(selectedNodes.value).toHaveLength(1)
expect(selectedNodes.value[0]).toEqual(graphNode)
})
})
describe('Node State Computation', () => {
test('should detect bypassed nodes', () => {
// Update the mock data before creating the composable
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
mockSelectedItems.value = [bypassedNode]
const { selectedNodes } = useSelectionState()
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isBypassed).toBe(true)
})
test('should detect pinned/collapsed states', () => {
// Update the mock data before creating the composable
const pinnedNode = createTestNode({ pinned: true })
const collapsedNode = createTestNode({ flags: { collapsed: true } })
mockSelectedItems.value = [pinnedNode, collapsedNode]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(true)
expect(isBypassed).toBe(false)
})
test('should provide non-reactive state computation', () => {
// Update the mock data before creating the composable
const node = createTestNode({ pinned: true })
mockSelectedItems.value = [node]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(false)
expect(isBypassed).toBe(false)
// Test with empty selection using new composable instance
mockSelectedItems.value = []
const { selectedNodes: newSelectedNodes } = useSelectionState()
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
})
})