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
- Fixes #9989 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)',
|
||||
|
||||
149
src/composables/graph/useImageMenuOptions.test.ts
Normal file
149
src/composables/graph/useImageMenuOptions.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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,
|
||||
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', () => {
|
||||
const node = createMockLGraphNode({ imgs: [] })
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
|
||||
expect(getImageMenuOptions(node)).toEqual([])
|
||||
})
|
||||
|
||||
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.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,10 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
|
||||
function canPasteImage(node: LGraphNode): boolean {
|
||||
return typeof node.pasteFiles === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for image-related menu operations
|
||||
*/
|
||||
@@ -63,6 +67,28 @@ export function useImageMenuOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
const pasteImage = async (node: LGraphNode) => {
|
||||
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 file = new File([blob], 'pasted-image.png', { type: imageType })
|
||||
node.pasteFiles?.([file])
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to paste image from clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (node: LGraphNode) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
@@ -95,6 +121,15 @@ export function useImageMenuOptions() {
|
||||
icon: 'icon-[lucide--copy]',
|
||||
action: () => copyImage(node)
|
||||
},
|
||||
...(canPasteImage(node)
|
||||
? [
|
||||
{
|
||||
label: t('contextMenu.Paste Image'),
|
||||
icon: 'icon-[lucide--clipboard-paste]',
|
||||
action: () => pasteImage(node)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('contextMenu.Save Image'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
|
||||
@@ -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