Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1809eff
right click vue nodes with contextmenu WIP
Myestery Oct 29, 2025
521b672
[feat] Add disabled state support for context menu items
Myestery Oct 31, 2025
b31e8d0
feat: add Extensions translation key to context menu
Myestery Nov 11, 2025
5aa6388
feat: add category type support to menu items
Myestery Nov 11, 2025
4eb5103
feat: implement menu ordering and extension categorization system
Myestery Nov 11, 2025
083fb56
feat: restructure context menu sections with proper ordering
Myestery Nov 11, 2025
2279888
feat: add search functionality to context menu
Myestery Nov 11, 2025
f827f48
fix: move Resize and Clone to structure operations section
Myestery Nov 11, 2025
727ce2e
WIP scroll
Myestery Nov 11, 2025
20c6aa4
feat: add viewport-aware positioning to prevent menu overflow
Myestery Nov 11, 2025
942d5bd
refactor: make position types internal to prevent unused export warnings
Myestery Nov 11, 2025
b89b6ba
refactor: remove unused menuOptionExists function
Myestery Nov 11, 2025
3a3940f
feat: add fuzzy search with debouncing to context menu
Myestery Nov 14, 2025
f8e005c
refactor: remove debug console logs from NodeOptions
Myestery Nov 15, 2025
3a83b79
Merge remote-tracking branch 'origin/main' into right-click-vue-node-…
Myestery Nov 18, 2025
929c8ef
fix searchbox color and add empty state
Myestery Nov 19, 2025
53d0614
fix: adjust badge height and update search placeholder for better UX
Myestery Nov 19, 2025
32cdb2c
fix: update search input autofocus behavior and add mobile viewport h…
Myestery Nov 19, 2025
b5a34b1
refactor: remove debug logging from context menu functions for cleane…
Myestery Nov 19, 2025
1c2f913
Merge branch 'main' into right-click-vue-node-contextmenu
Myestery Nov 19, 2025
94b0aa0
[feat] add shape option to node context menu and improve CSS positioning
Myestery Nov 20, 2025
ab1e207
refactor: Update submenu repositioning logic to use a direct content …
Myestery Nov 20, 2025
03c8cf8
fix: dock menu to top of viewport when trigger is above
Myestery Nov 21, 2025
07c5bc3
style: Replace `text-[12px]` with `text-xs` utility
Myestery Nov 21, 2025
e8e8ce3
feat: allow null values in context menu options and update related ty…
Myestery Nov 22, 2025
17ab44e
feat: Replace custom search input with component in NodeOptions.
Myestery Nov 22, 2025
16f3e43
Merge branch 'main' into right-click-vue-node-contextmenu
Myestery Dec 1, 2025
5fcfe8e
[refactor] Replace NodeOptions with PrimeVue ContextMenu
Myestery Dec 2, 2025
19856bb
[refactor] Rename SubmenuPopover to ColorPickerMenu
Myestery Dec 2, 2025
06d3ea5
[chore] Remove unused positioning files and exports
Myestery Dec 2, 2025
dad2e8b
refactor: replace specific hover background colors with theme variabl…
Myestery Dec 2, 2025
01cca71
style: enhance shortcut display in the node context menu.
Myestery Dec 2, 2025
dd2f20a
feat: ensure context menu's `isOpen` state is correctly updated on sh…
Myestery Dec 2, 2025
7826e2f
test: add 500ms page timeout
Myestery Dec 2, 2025
f301223
fix: Correct 'Open in Mask Editor' text and remove redundant `isOpen`…
Myestery Dec 2, 2025
d6bc30c
feat: Constrain node context menu dimensions with overflow scrolling …
Myestery Dec 2, 2025
a8571f5
Merge branch 'main' into right-click-vue-node-contextmenu
Myestery Dec 2, 2025
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