diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index 68ce7b8d5..764849286 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -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() diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts index 4a390af96..05bb578df 100644 --- a/browser_tests/tests/remoteWidgets.spec.ts +++ b/browser_tests/tests/remoteWidgets.spec.ts @@ -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() }) diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png index 7aa22906b..96f6507e1 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png index 41bb283d9..af92221f3 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png index a9d9bafce..f9b9b012c 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts new file mode 100644 index 000000000..a7311c15a --- /dev/null +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -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('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('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('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('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('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() + }) +}) diff --git a/src/assets/icons/custom/mask.svg b/src/assets/icons/custom/mask.svg new file mode 100644 index 000000000..1e1a6d97c --- /dev/null +++ b/src/assets/icons/custom/mask.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/graph/SelectionToolbox.spec.ts b/src/components/graph/SelectionToolbox.spec.ts new file mode 100644 index 000000000..2e1bd77cb --- /dev/null +++ b/src/components/graph/SelectionToolbox.spec.ts @@ -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 + + 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: + '
', + props: ['pt', 'style', 'class'] + }, + InfoButton: { template: '
' }, + ColorPickerButton: { + template: + '', + 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) + }) +}) diff --git a/src/components/graph/selectionToolbox/InfoButton.vue b/src/components/graph/selectionToolbox/InfoButton.vue new file mode 100644 index 000000000..3fd159d89 --- /dev/null +++ b/src/components/graph/selectionToolbox/InfoButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue index b207e5018..5187f0c02 100644 --- a/src/components/graph/selectionToolbox/Load3DViewerButton.vue +++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue @@ -1,6 +1,5 @@ diff --git a/src/components/graph/selectionToolbox/MoreOptions.vue b/src/components/graph/selectionToolbox/MoreOptions.vue new file mode 100644 index 000000000..f40a49b60 --- /dev/null +++ b/src/components/graph/selectionToolbox/MoreOptions.vue @@ -0,0 +1,316 @@ + + + diff --git a/src/components/graph/selectionToolbox/PinButton.vue b/src/components/graph/selectionToolbox/PinButton.vue deleted file mode 100644 index 86598339b..000000000 --- a/src/components/graph/selectionToolbox/PinButton.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue index 786fe511f..0da7364a0 100644 --- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue +++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue @@ -1,17 +1,22 @@ diff --git a/src/components/graph/selectionToolbox/SubmenuPopover.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue new file mode 100644 index 000000000..056f0f90b --- /dev/null +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/components/graph/selectionToolbox/VerticalDivider.vue b/src/components/graph/selectionToolbox/VerticalDivider.vue new file mode 100644 index 000000000..dc6876a3e --- /dev/null +++ b/src/components/graph/selectionToolbox/VerticalDivider.vue @@ -0,0 +1,3 @@ + diff --git a/src/composables/canvas/useSelectionToolboxPosition.ts b/src/composables/canvas/useSelectionToolboxPosition.ts index efb8d5ca7..69a44631c 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.ts @@ -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 +): 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 +) { + if (!moreOptionsSelectionSignature) return false + return buildSelectionSignature(store) === moreOptionsSelectionSignature +} + export function useSelectionToolboxPosition( toolboxRef: Ref ) { @@ -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 +} diff --git a/src/composables/graph/useCanvasRefresh.ts b/src/composables/graph/useCanvasRefresh.ts new file mode 100644 index 000000000..c41000da1 --- /dev/null +++ b/src/composables/graph/useCanvasRefresh.ts @@ -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 + } +} diff --git a/src/composables/graph/useFrameNodes.ts b/src/composables/graph/useFrameNodes.ts new file mode 100644 index 000000000..294812dc3 --- /dev/null +++ b/src/composables/graph/useFrameNodes.ts @@ -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 } +} diff --git a/src/composables/graph/useGroupMenuOptions.ts b/src/composables/graph/useGroupMenuOptions.ts new file mode 100644 index 000000000..fcfc1c9f4 --- /dev/null +++ b/src/composables/graph/useGroupMenuOptions.ts @@ -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 + } +} diff --git a/src/composables/graph/useImageMenuOptions.ts b/src/composables/graph/useImageMenuOptions.ts new file mode 100644 index 000000000..ac613629f --- /dev/null +++ b/src/composables/graph/useImageMenuOptions.ts @@ -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((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 + } +} diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts new file mode 100644 index 000000000..71c4a9254 --- /dev/null +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -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 + } +} diff --git a/src/composables/graph/useNodeArrangement.ts b/src/composables/graph/useNodeArrangement.ts new file mode 100644 index 000000000..4d1808dea --- /dev/null +++ b/src/composables/graph/useNodeArrangement.ts @@ -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 + } +} diff --git a/src/composables/graph/useNodeCustomization.ts b/src/composables/graph/useNodeCustomization.ts new file mode 100644 index 000000000..703853ecf --- /dev/null +++ b/src/composables/graph/useNodeCustomization.ts @@ -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 + } +} diff --git a/src/composables/graph/useNodeMenuOptions.ts b/src/composables/graph/useNodeMenuOptions.ts new file mode 100644 index 000000000..c1d291a4d --- /dev/null +++ b/src/composables/graph/useNodeMenuOptions.ts @@ -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 + } +} diff --git a/src/composables/graph/useSelectedNodeActions.ts b/src/composables/graph/useSelectedNodeActions.ts new file mode 100644 index 000000000..9c37800f6 --- /dev/null +++ b/src/composables/graph/useSelectedNodeActions.ts @@ -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 + } +} diff --git a/src/composables/graph/useSelectionMenuOptions.ts b/src/composables/graph/useSelectionMenuOptions.ts new file mode 100644 index 000000000..0a90df1f8 --- /dev/null +++ b/src/composables/graph/useSelectionMenuOptions.ts @@ -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 + } +} diff --git a/src/composables/graph/useSelectionOperations.ts b/src/composables/graph/useSelectionOperations.ts new file mode 100644 index 000000000..649badc30 --- /dev/null +++ b/src/composables/graph/useSelectionOperations.ts @@ -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 + } +} diff --git a/src/composables/graph/useSelectionState.ts b/src/composables/graph/useSelectionState.ts new file mode 100644 index 000000000..c9a3a4a6f --- /dev/null +++ b/src/composables/graph/useSelectionState.ts @@ -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(() => + 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 + } +} diff --git a/src/composables/graph/useSubgraphOperations.ts b/src/composables/graph/useSubgraphOperations.ts new file mode 100644 index 000000000..c4d33bfe5 --- /dev/null +++ b/src/composables/graph/useSubgraphOperations.ts @@ -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 + } +} diff --git a/src/composables/graph/useSubmenuPositioning.ts b/src/composables/graph/useSubmenuPositioning.ts new file mode 100644 index 000000000..2dda2bd1c --- /dev/null +++ b/src/composables/graph/useSubmenuPositioning.ts @@ -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 // Component instances + ): Promise => { + 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, // 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 + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 7b1f06b29..0f98ee103 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/tests-ui/tests/composables/graph/useSelectionState.test.ts b/tests-ui/tests/composables/graph/useSelectionState.test.ts new file mode 100644 index 000000000..8d0ff56e8 --- /dev/null +++ b/tests-ui/tests/composables/graph/useSelectionState.test.ts @@ -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 + + 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) + }) + }) +})