[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

@@ -85,7 +85,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
const initialShape = await nodeRef.getProperty<number>('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
})
@@ -136,13 +136,18 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
await expect(renameItem).toBeVisible({ timeout: 5000 })
// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
for (let i = 0; i < 30; i++) {
await comfyPage.nextFrame()
}
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 })

View File

@@ -87,7 +87,6 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeOptions />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -115,7 +114,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 NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'

View File

@@ -0,0 +1,272 @@
<template>
<ContextMenu
ref="contextMenu"
:model="menuItems"
class="max-h-[80vh] md:max-h-none overflow-y-auto md:overflow-y-visible"
@show="onMenuShow"
@hide="onMenuHide"
>
<template #item="{ item, props, hasSubmenu }">
<a
v-bind="props.action"
class="flex items-center gap-2 px-3 py-1.5"
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
>
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
<span class="flex-1">{{ item.label }}</span>
<span
v-if="item.shortcut"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xs"
>
{{ item.shortcut }}
</span>
<i
v-if="hasSubmenu || item.isColorSubmenu"
class="icon-[lucide--chevron-right] size-4 opacity-60"
/>
</a>
</template>
</ContextMenu>
<!-- Color picker menu (custom with color circles) -->
<ColorPickerMenu
v-if="colorOption"
ref="colorPickerMenu"
key="color-picker-menu"
:option="colorOption"
@submenu-click="handleColorSelect"
/>
</template>
<script setup lang="ts">
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import {
registerNodeOptionsInstance,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
interface ExtendedMenuItem extends MenuItem {
isColorSubmenu?: boolean
shortcut?: string
originalOption?: MenuOption
}
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
const canvasStore = useCanvasStore()
// World position (canvas coordinates) where menu was opened
const worldPosition = ref({ x: 0, y: 0 })
// Get canvas bounding rect reactively
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
// Track last canvas transform to detect actual changes
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
// Update menu position based on canvas transform
const updateMenuPosition = () => {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
// Only update if canvas transform actually changed
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
// Convert world position to screen position
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
// Update menu position
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
// Sync with canvas transform using requestAnimationFrame
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
// Start/stop syncing based on menu visibility
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
// Close on touch outside to handle mobile devices where click might be swallowed
useEventListener(
window,
'touchstart',
(event: TouchEvent) => {
if (!isOpen.value || !contextMenu.value) return
const target = event.target as Node
const contextMenuInstance = contextMenu.value as unknown as {
container?: HTMLElement
$el?: HTMLElement
}
const menuEl = contextMenuInstance.container || contextMenuInstance.$el
if (menuEl && !menuEl.contains(target)) {
hide()
}
},
{ passive: true }
)
// Find color picker option
const colorOption = computed(() =>
menuOptions.value.find((opt) => opt.isColorPicker)
)
// Check if option is the color picker
function isColorOption(option: MenuOption): boolean {
return Boolean(option.isColorPicker)
}
// Convert MenuOption to PrimeVue MenuItem
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
if (option.type === 'divider') return { separator: true }
const isColor = isColorOption(option)
const item: ExtendedMenuItem = {
label: option.label,
icon: option.icon,
disabled: option.disabled,
shortcut: option.shortcut,
isColorSubmenu: isColor,
originalOption: option
}
// Native submenus for non-color options
if (option.hasSubmenu && option.submenu && !isColor) {
item.items = option.submenu.map((sub) => ({
label: sub.label,
icon: sub.icon,
disabled: sub.disabled,
command: () => {
sub.action()
hide()
}
}))
}
// Regular action items
if (!option.hasSubmenu && option.action) {
item.command = () => {
option.action?.()
hide()
}
}
return item
}
// Build menu items
const menuItems = computed<ExtendedMenuItem[]>(() =>
menuOptions.value.map(convertToMenuItem)
)
// Show context menu
function show(event: MouseEvent) {
bump()
// Convert screen position to world coordinates
// Screen position relative to canvas = event position - canvas offset
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
// Convert to world coordinates using canvas transform
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
isOpen.value = true
contextMenu.value?.show(event)
}
// Hide context menu
function hide() {
contextMenu.value?.hide()
}
function toggle(event: Event) {
if (isOpen.value) {
hide()
} else {
show(event as MouseEvent)
}
}
defineExpose({ toggle, hide, isOpen, show })
function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
colorPickerMenu.value?.toggle(event, target)
}
// Handle color selection
function handleColorSelect(subOption: SubMenuOption) {
subOption.action()
hide()
}
function onMenuShow() {
isOpen.value = true
}
function onMenuHide() {
isOpen.value = false
}
onMounted(() => {
registerNodeOptionsInstance({ toggle, hide, isOpen })
})
onUnmounted(() => {
registerNodeOptionsInstance(null)
})
</script>

View File

@@ -136,6 +136,7 @@ describe('SelectionToolbox', () => {
'<div class="panel selection-toolbox absolute left-1/2 rounded-lg"><slot /></div>',
props: ['pt', 'style', 'class']
},
NodeContextMenu: { template: '<div class="node-context-menu" />' },
InfoButton: { template: '<div class="info-button" />' },
ColorPickerButton: {
template:

View File

@@ -42,6 +42,7 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>
<script setup lang="ts">
@@ -68,6 +69,7 @@ import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'

View File

@@ -1,6 +1,6 @@
<template>
<Popover
ref="popover"
ref="popoverRef"
:auto-z-index="true"
:base-z-index="1100"
:dismissable="true"
@@ -34,7 +34,10 @@
'hover:bg-secondary-background-hover rounded cursor-pointer',
isColorSubmenu
? 'w-7 h-7 flex items-center justify-center'
: 'flex items-center gap-2 px-3 py-1.5 text-sm'
: 'flex items-center gap-2 px-3 py-1.5 text-sm',
subOption.disabled
? 'cursor-not-allowed pointer-events-none text-node-icon-disabled'
: 'hover:bg-secondary-background-hover'
)
"
:title="subOption.label"
@@ -82,23 +85,21 @@ const emit = defineEmits<Emits>()
const { getCurrentShape } = useNodeCustomization()
const popover = ref<InstanceType<typeof Popover>>()
const popoverRef = ref<InstanceType<typeof Popover>>()
const show = (event: Event, target?: HTMLElement) => {
popover.value?.show(event, target)
const toggle = (event: Event, target?: HTMLElement) => {
popoverRef.value?.toggle(event, target)
}
const hide = () => {
popover.value?.hide()
}
defineExpose({
show,
hide
toggle
})
const handleSubmenuClick = (subOption: SubMenuOption) => {
if (subOption.disabled) {
return
}
emit('submenu-click', subOption)
popoverRef.value?.hide()
}
const isShapeSelected = (subOption: SubMenuOption): boolean => {

View File

@@ -1,62 +0,0 @@
<template>
<div v-if="option.type === 'divider'" class="my-1 h-px bg-border-default" />
<div
v-else
role="button"
class="group flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm text-text-primary hover:bg-interface-menu-component-surface-hovered"
@click="handleClick"
>
<i v-if="option.icon" :class="[option.icon, 'h-4 w-4']" />
<span class="flex-1">{{ option.label }}</span>
<span
v-if="option.shortcut"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xxs"
>
{{ option.shortcut }}
</span>
<i
v-if="option.hasSubmenu"
:size="14"
class="icon-[lucide--chevron-right] opacity-60"
/>
<Badge
v-if="option.badge"
:severity="option.badge === 'new' ? 'info' : 'secondary'"
:value="t(option.badge)"
:class="
cn(
'h-4 gap-2.5 px-1 text-[9px] text-base-foreground uppercase rounded-4xl',
{
'bg-primary-background': option.badge === 'new',
'bg-secondary-background': option.badge === 'deprecated'
}
)
"
/>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Badge from 'primevue/badge'
import { useI18n } from 'vue-i18n'
import type { MenuOption } from '@/composables/graph/useMoreOptionsMenu'
const { t } = useI18n()
interface Props {
option: MenuOption
}
interface Emits {
(e: 'click', option: MenuOption, event: Event): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const handleClick = (event: Event) => {
emit('click', props.option, event)
}
</script>

View File

@@ -1,322 +0,0 @@
<template>
<div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50 w-[300px]'
},
content: {
class: [
'mt-2 text-base-foreground rounded-lg',
'shadow-lg border border-border-default',
'bg-interface-panel-surface'
]
}
}"
@show="onPopoverShow"
@hide="onPopoverHide"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<div class="flex min-w-48 flex-col p-2">
<MenuOptionItem
v-for="(option, index) in menuOptions"
:key="option.label || `divider-${index}`"
:option="option"
@click="handleOptionClick"
/>
</div>
</Popover>
<SubmenuPopover
v-for="option in menuOptionsWithSubmenu"
:key="`submenu-${option.label}`"
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
:option="option"
@submenu-click="handleSubmenuClick"
/>
</div>
</template>
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import Popover from 'primevue/popover'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import {
forceCloseMoreOptionsSignal,
moreOptionsOpen,
moreOptionsRestorePending,
restoreMoreOptionsSignal
} from '@/composables/canvas/useSelectionToolboxPosition'
import {
registerNodeOptionsInstance,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'
const popover = ref<InstanceType<typeof Popover>>()
const targetElement = ref<HTMLElement | null>(null)
const isTriggeredByToolbox = ref<boolean>(true)
// Track open state ourselves so we can restore after drag/move
const isOpen = ref(false)
const wasOpenBeforeHide = ref(false)
// Track why the popover was hidden so we only auto-reopen after drag.
type HideReason = 'manual' | 'drag'
const lastProgrammaticHideReason = ref<HideReason | null>(null)
const submenuRefs = ref<Record<string, InstanceType<typeof SubmenuPopover>>>({})
const currentSubmenu = ref<string | null>(null)
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
const canvasInteractions = useCanvasInteractions()
let lastLogTs = 0
const LOG_INTERVAL = 120 // ms
let overlayElCache: HTMLElement | null = null
function resolveOverlayEl(): HTMLElement | null {
// Prefer cached element (cleared on hide)
if (overlayElCache && overlayElCache.isConnected) return overlayElCache
// PrimeVue Popover root element (component instance $el)
const direct = (popover.value as any)?.$el
if (direct instanceof HTMLElement) {
overlayElCache = direct
return direct
}
// Fallback: try to locate a recent popover root near the button (same z-index class + absolute)
const btn = targetElement.value
if (btn) {
const candidates = Array.from(
document.querySelectorAll('div.absolute.z-50')
) as HTMLElement[]
// Heuristic: pick the one closest (vertically) below the button
const rect = btn.getBoundingClientRect()
let best: { el: HTMLElement; dist: number } | null = null
for (const el of candidates) {
const r = el.getBoundingClientRect()
const dist = Math.abs(r.top - rect.bottom)
if (!best || dist < best.dist) best = { el, dist }
}
if (best && best.el) {
overlayElCache = best.el
return best.el
}
}
return null
}
const repositionPopover = () => {
if (!isOpen.value) return
const btn = targetElement.value
const overlayEl = resolveOverlayEl()
if (!btn || !overlayEl) return
const rect = btn.getBoundingClientRect()
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
const left = isTriggeredByToolbox.value
? rect.left + rect.width / 2
: rect.right - rect.width / 4
const top = isTriggeredByToolbox.value
? rect.bottom + marginY
: rect.top - marginY - 6
try {
overlayEl.style.position = 'fixed'
overlayEl.style.left = `${left}px`
overlayEl.style.top = `${top}px`
overlayEl.style.transform = 'translate(-50%, 0)'
} catch (e) {
console.warn('[NodeOptions] Failed to set overlay style', e)
return
}
const now = performance.now()
if (now - lastLogTs > LOG_INTERVAL) {
lastLogTs = now
}
}
const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
function openPopover(
triggerEvent?: Event,
element?: HTMLElement,
clickedFromToolbox?: boolean
): boolean {
const el = element || targetElement.value
if (!el || !el.isConnected) return false
targetElement.value = el
if (clickedFromToolbox !== undefined)
isTriggeredByToolbox.value = clickedFromToolbox
bump()
popover.value?.show(triggerEvent ?? new Event('reopen'), el)
isOpen.value = true
moreOptionsOpen.value = true
moreOptionsRestorePending.value = false
return true
}
function closePopover(reason: HideReason = 'manual') {
lastProgrammaticHideReason.value = reason
popover.value?.hide()
isOpen.value = false
moreOptionsOpen.value = false
stopSync()
hideAll()
if (reason !== 'drag') {
wasOpenBeforeHide.value = false
// Natural hide: cancel any pending restore
moreOptionsRestorePending.value = false
} else {
if (!moreOptionsRestorePending.value) {
wasOpenBeforeHide.value = true
moreOptionsRestorePending.value = true
}
}
}
let restoreAttempts = 0
function attemptRestore() {
if (isOpen.value) return
if (!wasOpenBeforeHide.value && !moreOptionsRestorePending.value) return
// Try immediately
if (openPopover(new Event('reopen'), targetElement.value || undefined)) {
wasOpenBeforeHide.value = false
restoreAttempts = 0
return
}
// Defer with limited retries (layout / mount race)
if (restoreAttempts >= 5) return
restoreAttempts++
requestAnimationFrame(() => attemptRestore())
}
const toggle = (
event: Event,
element?: HTMLElement,
clickedFromToolbox?: boolean
) => {
if (isOpen.value) closePopover('manual')
else openPopover(event, element, clickedFromToolbox)
}
const hide = (reason: HideReason = 'manual') => closePopover(reason)
// Export functions for external triggering
defineExpose({
toggle,
hide,
isOpen
})
const hideAll = () => {
hideAllSubmenus(
menuOptionsWithSubmenu.value,
submenuRefs.value,
currentSubmenu
)
}
const handleOptionClick = (option: MenuOption, event: Event) => {
if (!option.hasSubmenu && option.action) {
option.action()
hide()
} else if (option.hasSubmenu) {
event.stopPropagation()
const submenuKey = `submenu-${option.label}`
const submenu = submenuRefs.value[submenuKey]
if (submenu) {
void toggleSubmenu(
option,
event,
submenu,
currentSubmenu,
menuOptionsWithSubmenu.value,
submenuRefs.value
)
}
}
}
const handleSubmenuClick = (subOption: SubMenuOption) => {
subOption.action()
hide('manual')
}
const setSubmenuRef = (key: string, el: any) => {
if (el) {
submenuRefs.value[key] = el
} else {
delete submenuRefs.value[key]
}
}
// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
const onPopoverShow = () => {
overlayElCache = resolveOverlayEl()
// Delay first reposition slightly to ensure DOM fully painted
requestAnimationFrame(() => repositionPopover())
startSync()
}
const onPopoverHide = () => {
if (lastProgrammaticHideReason.value == null) {
isOpen.value = false
hideAll()
wasOpenBeforeHide.value = false
moreOptionsOpen.value = false
moreOptionsRestorePending.value = false
}
overlayElCache = null
stopSync()
lastProgrammaticHideReason.value = null
}
// Watch for forced close (drag start)
watch(
() => forceCloseMoreOptionsSignal.value,
() => {
if (isOpen.value) hide('drag')
else
wasOpenBeforeHide.value =
wasOpenBeforeHide.value || moreOptionsRestorePending.value
}
)
watch(
() => restoreMoreOptionsSignal.value,
() => attemptRestore()
)
onMounted(() => {
// Register this instance globally
registerNodeOptionsInstance({
toggle,
hide,
isOpen
})
if (moreOptionsRestorePending.value && !isOpen.value) {
requestAnimationFrame(() => attemptRestore())
}
})
onUnmounted(() => {
stopSync()
// Unregister on unmount
registerNodeOptionsInstance(null)
})
</script>

View File

@@ -1,6 +1,5 @@
<template>
<Button
ref="buttonRef"
v-tooltip.top="{
value: $t('g.moreOptions'),
showDelay: 1000
@@ -17,17 +16,10 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
const buttonRef = ref<InstanceType<typeof Button> | null>(null)
const handleClick = (event: Event) => {
const el = (buttonRef.value as any)?.$el || buttonRef.value
const buttonEl = el instanceof HTMLElement ? el : null
if (buttonEl) {
toggleNodeOptions(event, buttonEl, true)
}
toggleNodeOptions(event)
}
</script>

View File

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

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)
}
// 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

View File

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

View File

@@ -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<string, any> // Component instances
): Promise<void> => {
if (!option.label || !option.hasSubmenu) return
// Check if this submenu is currently open
const isCurrentlyOpen = currentSubmenu.value === option.label
// Hide all submenus first
menuOptionsWithSubmenu.forEach((opt) => {
const sm = submenuRefs[`submenu-${opt.label}`]
if (sm) {
sm.hide()
}
})
currentSubmenu.value = null
// If it wasn't open before, show it now
if (!isCurrentlyOpen) {
currentSubmenu.value = option.label
await nextTick()
const menuItem = event.currentTarget as HTMLElement
const menuItemRect = menuItem.getBoundingClientRect()
// Find the parent popover content element that contains this menu item
const mainPopoverContent = menuItem.closest(
'[data-pc-section="content"]'
) as HTMLElement
if (mainPopoverContent) {
const mainPopoverRect = mainPopoverContent.getBoundingClientRect()
// Create a temporary positioned element as the target
const tempTarget = createPositionedTarget(
mainPopoverRect.right + 8,
menuItemRect.top,
`submenu-target-${option.label}`
)
// Create event using the temp target
const tempEvent = createMouseEvent(
mainPopoverRect.right + 8,
menuItemRect.top
)
// Show submenu relative to temp target
submenu.show(tempEvent, tempTarget)
// Clean up temp target after a delay
cleanupTempTarget(tempTarget, 100)
} else {
// Fallback: position to the right of the menu item
const tempTarget = createPositionedTarget(
menuItemRect.right + 8,
menuItemRect.top,
`submenu-fallback-target-${option.label}`
)
// Create event using the temp target
const tempEvent = createMouseEvent(
menuItemRect.right + 8,
menuItemRect.top
)
// Show submenu relative to temp target
submenu.show(tempEvent, tempTarget)
// Clean up temp target after a delay
cleanupTempTarget(tempTarget, 100)
}
}
}
/**
* Create a temporary positioned DOM element for submenu targeting
*/
const createPositionedTarget = (
left: number,
top: number,
id: string
): HTMLElement => {
const tempTarget = document.createElement('div')
tempTarget.style.position = 'absolute'
tempTarget.style.left = `${left}px`
tempTarget.style.top = `${top}px`
tempTarget.style.width = '1px'
tempTarget.style.height = '1px'
tempTarget.style.pointerEvents = 'none'
tempTarget.style.visibility = 'hidden'
tempTarget.id = id
document.body.appendChild(tempTarget)
return tempTarget
}
/**
* Create a mouse event with specific coordinates
*/
const createMouseEvent = (clientX: number, clientY: number): MouseEvent => {
return new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX,
clientY
})
}
/**
* Clean up temporary target element after delay
*/
const cleanupTempTarget = (target: HTMLElement, delay: number): void => {
setTimeout(() => {
if (target.parentNode) {
target.parentNode.removeChild(target)
}
}, delay)
}
/**
* Hide all submenus
*/
const hideAllSubmenus = (
menuOptionsWithSubmenu: MenuOption[],
submenuRefs: Record<string, any>, // Component instances
currentSubmenu: { value: string | null }
): void => {
menuOptionsWithSubmenu.forEach((option) => {
const submenu = submenuRefs[`submenu-${option.label}`]
if (submenu) {
submenu.hide()
}
})
currentSubmenu.value = null
}
return {
toggleSubmenu,
hideAllSubmenus
}
}

View File

@@ -127,6 +127,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.",
@@ -448,7 +449,8 @@
"Horizontal": "Horizontal",
"Vertical": "Vertical",
"new": "new",
"deprecated": "deprecated"
"deprecated": "deprecated",
"Extensions": "Extensions"
},
"icon": {
"bookmark": "Bookmark",

View File

@@ -290,10 +290,7 @@ const handleContextMenu = (event: MouseEvent) => {
handleNodeRightClick(event as PointerEvent, nodeData.id)
// Show the node options menu at the cursor position
const targetElement = event.currentTarget as HTMLElement
if (targetElement) {
toggleNodeOptions(event, targetElement, false)
}
toggleNodeOptions(event)
}
onMounted(() => {