mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
[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:
committed by
GitHub
parent
8d7dd9ed67
commit
e21f43f398
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
|
||||
272
src/components/graph/NodeContextMenu.vue
Normal file
272
src/components/graph/NodeContextMenu.vue
Normal 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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 => {
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user