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:
dante01yoon
2026-03-16 15:49:39 +09:00
parent 06f7e13957
commit 8033782f4b
4 changed files with 187 additions and 0 deletions

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

View File

@@ -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]',

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",