mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
- Drop PrimeVue ContextMenu, use custom teleported imperative menu - Add Disconnect Links and Remove Slot actions (LiteGraph parity) - Extract useCanvasAnchoredPosition composable from NodeContextMenu - Fix slot label reactivity: add node:slot-label:changed graph event that triggers refreshNodeSlots to update both inputs and outputs - Refresh shallowReactive inputs via splice for property changes - Refresh outputs via spread copy for label changes - Use hasAnySlotAction guard instead of canRenameSlot in slot components - Add i18n keys: disconnectLinks, removeSlot
230 lines
5.7 KiB
Vue
230 lines
5.7 KiB
Vue
<template>
|
|
<ContextMenu
|
|
ref="contextMenu"
|
|
:model="menuItems"
|
|
class="max-h-[80vh] overflow-y-auto md:max-h-none 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-sm 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 { useEventListener } from '@vueuse/core'
|
|
import ContextMenu from 'primevue/contextmenu'
|
|
import type { MenuItem } from 'primevue/menuitem'
|
|
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
|
|
|
import { useCanvasAnchoredPosition } from '@/composables/graph/useCanvasAnchoredPosition'
|
|
import {
|
|
registerNodeOptionsInstance,
|
|
useMoreOptionsMenu
|
|
} from '@/composables/graph/useMoreOptionsMenu'
|
|
import type {
|
|
MenuOption,
|
|
SubMenuOption
|
|
} from '@/composables/graph/useMoreOptionsMenu'
|
|
|
|
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 { screenPosition, anchorToEvent } = useCanvasAnchoredPosition(isOpen)
|
|
|
|
watchEffect(() => {
|
|
if (!isOpen.value) return
|
|
const menuInstance = contextMenu.value as unknown as {
|
|
container?: HTMLElement
|
|
}
|
|
const menuEl = menuInstance?.container
|
|
if (!menuEl) return
|
|
menuEl.style.left = `${screenPosition.value.x}px`
|
|
menuEl.style.top = `${screenPosition.value.y}px`
|
|
})
|
|
|
|
// 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()
|
|
anchorToEvent(event)
|
|
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 constrainMenuHeight() {
|
|
const menuInstance = contextMenu.value as unknown as {
|
|
container?: HTMLElement
|
|
}
|
|
const rootList = menuInstance?.container?.querySelector(
|
|
':scope > ul'
|
|
) as HTMLElement | null
|
|
if (!rootList) return
|
|
|
|
const rect = rootList.getBoundingClientRect()
|
|
const maxHeight = window.innerHeight - rect.top - 8
|
|
if (maxHeight > 0) {
|
|
rootList.style.maxHeight = `${maxHeight}px`
|
|
rootList.style.overflowY = 'auto'
|
|
}
|
|
}
|
|
|
|
function onMenuShow() {
|
|
isOpen.value = true
|
|
requestAnimationFrame(constrainMenuHeight)
|
|
}
|
|
|
|
function onMenuHide() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
onMounted(() => {
|
|
registerNodeOptionsInstance({ toggle, show, hide, isOpen })
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
registerNodeOptionsInstance(null)
|
|
})
|
|
</script>
|