feat: show ComfyUI context menu on textarea widget right-click (#9840)

## Summary

Right-clicking a textarea widget (e.g. text node) shows the browser's
native context menu instead of ComfyUI's context menu, preventing access
to promote/un-promote options in subgraphs.

## Changes

- **What**: Replace `@contextmenu.capture.stop` on
`WidgetTextarea.vue`'s `<Textarea>` with a handler implementing
double-right-click toggling: first right-click shows ComfyUI's context
menu, second right-click (while menu is open) allows browser native
menu. Exposes `isNodeOptionsOpen()` from `useMoreOptionsMenu.ts` to
check menu state.

## Review Focus

The capture-phase handler in `WidgetTextarea.vue` only changes
`contextmenu` handling — pointer event modifiers
(`pointerdown/move/up.capture.stop`) that prevent canvas panning are
untouched. The double-right-click pattern matches Notion/YouTube
behavior for editable text fields.

<!-- Pipeline-Ticket: d7a53160-e1e1-42bb-a5ac-c0c2702c629c -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9840-fix-show-ComfyUI-context-menu-on-textarea-widget-right-click-3216d73d36508102b4c9c13a5915bc48)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Christian Byrne
2026-03-13 07:03:20 -07:00
committed by GitHub
parent 48d928fc9e
commit b5ddc70233
3 changed files with 60 additions and 2 deletions

View File

@@ -80,8 +80,12 @@ export function showNodeOptions(
}
/**
* Hide the node options popover
* Check if the node options menu is currently open
*/
export function isNodeOptionsOpen(): boolean {
return nodeOptionsInstance?.isOpen.value ?? false
}
interface NodeOptionsInstance {
toggle: (event: Event) => void
show: (event: MouseEvent) => void

View File

@@ -7,6 +7,7 @@ import WidgetTextarea from './WidgetTextarea.vue'
import { createMockWidget } from './widgetTestUtils'
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
const mockIsNodeOptionsOpen = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn().mockReturnValue({
@@ -14,6 +15,10 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
})
}))
vi.mock('@/composables/graph/useMoreOptionsMenu', () => ({
isNodeOptionsOpen: mockIsNodeOptionsOpen
}))
function createTextareaWidget(
value: string = 'default text',
options: SimplifiedWidget<string>['options'] = {},
@@ -277,3 +282,43 @@ describe('WidgetTextarea Value Binding', () => {
})
})
})
describe('WidgetTextarea contextmenu', () => {
it('prevents browser menu on first right-click (menu closed)', () => {
mockIsNodeOptionsOpen.mockReturnValue(false)
const widget = createTextareaWidget('test')
const wrapper = mountComponent(widget, 'test')
const textarea = wrapper.find('textarea')
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true
})
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
textarea.element.dispatchEvent(event)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).not.toHaveBeenCalled()
})
it('allows browser menu on second right-click (menu open)', () => {
mockIsNodeOptionsOpen.mockReturnValue(true)
const widget = createTextareaWidget('test')
const wrapper = mountComponent(widget, 'test')
const textarea = wrapper.find('textarea')
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true
})
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
textarea.element.dispatchEvent(event)
expect(preventDefaultSpy).not.toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})

View File

@@ -31,7 +31,7 @@
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
@contextmenu.capture.stop
@contextmenu.capture="handleContextMenu"
/>
<Button
v-if="isReadOnly"
@@ -54,6 +54,7 @@ import { computed, useId } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isNodeOptionsOpen } from '@/composables/graph/useMoreOptionsMenu'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
@@ -85,6 +86,14 @@ const isReadOnly = computed(() =>
Boolean(widget.options?.read_only || widget.options?.disabled)
)
function handleContextMenu(e: MouseEvent) {
if (isNodeOptionsOpen()) {
e.stopPropagation()
return
}
e.preventDefault()
}
function handleCopy() {
copyToClipboard(modelValue.value)
}