mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
314
src/composables/usePaste.test.ts
Normal file
314
src/composables/usePaste.test.ts
Normal 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user