Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion browser_tests/tests/selectionToolboxSubmenus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,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
})
Expand Down Expand Up @@ -141,10 +141,12 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await comfyPage.page.waitForTimeout(500)

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 })
Expand Down
2 changes: 0 additions & 2 deletions src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,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>
Expand Down Expand Up @@ -111,7 +110,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 NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
Expand Down
179 changes: 179 additions & 0 deletions src/components/graph/NodeContextMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<template>
<ContextMenu
ref="contextMenu"
:model="menuItems"
class="max-h-[80vh] overflow-y-auto max-w-72"
@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"
:option="colorOption"
@submenu-click="handleColorSelect"
/>
</template>

<script setup lang="ts">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref } from 'vue'

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()

// 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()
isOpen.value = true
contextMenu.value?.show(event)
}

// Hide context menu
function hide() {
contextMenu.value?.hide()
colorPickerMenu.value?.hide()
}

// Toggle function for compatibility
function toggle(
event: Event,
_element?: HTMLElement,
_clickedFromToolbox?: boolean
) {
if (isOpen.value) {
hide()
} else {
show(event as MouseEvent)
}
}

defineExpose({ toggle, hide, isOpen, show })

function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
const target = event.currentTarget as HTMLElement
colorPickerMenu.value?.toggle(target)
}

// Handle color selection
function handleColorSelect(subOption: SubMenuOption) {
subOption.action()
hide()
}

function onMenuShow() {
isOpen.value = true
}

function onMenuHide() {
isOpen.value = false
colorPickerMenu.value?.hide()
}

onMounted(() => {
registerNodeOptionsInstance({ toggle, hide, isOpen })
})

onUnmounted(() => {
registerNodeOptionsInstance(null)
})
</script>
1 change: 1 addition & 0 deletions src/components/graph/SelectionToolbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/components/graph/SelectionToolbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>

<script setup lang="ts">
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,9 @@
<template>
<Popover
ref="popover"
:auto-z-index="true"
:base-z-index="1100"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-[60]'
},
content: {
class: [
'text-base-foreground rounded-lg',
'shadow-lg border border-base-background',
'bg-interface-panel-surface'
]
}
}"
<div
v-if="isVisible"
ref="popoverRef"
class="fixed z-[1100] rounded-lg shadow-lg border border-base-background bg-interface-panel-surface text-base-foreground"
:style="popoverStyle"
>
<div
:class="
Expand All @@ -34,7 +20,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"
Expand All @@ -55,12 +44,12 @@
</template>
</div>
</div>
</Popover>
</div>
</template>

<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Popover from 'primevue/popover'
import { onClickOutside } from '@vueuse/core'
import { computed, ref } from 'vue'

import type {
Expand All @@ -82,22 +71,64 @@ const emit = defineEmits<Emits>()

const { getCurrentShape } = useNodeCustomization()

const popover = ref<InstanceType<typeof Popover>>()
const popoverRef = ref<HTMLElement>()
const isVisible = ref(false)
const position = ref({ top: 0, left: 0 })
let justOpened = false

const show = (event: Event, target?: HTMLElement) => {
popover.value?.show(event, target)
const popoverStyle = computed(() => ({
top: `${position.value.top}px`,
left: `${position.value.left}px`
}))

const showToRight = (target: HTMLElement) => {
const rect = target.getBoundingClientRect()
position.value = {
top: rect.top,
left: rect.right + 4
}
isVisible.value = true
justOpened = true
setTimeout(() => {
justOpened = false
}, 0)
}

const hide = () => {
popover.value?.hide()
isVisible.value = false
}

const toggle = (target: HTMLElement) => {
if (isVisible.value) {
hide()
} else {
showToRight(target)
}
}

// Ignore clicks on context menu elements to prevent immediate close
onClickOutside(
popoverRef,
() => {
if (justOpened) {
justOpened = false
return
}
hide()
},
{ ignore: ['.p-contextmenu', '.p-contextmenu-item-link'] }
)

defineExpose({
show,
hide
showToRight,
hide,
toggle
})

const handleSubmenuClick = (subOption: SubMenuOption) => {
if (subOption.disabled) {
return
}
emit('submenu-click', subOption)
}

Expand Down
Loading
Loading