mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 05:49:54 +00:00
This pull request refactors and improves the "More Options" popover functionality for graph nodes in the UI. The main change is a rename and redesign of the menu component from `MoreOptions` to `NodeOptions`, introducing a global singleton pattern for popover control and enabling context menu support on node right-click. This results in better maintainability, more flexible triggering, and improved user experience. **Node Options popover refactor and global control:** * Renamed and refactored `MoreOptions.vue` to `NodeOptions.vue`, removing the embedded button and exposing imperative methods (`toggle`, `hide`, `isOpen`) for external control. The component now registers/unregisters itself globally via `registerNodeOptionsInstance`. [[1]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL2-R2) [[2]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL203-R197) [[3]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eR294-R309) * Added `NodeOptionsButton.vue` as a dedicated button component for triggering the popover, decoupling the button UI from the popover logic. * Implemented a global singleton pattern in `useMoreOptionsMenu.ts` for controlling the `NodeOptions` popover from anywhere, with `toggleNodeOptions` and `registerNodeOptionsInstance` functions. [[1]](diffhunk://#diff-ae87bdb1e06725eb19b8d0fc82ec40a5f8ca831a6632767cc5d214fc903b89b6R35-R65) [[2]](diffhunk://#diff-ae87bdb1e06725eb19b8d0fc82ec40a5f8ca831a6632767cc5d214fc903b89b6L184-R216) **UI integration and event handling improvements:** * Updated `SelectionToolbox.vue` to use the new `NodeOptionsButton` instead of the previous embedded `MoreOptions` button, and added the `NodeOptions` popover to the main `GraphCanvas.vue` template for global accessibility. [[1]](diffhunk://#diff-05d80ee1e28e634dc758394ddf1bfaa8e5ec72a186a6ea2e2b6f5dfba867b264L41-R41) [[2]](diffhunk://#diff-05d80ee1e28e634dc758394ddf1bfaa8e5ec72a186a6ea2e2b6f5dfba867b264L71-R71) [[3]](diffhunk://#diff-aaf17c713f29c6db8ea03efe7fc3483a858982e818a324b23cff89859e71559cR65) [[4]](diffhunk://#diff-aaf17c713f29c6db8ea03efe7fc3483a858982e818a324b23cff89859e71559cR91) * Added right-click context menu support to `LGraphNode.vue`, triggering the node options popover at the cursor position and integrating with node selection logic. [[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R45) [[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R141) [[3]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L180-R187) [[4]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R249-R263) **Minor improvements and cleanup:** * Updated references and variable names throughout the codebase to reflect the new `NodeOptions` naming and logic. [[1]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL53) [[2]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eR50) [[3]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL75-R60) [[4]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL91-L95) [[5]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL110-R90) [[6]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL133-R113) [[7]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL146-R126) [[8]](diffhunk://#diff-e0dbd5e37efd2c79e7317415455340b0dd150b758077b170a663f67d2453605eL157-R140) This refactor makes the node options menu more modular, easier to maintain, and more flexible for future UI improvements. https://github.com/user-attachments/assets/9c2f2556-4544-4e20-9f22-8f485b0ceadc ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5790-Right-click-vue-nodes-27a6d73d365081a98263c88d2e09e629) by [Unito](https://www.unito.io)
329 lines
8.8 KiB
Vue
329 lines
8.8 KiB
Vue
<template>
|
|
<div>
|
|
<Popover
|
|
ref="popover"
|
|
:append-to="'body'"
|
|
:auto-z-index="true"
|
|
:base-z-index="1000"
|
|
:dismissable="true"
|
|
:close-on-escape="true"
|
|
unstyled
|
|
:pt="pt"
|
|
@show="onPopoverShow"
|
|
@hide="onPopoverHide"
|
|
@wheel="canvasInteractions.forwardEventToCanvas"
|
|
>
|
|
<div class="flex flex-col p-2 min-w-48">
|
|
<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"
|
|
:container-styles="containerStyles"
|
|
@submenu-click="handleSubmenuClick"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useRafFn } from '@vueuse/core'
|
|
import Popover from 'primevue/popover'
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
|
import {
|
|
forceCloseMoreOptionsSignal,
|
|
moreOptionsOpen,
|
|
moreOptionsRestorePending,
|
|
restoreMoreOptionsSignal
|
|
} from '@/composables/canvas/useSelectionToolboxPosition'
|
|
import {
|
|
type MenuOption,
|
|
type SubMenuOption,
|
|
registerNodeOptionsInstance,
|
|
useMoreOptionsMenu
|
|
} from '@/composables/graph/useMoreOptionsMenu'
|
|
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
|
|
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
|
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
|
|
|
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()
|
|
const minimap = useMinimap()
|
|
const containerStyles = minimap.containerStyles
|
|
|
|
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]
|
|
}
|
|
}
|
|
|
|
const pt = computed(() => ({
|
|
root: {
|
|
class: 'absolute z-50 w-[300px] px-[12]'
|
|
},
|
|
content: {
|
|
class: [
|
|
'mt-2 text-neutral dark-theme:text-white rounded-lg',
|
|
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
|
],
|
|
style: {
|
|
backgroundColor: containerStyles.value.backgroundColor
|
|
}
|
|
}
|
|
}))
|
|
|
|
// 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>
|