Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
e09534f8f6 fix: show ComfyUI context menu on textarea widget right-click (green)
Replace @contextmenu.capture.stop with a handler that shows ComfyUI's
context menu on first right-click and allows browser native menu on
second right-click (double-right-click toggle, matching Notion/YouTube
behavior). Exposes isNodeOptionsOpen() from useMoreOptionsMenu to check
menu state.

Fixes #d7a53160-e1e1-42bb-a5ac-c0c2702c629c
2026-03-13 09:47:26 -07:00
bymyself
bf77f1d778 test: add browser test for textarea right-click in subgraph (red)
Add E2E test that right-clicks a textarea widget (CLIPTextEncode) inside
a subgraph and asserts the Promote Widget menu option is visible. This
test currently fails because WidgetTextarea.vue swallows the contextmenu
event with @contextmenu.capture.stop.
2026-03-13 09:47:14 -07:00
4 changed files with 95 additions and 2 deletions

View File

@@ -405,6 +405,41 @@ test.describe(
})
})
test.describe('Textarea Widget Context Menu in Subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Right-click on textarea widget inside subgraph shows Promote Widget option', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-text-widget'
)
// Navigate into the subgraph (node id 11)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
// Find the CLIPTextEncode node (id 10) which has a textarea widget
const nodeLocator = comfyPage.vueNodes.getNodeLocator('10')
// Right-click directly on the textarea DOM element
const textarea = nodeLocator.locator('textarea')
await expect(textarea).toBeVisible()
await textarea.click({ button: 'right' })
await comfyPage.nextFrame()
// The ComfyUI context menu should appear with Promote Widget option
const promoteEntry = comfyPage.page
.locator('.p-contextmenu .p-menuitem-text, .litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry.first()).toBeVisible({ timeout: 5000 })
})
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
comfyPage

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