diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts index db63261528..ec5954e9b9 100644 --- a/browser_tests/tests/selectionToolboxSubmenus.spec.ts +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -87,7 +87,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => { const initialShape = await nodeRef.getProperty('shape') await openMoreOptions(comfyPage) - await comfyPage.page.getByText('Shape', { exact: true }).click() + await comfyPage.page.getByText('Shape', { exact: true }).hover() await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({ timeout: 5000 }) @@ -141,10 +141,12 @@ test.describe('Selection Toolbox - More Options Submenus', () => { await expect( comfyPage.page.getByText('Rename', { exact: true }) ).toBeVisible({ timeout: 5000 }) + await comfyPage.page.waitForTimeout(500) 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 }) diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 125418f3bd..56cdba0033 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -83,7 +83,6 @@ @@ -111,7 +110,6 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue' import NodeTooltip from '@/components/graph/NodeTooltip.vue' import SelectionToolbox from '@/components/graph/SelectionToolbox.vue' import TitleEditor from '@/components/graph/TitleEditor.vue' -import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue' import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue' import SideToolbar from '@/components/sidebar/SideToolbar.vue' import TopbarBadges from '@/components/topbar/TopbarBadges.vue' diff --git a/src/components/graph/NodeContextMenu.vue b/src/components/graph/NodeContextMenu.vue new file mode 100644 index 0000000000..f81a677d87 --- /dev/null +++ b/src/components/graph/NodeContextMenu.vue @@ -0,0 +1,179 @@ + + + diff --git a/src/components/graph/SelectionToolbox.test.ts b/src/components/graph/SelectionToolbox.test.ts index e8e689bb25..0938a3fe3f 100644 --- a/src/components/graph/SelectionToolbox.test.ts +++ b/src/components/graph/SelectionToolbox.test.ts @@ -136,6 +136,7 @@ describe('SelectionToolbox', () => { '
', props: ['pt', 'style', 'class'] }, + NodeContextMenu: { template: '
' }, InfoButton: { template: '
' }, ColorPickerButton: { template: diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index 06626ab1e1..a56a82cf93 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -42,6 +42,7 @@
+ diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue deleted file mode 100644 index 7bd18cfac9..0000000000 --- a/src/components/graph/selectionToolbox/NodeOptions.vue +++ /dev/null @@ -1,322 +0,0 @@ - - - diff --git a/src/components/input/SearchBox.vue b/src/components/input/SearchBox.vue index 1565bfcb31..adfeb3c367 100644 --- a/src/components/input/SearchBox.vue +++ b/src/components/input/SearchBox.vue @@ -90,4 +90,8 @@ const wrapperStyle = computed(() => { return cn(baseClasses, 'rounded-lg', sizeClasses) }) + +defineExpose({ + focusInput +}) diff --git a/src/composables/canvas/useSelectionToolboxPosition.ts b/src/composables/canvas/useSelectionToolboxPosition.ts index 0d15b3a129..734628bfb9 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.ts @@ -21,10 +21,10 @@ import { computeUnionBounds } from '@/utils/mathUtil' */ // 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) +const moreOptionsOpen = ref(false) +const forceCloseMoreOptionsSignal = ref(0) +const restoreMoreOptionsSignal = ref(0) +const moreOptionsRestorePending = ref(false) let moreOptionsWasOpenBeforeDrag = false let moreOptionsSelectionSignature: string | null = null diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts index 29d6fec919..5c9b5d1f62 100644 --- a/src/composables/graph/useMoreOptionsMenu.ts +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -2,8 +2,13 @@ import { computed, ref } from 'vue' import type { Ref } from 'vue' import type { LGraphGroup } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { isLGraphGroup } from '@/utils/litegraphUtil' +import { + buildStructuredMenu, + convertContextMenuToOptions +} from './contextMenuConverter' import { useGroupMenuOptions } from './useGroupMenuOptions' import { useImageMenuOptions } from './useImageMenuOptions' import { useNodeMenuOptions } from './useNodeMenuOptions' @@ -78,6 +83,19 @@ export function registerNodeOptionsInstance( nodeOptionsInstance = instance } +/** + * Mark menu options as coming from Vue hardcoded menu + */ +function markAsVueOptions(options: MenuOption[]): MenuOption[] { + return options.map((opt) => { + // Don't mark dividers or category labels + if (opt.type === 'divider' || opt.type === 'category') { + return opt + } + return { ...opt, source: 'vue' } + }) +} + /** * Composable for managing the More Options menu configuration * Refactored to use smaller, focused composables for better maintainability @@ -95,10 +113,11 @@ export function useMoreOptionsMenu() { computeSelectionFlags } = useSelectionState() + const canvasStore = useCanvasStore() + const { getImageMenuOptions } = useImageMenuOptions() const { getNodeInfoOption, - getAdjustSizeOption, getNodeVisualOptions, getPinOption, getBypassOption, @@ -106,16 +125,13 @@ export function useMoreOptionsMenu() { } = useNodeMenuOptions() const { getFitGroupToNodesOption, - getGroupShapeOptions, getGroupColorOptions, getGroupModeOptions } = useGroupMenuOptions() const { getBasicSelectionOptions, getSubgraphOptions, - getMultipleNodesOptions, - getDeleteOption, - getAlignmentOptions + getMultipleNodesOptions } = useSelectionMenuOptions() const hasSubgraphs = hasSubgraphsComputed @@ -142,80 +158,109 @@ export function useMoreOptionsMenu() { ? selectedGroups[0] : null const hasSubgraphsSelected = hasSubgraphs.value + + // For single node selection, also get LiteGraph menu items to merge + const litegraphOptions: MenuOption[] = [] + if ( + selectedNodes.value.length === 1 && + !groupContext && + canvasStore.canvas + ) { + try { + const node = selectedNodes.value[0] + const rawItems = canvasStore.canvas.getNodeMenuOptions(node) + // Don't apply structuring yet - we'll do it after merging with Vue options + litegraphOptions.push( + ...convertContextMenuToOptions(rawItems, node, false) + ) + } catch (error) { + console.error('Error getting LiteGraph menu items:', error) + } + } + const options: MenuOption[] = [] // Section 1: Basic selection operations (Rename, Copy, Duplicate) - options.push(...getBasicSelectionOptions()) + const basicOps = getBasicSelectionOptions() + options.push(...basicOps) options.push({ type: 'divider' }) - // Section 2: Node Info & Size Adjustment - if (nodeDef.value) { - options.push(getNodeInfoOption(showNodeHelp)) + // Section 2: Node actions (Run Branch, Pin, Bypass, Mute) + if (hasOutputNodesSelected.value) { + const runBranch = getRunBranchOption() + options.push(runBranch) } - - if (groupContext) { - options.push(getFitGroupToNodesOption(groupContext)) - } else { - options.push(getAdjustSizeOption()) + if (!groupContext) { + const pin = getPinOption(states, bump) + const bypass = getBypassOption(states, bump) + options.push(pin) + options.push(bypass) } - - // 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' }) + const groupModes = getGroupModeOptions(groupContext, bump) + options.push(...groupModes) } - - // 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) + // Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node) + const subgraphOps = getSubgraphOptions(hasSubgraphsSelected) + options.push(...subgraphOps) if (hasMultipleNodes.value) { - options.push(...getAlignmentOptions()) + const multiOps = getMultipleNodesOptions() + options.push(...multiOps) } - - // Section 10: Mode operations if (groupContext) { - // Group mode operations - options.push(...getGroupModeOptions(groupContext, bump)) + const fitGroup = getFitGroupToNodesOption(groupContext) + options.push(fitGroup) } else { - // Bypass option for nodes - options.push(getBypassOption(states, bump)) + // Add minimize/expand option only + const visualOptions = getNodeVisualOptions(states, bump) + if (visualOptions.length > 0) { + options.push(visualOptions[0]) // Minimize/Expand + } } + options.push({ type: 'divider' }) - // Section 11: Run Branch (if output nodes) - if (hasOutputNodesSelected.value) { - options.push(getRunBranchOption()) + // Section 4: Node properties (Node Info, Color) + if (nodeDef.value) { + const nodeInfo = getNodeInfoOption(showNodeHelp) + options.push(nodeInfo) + } + if (groupContext) { + const groupColor = getGroupColorOptions(groupContext, bump) + options.push(groupColor) + } else { + // Add shape and color options + const visualOptions = getNodeVisualOptions(states, bump) + if (visualOptions.length > 1) { + options.push(visualOptions[1]) // Shape (index 1) + } + if (visualOptions.length > 2) { + options.push(visualOptions[2]) // Color (index 2) + } } - - // Section 12: Final divider and Delete options.push({ type: 'divider' }) - options.push(getDeleteOption()) - return options + // Section 5: Node-specific options (image operations) + if (hasImageNode.value && selectedNodes.value.length > 0) { + const imageOps = getImageMenuOptions(selectedNodes.value[0]) + options.push(...imageOps) + options.push({ type: 'divider' }) + } + // Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu + + // Mark all Vue options with source + const markedVueOptions = markAsVueOptions(options) + // For single node selection, merge LiteGraph options with Vue options + // Vue options will take precedence during deduplication in buildStructuredMenu + if (litegraphOptions.length > 0) { + // Merge: LiteGraph options first, then Vue options (Vue will win in dedup) + const merged = [...litegraphOptions, ...markedVueOptions] + return buildStructuredMenu(merged) + } + // For other cases, structure the Vue options + const result = buildStructuredMenu(markedVueOptions) + return result }) // Computed property to get only menu items with submenus diff --git a/src/composables/graph/useNodeMenuOptions.ts b/src/composables/graph/useNodeMenuOptions.ts index c1d291a4d8..7e8cdfcf4e 100644 --- a/src/composables/graph/useNodeMenuOptions.ts +++ b/src/composables/graph/useNodeMenuOptions.ts @@ -73,6 +73,7 @@ export function useNodeMenuOptions() { icon: 'icon-[lucide--palette]', hasSubmenu: true, submenu: colorSubmenu.value, + isColorPicker: true, action: () => {} } ] @@ -96,7 +97,7 @@ export function useNodeMenuOptions() { label: states.bypassed ? t('contextMenu.Remove Bypass') : t('contextMenu.Bypass'), - icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]', + icon: 'icon-[lucide--redo-dot]', shortcut: 'Ctrl+B', action: () => { toggleNodeBypass() diff --git a/src/composables/graph/useSubmenuPositioning.ts b/src/composables/graph/useSubmenuPositioning.ts deleted file mode 100644 index 2dda2bd1cf..0000000000 --- a/src/composables/graph/useSubmenuPositioning.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 1d2e143657..a6b8c98a2a 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -126,6 +126,7 @@ "search": "Search", "searchPlaceholder": "Search...", "noResultsFound": "No Results Found", + "noResults": "No Results", "searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.", "noTasksFound": "No Tasks Found", "noTasksFoundMessage": "There are no tasks in the queue.", @@ -438,7 +439,8 @@ "Horizontal": "Horizontal", "Vertical": "Vertical", "new": "new", - "deprecated": "deprecated" + "deprecated": "deprecated", + "Extensions": "Extensions" }, "icon": { "bookmark": "Bookmark",