feat: add Paste Image option to Load Image node context menu (#10021)

## Summary

- Adds a **Paste Image** context menu option to nodes that support image
pasting (e.g. Load Image), complementing the existing **Copy Image**
option
- Reads clipboard image data via `navigator.clipboard.read()` and
delegates to `node.pasteFiles()` — same path as `Ctrl+V` paste
- Only shown on nodes that implement `pasteFiles` (e.g. LoadImage)
- Available even when no image is loaded yet (e.g. fresh LoadImage node)
- **Node 2.0 context menu only** — legacy litegraph menu is not
supported

- Fixes #9989

<img width="852" height="685" alt="스크린샷 2026-03-16 오후 5 34 28"
src="https://github.com/user-attachments/assets/219e8162-312a-400b-90ec-961b95b5f472"
/>


## Test plan

- [x] Right-click a Load Image node (with or without image loaded) →
verify "Paste Image" appears
- [x] Copy an image to clipboard → click "Paste Image" → verify image is
loaded into the node
- [x] Verify "Paste Image" does not appear on output-only image nodes
(e.g. Save Image, Preview Image)
- [x] Unit tests pass: `pnpm vitest run
src/composables/graph/useImageMenuOptions.test.ts`

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dante
2026-03-16 23:21:05 +09:00
committed by GitHub
parent bf23384de0
commit f9102c7c44
5 changed files with 289 additions and 19 deletions

View File

@@ -0,0 +1,54 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Paste Image context menu option', { tag: ['@node'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('shows Paste Image in LoadImage node context menu', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const nodeEl = comfyPage.page.locator(
`[data-node-id="${loadImageNode.id}"]`
)
await nodeEl.click({ button: 'right' })
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
const menuLabels = await menu
.locator('[role="menuitem"] span.flex-1')
.allInnerTexts()
expect(menuLabels).toContain('Paste Image')
})
test('does not show Paste Image on output-only image nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/single_save_image_node')
const saveImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('SaveImage')
)[0]
const nodeEl = comfyPage.page.locator(
`[data-node-id="${saveImageNode.id}"]`
)
await nodeEl.click({ button: 'right' })
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
const menuLabels = await menu
.locator('[role="menuitem"] span.flex-1')
.allInnerTexts()
expect(menuLabels).not.toContain('Paste Image')
expect(menuLabels).not.toContain('Open Image')
})
})

View File

@@ -63,6 +63,7 @@ const CORE_MENU_ITEMS = new Set([
// Built-in node operations (node-specific)
'Open Image',
'Copy Image',
'Paste Image',
'Save Image',
'Open in Mask Editor',
'Edit Subgraph Widgets',
@@ -241,6 +242,7 @@ const MENU_ORDER: string[] = [
'Open in Mask Editor',
'Open Image',
'Copy Image',
'Paste Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',

View File

@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useI18n: () => ({
t: (key: string) => key.split('.').pop() ?? key
})
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
writable: true,
configurable: true
})
}
function createImageNode(
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
): LGraphNode {
const img = new Image()
img.src = 'http://localhost/test.png'
Object.defineProperty(img, 'naturalWidth', { value: 100 })
Object.defineProperty(img, 'naturalHeight', { value: 100 })
return createMockLGraphNode({
imgs: [img],
imageIndex: 0,
pasteFile: vi.fn(),
pasteFiles: vi.fn(),
...overrides
})
}
describe('useImageMenuOptions', () => {
afterEach(() => {
vi.restoreAllMocks()
})
describe('getImageMenuOptions', () => {
it('includes Paste Image option when node supports paste', () => {
const node = createImageNode()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
expect(labels).toContain('Paste Image')
})
it('excludes Paste Image option when node does not support paste', () => {
const node = createImageNode({ pasteFiles: undefined })
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
expect(labels).not.toContain('Paste Image')
})
it('returns empty array when node has no images and no pasteFiles', () => {
const node = createMockLGraphNode({ imgs: [] })
const { getImageMenuOptions } = useImageMenuOptions()
expect(getImageMenuOptions(node)).toEqual([])
})
it('returns only Paste Image when node has no images but supports paste', () => {
const node = createMockLGraphNode({
imgs: [],
pasteFile: vi.fn(),
pasteFiles: vi.fn()
})
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
expect(labels).toEqual(['Paste Image'])
})
it('places Paste Image between Copy Image and Save Image', () => {
const node = createImageNode()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const labels = options.map((o) => o.label)
const copyIdx = labels.indexOf('Copy Image')
const pasteIdx = labels.indexOf('Paste Image')
const saveIdx = labels.indexOf('Save Image')
expect(copyIdx).toBeLessThan(pasteIdx)
expect(pasteIdx).toBeLessThan(saveIdx)
})
})
describe('pasteImage action', () => {
it('reads clipboard and calls pasteFiles on the node', async () => {
const node = createImageNode()
const mockBlob = new Blob(['fake'], { type: 'image/png' })
const mockClipboardItem = {
types: ['image/png'],
getType: vi.fn().mockResolvedValue(mockBlob)
}
mockClipboard({
read: vi.fn().mockResolvedValue([mockClipboardItem])
} as unknown as Clipboard)
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const pasteOption = options.find((o) => o.label === 'Paste Image')
await pasteOption!.action!()
expect(navigator.clipboard.read).toHaveBeenCalled()
expect(node.pasteFile).toHaveBeenCalledWith(expect.any(File))
expect(node.pasteFiles).toHaveBeenCalledWith(
expect.arrayContaining([expect.any(File)])
)
})
it('handles missing clipboard API gracefully', async () => {
const node = createImageNode()
mockClipboard({ read: undefined } as unknown as Clipboard)
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const pasteOption = options.find((o) => o.label === 'Paste Image')
await expect(pasteOption!.action!()).resolves.toBeUndefined()
expect(node.pasteFiles).not.toHaveBeenCalled()
})
it('handles clipboard with no image data gracefully', async () => {
const node = createImageNode()
const mockClipboardItem = {
types: ['text/plain'],
getType: vi.fn()
}
mockClipboard({
read: vi.fn().mockResolvedValue([mockClipboardItem])
} as unknown as Clipboard)
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const pasteOption = options.find((o) => o.label === 'Paste Image')
await pasteOption!.action!()
expect(node.pasteFiles).not.toHaveBeenCalled()
})
})
})

View File

@@ -6,6 +6,36 @@ import { useCommandStore } from '@/stores/commandStore'
import type { MenuOption } from './useMoreOptionsMenu'
function canPasteImage(node?: LGraphNode): boolean {
return typeof node?.pasteFiles === 'function'
}
async function pasteClipboardImageToNode(node: LGraphNode): Promise<void> {
if (!navigator.clipboard?.read) {
console.warn('Clipboard API not available')
return
}
try {
const clipboardItems = await navigator.clipboard.read()
for (const item of clipboardItems) {
const imageType = item.types.find((type) => type.startsWith('image/'))
if (!imageType) continue
const blob = await item.getType(imageType)
const ext = imageType.split('/')[1] ?? 'png'
const file = new File([blob], `pasted-image.${ext}`, {
type: imageType
})
node.pasteFile?.(file)
node.pasteFiles?.([file])
return
}
} catch (error) {
console.error('Failed to paste image from clipboard:', error)
}
}
/**
* Composable for image-related menu operations
*/
@@ -78,29 +108,48 @@ export function useImageMenuOptions() {
}
const getImageMenuOptions = (node: LGraphNode): MenuOption[] => {
if (!node?.imgs?.length) return []
const hasImages = !!node?.imgs?.length
const canPaste = canPasteImage(node)
if (!hasImages && !canPaste) return []
return [
{
label: t('contextMenu.Open in Mask Editor'),
action: () => openMaskEditor()
},
{
label: t('contextMenu.Open Image'),
icon: 'icon-[lucide--external-link]',
action: () => openImage(node)
},
{
label: t('contextMenu.Copy Image'),
icon: 'icon-[lucide--copy]',
action: () => copyImage(node)
},
{
const options: MenuOption[] = []
if (hasImages) {
options.push(
{
label: t('contextMenu.Open in Mask Editor'),
action: () => openMaskEditor()
},
{
label: t('contextMenu.Open Image'),
icon: 'icon-[lucide--external-link]',
action: () => openImage(node)
},
{
label: t('contextMenu.Copy Image'),
icon: 'icon-[lucide--copy]',
action: () => copyImage(node)
}
)
}
if (canPaste) {
options.push({
label: t('contextMenu.Paste Image'),
icon: 'icon-[lucide--clipboard-paste]',
action: () => pasteClipboardImageToNode(node)
})
}
if (hasImages) {
options.push({
label: t('contextMenu.Save Image'),
icon: 'icon-[lucide--download]',
action: () => saveImage(node)
}
]
})
}
return options
}
return {

View File

@@ -546,6 +546,7 @@
"Open in Mask Editor": "Open in Mask Editor",
"Open Image": "Open Image",
"Copy Image": "Copy Image",
"Paste Image": "Paste Image",
"Save Image": "Save Image",
"Rename": "Rename",
"RenameWidget": "Rename Widget",