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

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