mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
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:
54
browser_tests/tests/pasteImageContextMenu.spec.ts
Normal file
54
browser_tests/tests/pasteImageContextMenu.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)',
|
||||
|
||||
164
src/composables/graph/useImageMenuOptions.test.ts
Normal file
164
src/composables/graph/useImageMenuOptions.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user