mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +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')
|
const initialShape = await nodeRef.getProperty<number>('shape')
|
||||||
|
|
||||||
await openMoreOptions(comfyPage)
|
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({
|
await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
})
|
})
|
||||||
@@ -136,13 +136,18 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
|
|||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
await openMoreOptions(comfyPage)
|
await openMoreOptions(comfyPage)
|
||||||
await expect(
|
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
|
||||||
comfyPage.page.getByText('Rename', { exact: true })
|
await expect(renameItem).toBeVisible({ timeout: 5000 })
|
||||||
).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
|
await comfyPage.page
|
||||||
.locator('#graph-canvas')
|
.locator('#graph-canvas')
|
||||||
.click({ position: { x: 0, y: 50 }, force: true })
|
.click({ position: { x: 0, y: 50 }, force: true })
|
||||||
|
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.page.getByText('Rename', { exact: true })
|
comfyPage.page.getByText('Rename', { exact: true })
|
||||||
|
|||||||
@@ -87,7 +87,6 @@
|
|||||||
<template v-if="comfyAppReady">
|
<template v-if="comfyAppReady">
|
||||||
<TitleEditor />
|
<TitleEditor />
|
||||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||||
<NodeOptions />
|
|
||||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||||
</template>
|
</template>
|
||||||
@@ -115,7 +114,6 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
|||||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||||
import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue'
|
|
||||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||||
import SideToolbar from '@/components/sidebar/SideToolbar.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>',
|
'<div class="panel selection-toolbox absolute left-1/2 rounded-lg"><slot /></div>',
|
||||||
props: ['pt', 'style', 'class']
|
props: ['pt', 'style', 'class']
|
||||||
},
|
},
|
||||||
|
NodeContextMenu: { template: '<div class="node-context-menu" />' },
|
||||||
InfoButton: { template: '<div class="info-button" />' },
|
InfoButton: { template: '<div class="info-button" />' },
|
||||||
ColorPickerButton: {
|
ColorPickerButton: {
|
||||||
template:
|
template:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
</Panel>
|
</Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
<NodeContextMenu />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -68,6 +69,7 @@ import { useExtensionService } from '@/services/extensionService'
|
|||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||||
|
|
||||||
|
import NodeContextMenu from './NodeContextMenu.vue'
|
||||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Popover
|
<Popover
|
||||||
ref="popover"
|
ref="popoverRef"
|
||||||
:auto-z-index="true"
|
:auto-z-index="true"
|
||||||
:base-z-index="1100"
|
:base-z-index="1100"
|
||||||
:dismissable="true"
|
:dismissable="true"
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
'hover:bg-secondary-background-hover rounded cursor-pointer',
|
'hover:bg-secondary-background-hover rounded cursor-pointer',
|
||||||
isColorSubmenu
|
isColorSubmenu
|
||||||
? 'w-7 h-7 flex items-center justify-center'
|
? '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"
|
:title="subOption.label"
|
||||||
@@ -82,23 +85,21 @@ const emit = defineEmits<Emits>()
|
|||||||
|
|
||||||
const { getCurrentShape } = useNodeCustomization()
|
const { getCurrentShape } = useNodeCustomization()
|
||||||
|
|
||||||
const popover = ref<InstanceType<typeof Popover>>()
|
const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||||
|
|
||||||
const show = (event: Event, target?: HTMLElement) => {
|
const toggle = (event: Event, target?: HTMLElement) => {
|
||||||
popover.value?.show(event, target)
|
popoverRef.value?.toggle(event, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hide = () => {
|
|
||||||
popover.value?.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show,
|
toggle
|
||||||
hide
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||||
|
if (subOption.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('submenu-click', subOption)
|
emit('submenu-click', subOption)
|
||||||
|
popoverRef.value?.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
const isShapeSelected = (subOption: SubMenuOption): boolean => {
|
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>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
ref="buttonRef"
|
|
||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
value: $t('g.moreOptions'),
|
value: $t('g.moreOptions'),
|
||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
@@ -17,17 +16,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||||
|
|
||||||
const buttonRef = ref<InstanceType<typeof Button> | null>(null)
|
|
||||||
|
|
||||||
const handleClick = (event: Event) => {
|
const handleClick = (event: Event) => {
|
||||||
const el = (buttonRef.value as any)?.$el || buttonRef.value
|
toggleNodeOptions(event)
|
||||||
const buttonEl = el instanceof HTMLElement ? el : null
|
|
||||||
if (buttonEl) {
|
|
||||||
toggleNodeOptions(event, buttonEl, true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import { computeUnionBounds } from '@/utils/mathUtil'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore
|
// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore
|
||||||
export const moreOptionsOpen = ref(false)
|
const moreOptionsOpen = ref(false)
|
||||||
export const forceCloseMoreOptionsSignal = ref(0)
|
const forceCloseMoreOptionsSignal = ref(0)
|
||||||
export const restoreMoreOptionsSignal = ref(0)
|
const restoreMoreOptionsSignal = ref(0)
|
||||||
export const moreOptionsRestorePending = ref(false)
|
const moreOptionsRestorePending = ref(false)
|
||||||
let moreOptionsWasOpenBeforeDrag = false
|
let moreOptionsWasOpenBeforeDrag = false
|
||||||
let moreOptionsSelectionSignature: string | null = null
|
let moreOptionsSelectionSignature: string | null = null
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { computed, ref } from 'vue'
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildStructuredMenu,
|
||||||
|
convertContextMenuToOptions
|
||||||
|
} from './contextMenuConverter'
|
||||||
import { useGroupMenuOptions } from './useGroupMenuOptions'
|
import { useGroupMenuOptions } from './useGroupMenuOptions'
|
||||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||||
import { useNodeMenuOptions } from './useNodeMenuOptions'
|
import { useNodeMenuOptions } from './useNodeMenuOptions'
|
||||||
@@ -43,15 +48,10 @@ let nodeOptionsInstance: null | NodeOptionsInstance = null
|
|||||||
/**
|
/**
|
||||||
* Toggle the node options popover
|
* Toggle the node options popover
|
||||||
* @param event - The trigger event
|
* @param event - The trigger event
|
||||||
* @param element - The target element (button) that triggered the popover
|
|
||||||
*/
|
*/
|
||||||
export function toggleNodeOptions(
|
export function toggleNodeOptions(event: Event) {
|
||||||
event: Event,
|
|
||||||
element: HTMLElement,
|
|
||||||
clickedFromToolbox: boolean = false
|
|
||||||
) {
|
|
||||||
if (nodeOptionsInstance?.toggle) {
|
if (nodeOptionsInstance?.toggle) {
|
||||||
nodeOptionsInstance.toggle(event, element, clickedFromToolbox)
|
nodeOptionsInstance.toggle(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +59,7 @@ export function toggleNodeOptions(
|
|||||||
* Hide the node options popover
|
* Hide the node options popover
|
||||||
*/
|
*/
|
||||||
interface NodeOptionsInstance {
|
interface NodeOptionsInstance {
|
||||||
toggle: (
|
toggle: (event: Event) => void
|
||||||
event: Event,
|
|
||||||
element: HTMLElement,
|
|
||||||
clickedFromToolbox: boolean
|
|
||||||
) => void
|
|
||||||
hide: () => void
|
hide: () => void
|
||||||
isOpen: Ref<boolean>
|
isOpen: Ref<boolean>
|
||||||
}
|
}
|
||||||
@@ -78,6 +74,19 @@ export function registerNodeOptionsInstance(
|
|||||||
nodeOptionsInstance = instance
|
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
|
* Composable for managing the More Options menu configuration
|
||||||
* Refactored to use smaller, focused composables for better maintainability
|
* Refactored to use smaller, focused composables for better maintainability
|
||||||
@@ -95,10 +104,11 @@ export function useMoreOptionsMenu() {
|
|||||||
computeSelectionFlags
|
computeSelectionFlags
|
||||||
} = useSelectionState()
|
} = useSelectionState()
|
||||||
|
|
||||||
|
const canvasStore = useCanvasStore()
|
||||||
|
|
||||||
const { getImageMenuOptions } = useImageMenuOptions()
|
const { getImageMenuOptions } = useImageMenuOptions()
|
||||||
const {
|
const {
|
||||||
getNodeInfoOption,
|
getNodeInfoOption,
|
||||||
getAdjustSizeOption,
|
|
||||||
getNodeVisualOptions,
|
getNodeVisualOptions,
|
||||||
getPinOption,
|
getPinOption,
|
||||||
getBypassOption,
|
getBypassOption,
|
||||||
@@ -106,16 +116,13 @@ export function useMoreOptionsMenu() {
|
|||||||
} = useNodeMenuOptions()
|
} = useNodeMenuOptions()
|
||||||
const {
|
const {
|
||||||
getFitGroupToNodesOption,
|
getFitGroupToNodesOption,
|
||||||
getGroupShapeOptions,
|
|
||||||
getGroupColorOptions,
|
getGroupColorOptions,
|
||||||
getGroupModeOptions
|
getGroupModeOptions
|
||||||
} = useGroupMenuOptions()
|
} = useGroupMenuOptions()
|
||||||
const {
|
const {
|
||||||
getBasicSelectionOptions,
|
getBasicSelectionOptions,
|
||||||
getSubgraphOptions,
|
getSubgraphOptions,
|
||||||
getMultipleNodesOptions,
|
getMultipleNodesOptions
|
||||||
getDeleteOption,
|
|
||||||
getAlignmentOptions
|
|
||||||
} = useSelectionMenuOptions()
|
} = useSelectionMenuOptions()
|
||||||
|
|
||||||
const hasSubgraphs = hasSubgraphsComputed
|
const hasSubgraphs = hasSubgraphsComputed
|
||||||
@@ -142,85 +149,107 @@ export function useMoreOptionsMenu() {
|
|||||||
? selectedGroups[0]
|
? selectedGroups[0]
|
||||||
: null
|
: null
|
||||||
const hasSubgraphsSelected = hasSubgraphs.value
|
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[] = []
|
const options: MenuOption[] = []
|
||||||
|
|
||||||
// Section 1: Basic selection operations (Rename, Copy, Duplicate)
|
// Section 1: Basic selection operations (Rename, Copy, Duplicate)
|
||||||
options.push(...getBasicSelectionOptions())
|
const basicOps = getBasicSelectionOptions()
|
||||||
|
options.push(...basicOps)
|
||||||
options.push({ type: 'divider' })
|
options.push({ type: 'divider' })
|
||||||
|
|
||||||
// Section 2: Node Info & Size Adjustment
|
// Section 2: Node actions (Run Branch, Pin, Bypass, Mute)
|
||||||
if (nodeDef.value) {
|
if (hasOutputNodesSelected.value) {
|
||||||
options.push(getNodeInfoOption(showNodeHelp))
|
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) {
|
if (groupContext) {
|
||||||
options.push(getFitGroupToNodesOption(groupContext))
|
const groupModes = getGroupModeOptions(groupContext, bump)
|
||||||
} else {
|
options.push(...groupModes)
|
||||||
options.push(getAdjustSizeOption())
|
|
||||||
}
|
}
|
||||||
|
options.push({ type: 'divider' })
|
||||||
|
|
||||||
// Section 3: Collapse/Shape/Color
|
// Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node)
|
||||||
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
|
|
||||||
options.push(
|
options.push(
|
||||||
...getSubgraphOptions({
|
...getSubgraphOptions({
|
||||||
hasSubgraphs: hasSubgraphsSelected,
|
hasSubgraphs: hasSubgraphsSelected,
|
||||||
hasMultipleSelection: hasMultipleNodes.value
|
hasMultipleSelection: hasMultipleNodes.value
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Section 6: Multiple nodes operations
|
|
||||||
if (hasMultipleNodes.value) {
|
if (hasMultipleNodes.value) {
|
||||||
options.push(...getMultipleNodesOptions())
|
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) {
|
if (groupContext) {
|
||||||
// Group mode operations
|
options.push(getFitGroupToNodesOption(groupContext))
|
||||||
options.push(...getGroupModeOptions(groupContext, bump))
|
|
||||||
} else {
|
} else {
|
||||||
// Bypass option for nodes
|
// Node context: Expand/Minimize
|
||||||
options.push(getBypassOption(states, bump))
|
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({ 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
|
// Computed property to get only menu items with submenus
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function useNodeMenuOptions() {
|
|||||||
icon: 'icon-[lucide--palette]',
|
icon: 'icon-[lucide--palette]',
|
||||||
hasSubmenu: true,
|
hasSubmenu: true,
|
||||||
submenu: colorSubmenu.value,
|
submenu: colorSubmenu.value,
|
||||||
|
isColorPicker: true,
|
||||||
action: () => {}
|
action: () => {}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -96,7 +97,7 @@ export function useNodeMenuOptions() {
|
|||||||
label: states.bypassed
|
label: states.bypassed
|
||||||
? t('contextMenu.Remove Bypass')
|
? t('contextMenu.Remove Bypass')
|
||||||
: t('contextMenu.Bypass'),
|
: t('contextMenu.Bypass'),
|
||||||
icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]',
|
icon: 'icon-[lucide--redo-dot]',
|
||||||
shortcut: 'Ctrl+B',
|
shortcut: 'Ctrl+B',
|
||||||
action: () => {
|
action: () => {
|
||||||
toggleNodeBypass()
|
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",
|
"search": "Search",
|
||||||
"searchPlaceholder": "Search...",
|
"searchPlaceholder": "Search...",
|
||||||
"noResultsFound": "No Results Found",
|
"noResultsFound": "No Results Found",
|
||||||
|
"noResults": "No Results",
|
||||||
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
|
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
|
||||||
"noTasksFound": "No Tasks Found",
|
"noTasksFound": "No Tasks Found",
|
||||||
"noTasksFoundMessage": "There are no tasks in the queue.",
|
"noTasksFoundMessage": "There are no tasks in the queue.",
|
||||||
@@ -448,7 +449,8 @@
|
|||||||
"Horizontal": "Horizontal",
|
"Horizontal": "Horizontal",
|
||||||
"Vertical": "Vertical",
|
"Vertical": "Vertical",
|
||||||
"new": "new",
|
"new": "new",
|
||||||
"deprecated": "deprecated"
|
"deprecated": "deprecated",
|
||||||
|
"Extensions": "Extensions"
|
||||||
},
|
},
|
||||||
"icon": {
|
"icon": {
|
||||||
"bookmark": "Bookmark",
|
"bookmark": "Bookmark",
|
||||||
|
|||||||
@@ -290,10 +290,7 @@ const handleContextMenu = (event: MouseEvent) => {
|
|||||||
handleNodeRightClick(event as PointerEvent, nodeData.id)
|
handleNodeRightClick(event as PointerEvent, nodeData.id)
|
||||||
|
|
||||||
// Show the node options menu at the cursor position
|
// Show the node options menu at the cursor position
|
||||||
const targetElement = event.currentTarget as HTMLElement
|
toggleNodeOptions(event)
|
||||||
if (targetElement) {
|
|
||||||
toggleNodeOptions(event, targetElement, false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user