Files
ComfyUI_frontend/src/composables/graph/useMoreOptionsMenu.ts
Johnpaul Chiwetelu 552452622d fix: context menu not opening when right-clicking different Vue nodes (#7644)
## Summary
- Fixed issue where right-clicking on one Vue node then another would
close the context menu instead of repositioning it
- Added `showNodeOptions` function that always shows the menu at the new
position (used for contextmenu events)
- Kept `toggleNodeOptions` for the "More Options" button where toggle
behavior is expected

## Test plan
- [ ] Right-click on a Vue node to open context menu
- [ ] Right-click on a different Vue node - menu should immediately show
for the new node
- [ ] Click "More Options" button when menu is open - should close the
menu
- [ ] Click "More Options" button when menu is closed - should open the
menu



https://github.com/user-attachments/assets/bb31c2e4-12b4-4786-96ac-23b1e2b4daa0



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7644-fix-context-menu-not-opening-when-right-clicking-different-Vue-nodes-2ce6d73d36508145bc30fe6947b6808a)
by [Unito](https://www.unito.io)
2025-12-19 22:36:05 +01:00

280 lines
7.9 KiB
TypeScript

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'
import { useSelectionMenuOptions } from './useSelectionMenuOptions'
import { useSelectionState } from './useSelectionState'
export interface MenuOption {
label?: string
icon?: string
shortcut?: string
hasSubmenu?: boolean
type?: 'divider' | 'category'
action?: () => void
submenu?: SubMenuOption[]
badge?: BadgeVariant
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
}
export interface SubMenuOption {
label: string
icon?: string
action: () => void
color?: string
disabled?: boolean
}
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
}
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
/**
* Toggle the node options popover
* @param event - The trigger event
*/
export function toggleNodeOptions(event: Event) {
if (nodeOptionsInstance?.toggle) {
nodeOptionsInstance.toggle(event)
}
}
/**
* Show the node options popover (always shows, doesn't toggle)
* Use this for contextmenu events where we always want to show at the new position
* @param event - The trigger event (must be MouseEvent for position)
*/
export function showNodeOptions(event: MouseEvent) {
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
}
}
/**
* Hide the node options popover
*/
interface NodeOptionsInstance {
toggle: (event: Event) => void
show: (event: MouseEvent) => void
hide: () => void
isOpen: Ref<boolean>
}
/**
* Register the NodeOptions component instance
* @param instance - The NodeOptions component instance
*/
export function registerNodeOptionsInstance(
instance: null | NodeOptionsInstance
) {
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
*/
export function useMoreOptionsMenu() {
const {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
hasSubgraphs: hasSubgraphsComputed,
hasImageNode,
hasOutputNodesSelected,
hasMultipleSelection,
computeSelectionFlags
} = useSelectionState()
const canvasStore = useCanvasStore()
const { getImageMenuOptions } = useImageMenuOptions()
const {
getNodeInfoOption,
getNodeVisualOptions,
getPinOption,
getBypassOption,
getRunBranchOption
} = useNodeMenuOptions()
const {
getFitGroupToNodesOption,
getGroupColorOptions,
getGroupModeOptions
} = useGroupMenuOptions()
const {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions
} = 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
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
// 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)
const basicOps = getBasicSelectionOptions()
options.push(...basicOps)
options.push({ type: 'divider' })
// Section 2: Node actions (Run Branch, Pin, Bypass, Mute)
if (hasOutputNodesSelected.value) {
const runBranch = getRunBranchOption()
options.push(runBranch)
}
if (!groupContext) {
const pin = getPinOption(states, bump)
const bypass = getBypassOption(states, bump)
options.push(pin)
options.push(bypass)
}
if (groupContext) {
const groupModes = getGroupModeOptions(groupContext, bump)
options.push(...groupModes)
}
options.push({ type: 'divider' })
// Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node)
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)
if (hasMultipleNodes.value) {
options.push(...getMultipleNodesOptions())
}
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))
} else {
// Node context: Expand/Minimize
const visualOptions = getNodeVisualOptions(states, bump)
if (visualOptions.length > 0) {
options.push(visualOptions[0]) // Expand/Minimize (index 0)
}
}
options.push({ type: 'divider' })
// Section 4: Node properties (Node Info, Shape, Color)
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
}
if (groupContext) {
options.push(getGroupColorOptions(groupContext, bump))
} 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)
}
}
options.push({ type: 'divider' })
// Section 5: Image operations (if image node)
if (hasImageNode.value && selectedNodes.value.length > 0) {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
// Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu
// Mark all Vue options with source
const markedVueOptions = markAsVueOptions(options)
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
const menuOptionsWithSubmenu = computed(() =>
menuOptions.value.filter((option) => option.hasSubmenu && option.submenu)
)
return {
menuOptions,
menuOptionsWithSubmenu,
bump,
hasSubgraphs,
registerNodeOptionsInstance
}
}