[feat] Replace NodeOptions with PrimeVue ContextMenu (#7114)

## Summary
- Add `NodeContextMenu.vue` using PrimeVue ContextMenu component with
native submenu support
- Rename `SubmenuPopover.vue` to `ColorPickerMenu.vue` (specialized for
color picker)
- Delete old components: `NodeOptions.vue`, `MenuOptionItem.vue`,
`useSubmenuPositioning.ts`
- Wire up context menu converter in `useMoreOptionsMenu.ts`
- Update tests to use hover instead of click for submenus

## Dependencies
**This PR depends on #7113** - the context menu converter infrastructure
PR. It should be merged after that PR.

## Benefits
- Native PrimeVue submenu support with proper keyboard navigation
- Constrained menu dimensions with overflow scrolling (max-h-[80vh])
- Cleaner component architecture with ~280 fewer lines of code
- Better separation: ColorPickerMenu handles only the custom color
picker UI

## Test plan
- [x] Typecheck passes
- [x] Lint passes
- [x] Knip passes
- [ ] Browser tests for submenu interactions pass
- [ ] Manual testing of node context menu

## Screenshots
(Menu UI should look the same, with improved submenu behavior)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7114-feat-Replace-NodeOptions-with-PrimeVue-ContextMenu-2be6d73d365081fda576fd691175eacf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Johnpaul Chiwetelu
2025-12-17 06:47:28 +01:00
committed by GitHub
parent 8d7dd9ed67
commit e21f43f398
15 changed files with 410 additions and 657 deletions

View File

@@ -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'
@@ -43,15 +48,10 @@ let nodeOptionsInstance: null | NodeOptionsInstance = null
/**
* Toggle the node options popover
* @param event - The trigger event
* @param element - The target element (button) that triggered the popover
*/
export function toggleNodeOptions(
event: Event,
element: HTMLElement,
clickedFromToolbox: boolean = false
) {
export function toggleNodeOptions(event: Event) {
if (nodeOptionsInstance?.toggle) {
nodeOptionsInstance.toggle(event, element, clickedFromToolbox)
nodeOptionsInstance.toggle(event)
}
}
@@ -59,11 +59,7 @@ export function toggleNodeOptions(
* Hide the node options popover
*/
interface NodeOptionsInstance {
toggle: (
event: Event,
element: HTMLElement,
clickedFromToolbox: boolean
) => void
toggle: (event: Event) => void
hide: () => void
isOpen: Ref<boolean>
}
@@ -78,6 +74,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 +104,11 @@ export function useMoreOptionsMenu() {
computeSelectionFlags
} = useSelectionState()
const canvasStore = useCanvasStore()
const { getImageMenuOptions } = useImageMenuOptions()
const {
getNodeInfoOption,
getAdjustSizeOption,
getNodeVisualOptions,
getPinOption,
getBypassOption,
@@ -106,16 +116,13 @@ export function useMoreOptionsMenu() {
} = useNodeMenuOptions()
const {
getFitGroupToNodesOption,
getGroupShapeOptions,
getGroupColorOptions,
getGroupModeOptions
} = useGroupMenuOptions()
const {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions,
getDeleteOption,
getAlignmentOptions
getMultipleNodesOptions
} = useSelectionMenuOptions()
const hasSubgraphs = hasSubgraphsComputed
@@ -142,85 +149,107 @@ 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) {
const pin = getPinOption(states, bump)
const bypass = getBypassOption(states, bump)
options.push(pin)
options.push(bypass)
}
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))
} else {
options.push(getAdjustSizeOption())
const groupModes = getGroupModeOptions(groupContext, bump)
options.push(...groupModes)
}
options.push({ type: 'divider' })
// 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
// Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node)
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)
// 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))
options.push(getFitGroupToNodesOption(groupContext))
} else {
// Bypass option for nodes
options.push(getBypassOption(states, bump))
// Node context: Expand/Minimize
const visualOptions = getNodeVisualOptions(states, bump)
if (visualOptions.length > 0) {
options.push(visualOptions[0]) // Expand/Minimize (index 0)
}
}
// 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
// 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