mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-24 08:44:06 +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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user