mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
## 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>
159 lines
4.1 KiB
TypeScript
159 lines
4.1 KiB
TypeScript
import { useI18n } from 'vue-i18n'
|
|
|
|
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
|
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
|
|
*/
|
|
export function useImageMenuOptions() {
|
|
const { t } = useI18n()
|
|
|
|
const openMaskEditor = () => {
|
|
const commandStore = useCommandStore()
|
|
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
|
}
|
|
|
|
const openImage = (node: LGraphNode) => {
|
|
if (!node?.imgs?.length) return
|
|
const img = node.imgs[node.imageIndex ?? 0]
|
|
if (!img) return
|
|
const url = new URL(img.src)
|
|
url.searchParams.delete('preview')
|
|
void openFileInNewTab(url.toString())
|
|
}
|
|
|
|
const copyImage = async (node: LGraphNode) => {
|
|
if (!node?.imgs?.length) return
|
|
const img = node.imgs[node.imageIndex ?? 0]
|
|
if (!img) return
|
|
|
|
const canvas = document.createElement('canvas')
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
canvas.width = img.naturalWidth
|
|
canvas.height = img.naturalHeight
|
|
ctx.drawImage(img, 0, 0)
|
|
|
|
try {
|
|
const blob = await new Promise<Blob | null>((resolve) => {
|
|
canvas.toBlob(resolve, 'image/png')
|
|
})
|
|
|
|
if (!blob) {
|
|
console.warn('Failed to create image blob')
|
|
return
|
|
}
|
|
|
|
// Check if clipboard API is available
|
|
if (!navigator.clipboard?.write) {
|
|
console.warn('Clipboard API not available')
|
|
return
|
|
}
|
|
|
|
await navigator.clipboard.write([
|
|
new ClipboardItem({ 'image/png': blob })
|
|
])
|
|
} catch (error) {
|
|
console.error('Failed to copy image to clipboard:', error)
|
|
}
|
|
}
|
|
|
|
const saveImage = (node: LGraphNode) => {
|
|
if (!node?.imgs?.length) return
|
|
const img = node.imgs[node.imageIndex ?? 0]
|
|
if (!img) return
|
|
|
|
try {
|
|
const url = new URL(img.src)
|
|
url.searchParams.delete('preview')
|
|
downloadFile(url.toString())
|
|
} catch (error) {
|
|
console.error('Failed to save image:', error)
|
|
}
|
|
}
|
|
|
|
const getImageMenuOptions = (node: LGraphNode): MenuOption[] => {
|
|
const hasImages = !!node?.imgs?.length
|
|
const canPaste = canPasteImage(node)
|
|
if (!hasImages && !canPaste) return []
|
|
|
|
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 {
|
|
getImageMenuOptions
|
|
}
|
|
}
|