Drag image to load image (#7898)

## Summary

<!-- One sentence describing what changed and why. -->
Added feature to drag image into workflow to create a load image node if
the image does not have workflow meta data.

Also added tests for usePaste.ts as I extracted code to be reusable
there and there wasn't any tests.

## Changes

- **What**: <!-- Core functionality added/modified -->
app.ts handleFile updated,
usePaste.ts usePaste updated with new method pasteImageNode

## Review Focus
<!-- Fixes #ISSUE_NUMBER -->
Not sure if it has an issue, just has a notion task.

https://www.notion.so/comfy-org/Drag-in-an-image-that-s-not-a-workflow-and-being-able-to-directly-loading-it-as-Load-Image-2156d73d365080c4851ffc1425e06caf

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->


https://github.com/user-attachments/assets/0403e4f1-2a99-4939-bf01-3d9e8f9834bb

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7898-Drag-image-to-load-image-2e26d73d36508187abdff986e8087370)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Brian Jemilo II
2026-01-10 14:55:19 -06:00
committed by GitHub
parent 52b94e06a1
commit df1eb32907
3 changed files with 369 additions and 34 deletions

View File

@@ -0,0 +1,314 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraphCanvas,
LGraph,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isImageNode } from '@/utils/litegraphUtil'
import { pasteImageNode, usePaste } from './usePaste'
function createMockNode() {
return {
pos: [0, 0],
pasteFile: vi.fn(),
pasteFiles: vi.fn()
}
}
function createImageFile(
name: string = 'test.png',
type: string = 'image/png'
): File {
return new File([''], name, { type })
}
function createAudioFile(
name: string = 'test.mp3',
type: string = 'audio/mpeg'
): File {
return new File([''], name, { type })
}
function createDataTransfer(files: File[] = []): DataTransfer {
const dataTransfer = new DataTransfer()
files.forEach((file) => dataTransfer.items.add(file))
return dataTransfer
}
const mockCanvas = {
current_node: null as LGraphNode | null,
graph: {
add: vi.fn(),
change: vi.fn()
} as Partial<LGraph> as LGraph,
graph_mouse: [100, 200],
pasteFromClipboard: vi.fn(),
_deserializeItems: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
const mockCanvasStore = {
canvas: mockCanvas,
getCanvas: vi.fn(() => mockCanvas)
}
const mockWorkspaceStore = {
shiftDown: false
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn((target, event, handler) => {
target.addEventListener(event, handler)
return () => target.removeEventListener(event, handler)
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => mockWorkspaceStore
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/utils/litegraphUtil', () => ({
isAudioNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn()
}))
vi.mock('@/workbench/eventHelpers', () => ({
shouldIgnoreCopyPaste: vi.fn()
}))
describe('pasteImageNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
)
})
it('should create new LoadImage node when no image node provided', () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should use existing image node when provided', () => {
const mockNode = createMockNode()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
})
it('should handle multiple image files', () => {
const mockNode = createMockNode()
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const dataTransfer = createDataTransfer([file1, file2])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
})
it('should do nothing when no image files present', () => {
const mockNode = createMockNode()
const dataTransfer = createDataTransfer()
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).not.toHaveBeenCalled()
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
})
it('should filter non-image items', () => {
const mockNode = createMockNode()
const imageFile = createImageFile()
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
const dataTransfer = createDataTransfer([textFile, imageFile])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
)
})
it('should handle image paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
it('should handle audio paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
usePaste()
const file = createAudioFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
it('should handle workflow JSON paste', async () => {
const workflow = { version: '1.0', nodes: [], extra: {} }
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', JSON.stringify(workflow))
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(app.loadGraphData).toHaveBeenCalledWith(workflow)
})
})
it('should ignore paste when shift is down', () => {
mockWorkspaceStore.shiftDown = true
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(LiteGraph.createNode).not.toHaveBeenCalled()
})
it('should use existing image node when selected', () => {
const mockNode = {
is_selected: true,
pasteFile: vi.fn(),
pasteFiles: vi.fn()
} as unknown as Partial<LGraphNode> as LGraphNode
mockCanvas.current_node = mockNode
vi.mocked(isImageNode).mockReturnValue(true)
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should call canvas pasteFromClipboard for non-workflow text', () => {
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'just some text')
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
it('should handle clipboard items with metadata', async () => {
const data = { test: 'data' }
const encoded = btoa(JSON.stringify(data))
const html = `<div data-metadata="${encoded}"></div>`
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).toHaveBeenCalledWith(
data,
expect.any(Object)
)
})
})
})

View File

@@ -1,7 +1,7 @@
import { useEventListener } from '@vueuse/core'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -26,6 +26,48 @@ function pasteClipboardItems(data: DataTransfer): boolean {
return false
}
function pasteItemsOnNode(
items: DataTransferItemList,
node: LGraphNode | null,
contentType: string
): void {
if (!node) return
const filteredItems = Array.from(items).filter((item) =>
item.type.startsWith(contentType)
)
const blob = filteredItems[0]?.getAsFile()
if (!blob) return
node.pasteFile?.(blob)
node.pasteFiles?.(
Array.from(filteredItems)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
}
export function pasteImageNode(
canvas: LGraphCanvas,
items: DataTransferItemList,
imageNode: LGraphNode | null = null
): void {
const { graph, graph_mouse: [posX, posY] } = canvas
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [posX, posY]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
}
pasteItemsOnNode(items, imageNode, 'image')
}
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
*/
@@ -33,28 +75,6 @@ export const usePaste = () => {
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const pasteItemsOnNode = (
items: DataTransferItemList,
node: LGraphNode | null,
contentType: string
) => {
if (!node) return
const filteredItems = Array.from(items).filter((item) =>
item.type.startsWith(contentType)
)
const blob = filteredItems[0]?.getAsFile()
if (!blob) return
node.pasteFile?.(blob)
node.pasteFiles?.(
Array.from(filteredItems)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
}
useEventListener(document, 'paste', async (e) => {
if (shouldIgnoreCopyPaste(e.target)) {
// Default system copy
@@ -80,8 +100,10 @@ export const usePaste = () => {
const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode)
const isAudioNodeSelected = isNodeSelected && isAudioNode(currentNode)
let imageNode: LGraphNode | null = isImageNodeSelected ? currentNode : null
let audioNode: LGraphNode | null = isAudioNodeSelected ? currentNode : null
const imageNode: LGraphNode | null = isImageNodeSelected
? currentNode
: null
const videoNode: LGraphNode | null = isVideoNodeSelected
? currentNode
: null
@@ -89,16 +111,7 @@ export const usePaste = () => {
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
}
pasteItemsOnNode(items, imageNode, 'image')
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {

View File

@@ -96,6 +96,7 @@ import { type ComfyWidgetConstructor } from './widgets'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { extractFileFromDragEvent } from '@/utils/eventUtils'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { pasteImageNode } from '@/composables/usePaste'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -1441,6 +1442,13 @@ export class ComfyApp {
const fileName = file.name.replace(/\.\w+$/, '') // Strip file extension
const workflowData = await getWorkflowDataFromFile(file)
if (!workflowData) {
if (file.type.startsWith('image')) {
const transfer = new DataTransfer()
transfer.items.add(file)
pasteImageNode(this.canvas, transfer.items)
return
}
this.showErrorOnFileLoad(file)
return
}