mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 04:31:58 +00:00
## Summary <!-- One sentence describing what changed and why. --> Added feature to drag and drop multiple images into the UI and connect them with a Batch Images node with tests to add convenience for users. Only works with a group of images, mixing files not supported. ## Review Focus <!-- Critical design decisions or edge cases that need attention --> I've updated our usage of Litegraph.createNode, honestly, that method is pretty bad, onNodeCreated option method doesn't even return the node created. I think I will probably go check out their repo to do a PR over there. Anyways, I made a createNode method to avoid race conditions when creating nodes for the paste actions. Will allow us to better programmatically create nodes that do not have workflows that also need to be connected to other nodes. <!-- If this PR fixes an issue, uncomment and update the line below --> https://www.notion.so/comfy-org/Implement-Multi-image-drag-and-drop-to-canvas-2eb6d73d36508195ad8addfc4367db10 ## Screenshots (if applicable) https://github.com/user-attachments/assets/d4155807-56e2-4e39-8ab1-16eda90f6a53 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8282-Batch-Drag-Drop-Images-2f16d73d365081c1ab31ce9da47a7be5) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Austin Mroz <austin@comfy.org>
418 lines
12 KiB
TypeScript
418 lines
12 KiB
TypeScript
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 { createNode, isImageNode } from '@/utils/litegraphUtil'
|
|
import {
|
|
cloneDataTransfer,
|
|
pasteImageNode,
|
|
pasteImageNodes,
|
|
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', () => ({
|
|
createNode: vi.fn(),
|
|
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 | null) => node as LGraphNode
|
|
)
|
|
})
|
|
|
|
it('should create new LoadImage node when no image node provided', async () => {
|
|
const mockNode = createMockNode()
|
|
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
|
|
|
|
const file = createImageFile()
|
|
const dataTransfer = createDataTransfer([file])
|
|
|
|
await pasteImageNode(
|
|
mockCanvas as unknown as LGraphCanvas,
|
|
dataTransfer.items
|
|
)
|
|
|
|
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
|
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
|
})
|
|
|
|
it('should use existing image node when provided', async () => {
|
|
const mockNode = createMockNode()
|
|
const file = createImageFile()
|
|
const dataTransfer = createDataTransfer([file])
|
|
|
|
await 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', async () => {
|
|
const mockNode = createMockNode()
|
|
const file1 = createImageFile('test1.png')
|
|
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
|
const dataTransfer = createDataTransfer([file1, file2])
|
|
|
|
await 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', async () => {
|
|
const mockNode = createMockNode()
|
|
const dataTransfer = createDataTransfer()
|
|
|
|
await 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', async () => {
|
|
const mockNode = createMockNode()
|
|
const imageFile = createImageFile()
|
|
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
|
const dataTransfer = createDataTransfer([textFile, imageFile])
|
|
|
|
await pasteImageNode(
|
|
mockCanvas as unknown as LGraphCanvas,
|
|
dataTransfer.items,
|
|
mockNode as unknown as LGraphNode
|
|
)
|
|
|
|
expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile)
|
|
expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile])
|
|
})
|
|
})
|
|
|
|
describe('pasteImageNodes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('should create multiple nodes for multiple files', async () => {
|
|
const mockNode1 = createMockNode()
|
|
const mockNode2 = createMockNode()
|
|
vi.mocked(createNode)
|
|
.mockResolvedValueOnce(mockNode1 as unknown as LGraphNode)
|
|
.mockResolvedValueOnce(mockNode2 as unknown as LGraphNode)
|
|
|
|
const file1 = createImageFile('test1.png')
|
|
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
|
const fileList = createDataTransfer([file1, file2]).files
|
|
|
|
const result = await pasteImageNodes(
|
|
mockCanvas as unknown as LGraphCanvas,
|
|
fileList
|
|
)
|
|
|
|
expect(createNode).toHaveBeenCalledTimes(2)
|
|
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
|
|
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
|
|
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
|
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
|
expect(result).toEqual([mockNode1, mockNode2])
|
|
})
|
|
|
|
it('should handle empty file list', async () => {
|
|
const fileList = createDataTransfer([]).files
|
|
|
|
const result = await pasteImageNodes(
|
|
mockCanvas as unknown as LGraphCanvas,
|
|
fileList
|
|
)
|
|
|
|
expect(createNode).not.toHaveBeenCalled()
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('usePaste', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockCanvas.current_node = null
|
|
mockWorkspaceStore.shiftDown = false
|
|
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
|
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
|
)
|
|
})
|
|
|
|
it('should handle image paste', async () => {
|
|
const mockNode = createMockNode()
|
|
vi.mocked(createNode).mockResolvedValue(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(createNode).toHaveBeenCalledWith(mockCanvas, '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)
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('cloneDataTransfer', () => {
|
|
it('should clone string data', () => {
|
|
const original = new DataTransfer()
|
|
original.setData('text/plain', 'test text')
|
|
original.setData('text/html', '<p>test html</p>')
|
|
|
|
const cloned = cloneDataTransfer(original)
|
|
|
|
expect(cloned.getData('text/plain')).toBe('test text')
|
|
expect(cloned.getData('text/html')).toBe('<p>test html</p>')
|
|
})
|
|
|
|
it('should clone files', () => {
|
|
const file1 = createImageFile('test1.png')
|
|
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
|
const original = createDataTransfer([file1, file2])
|
|
|
|
const cloned = cloneDataTransfer(original)
|
|
|
|
// Files are added from both .files and .items, causing duplicates
|
|
expect(cloned.files.length).toBeGreaterThanOrEqual(2)
|
|
expect(Array.from(cloned.files)).toContain(file1)
|
|
expect(Array.from(cloned.files)).toContain(file2)
|
|
})
|
|
|
|
it('should preserve dropEffect and effectAllowed', () => {
|
|
const original = new DataTransfer()
|
|
original.dropEffect = 'copy'
|
|
original.effectAllowed = 'copyMove'
|
|
|
|
const cloned = cloneDataTransfer(original)
|
|
|
|
expect(cloned.dropEffect).toBe('copy')
|
|
expect(cloned.effectAllowed).toBe('copyMove')
|
|
})
|
|
|
|
it('should handle empty DataTransfer', () => {
|
|
const original = new DataTransfer()
|
|
|
|
const cloned = cloneDataTransfer(original)
|
|
|
|
expect(cloned.types.length).toBe(0)
|
|
expect(cloned.files.length).toBe(0)
|
|
})
|
|
|
|
it('should clone both string data and files', () => {
|
|
const file = createImageFile()
|
|
const original = createDataTransfer([file])
|
|
original.setData('text/plain', 'test')
|
|
|
|
const cloned = cloneDataTransfer(original)
|
|
|
|
expect(cloned.getData('text/plain')).toBe('test')
|
|
// Files are added from both .files and .items
|
|
expect(cloned.files.length).toBeGreaterThanOrEqual(1)
|
|
expect(Array.from(cloned.files)).toContain(file)
|
|
})
|
|
})
|