mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 06:44:32 +00:00
Batch Drag & Drop Images (#8282)
## 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>
This commit is contained in:
200
src/scripts/app.test.ts
Normal file
200
src/scripts/app.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { ComfyApp } from './app'
|
||||
import { createNode } from '@/utils/litegraphUtil'
|
||||
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
createNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn(),
|
||||
isAudioNode: vi.fn(),
|
||||
executeWidgetsCallback: vi.fn(),
|
||||
fixLinkInputSlots: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePaste', () => ({
|
||||
pasteImageNode: vi.fn(),
|
||||
pasteImageNodes: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/metadata/parser', () => ({
|
||||
getWorkflowDataFromFile: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({
|
||||
addAlert: vi.fn(),
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
function createMockNode(options: { [K in keyof LGraphNode]?: any } = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
type: 'LoadImage',
|
||||
connect: vi.fn(),
|
||||
getBounding: vi.fn(() => new Float64Array([0, 0, 200, 100])),
|
||||
...options
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
function createMockCanvas(): Partial<LGraphCanvas> {
|
||||
const mockGraph: Partial<LGraph> = {
|
||||
change: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
graph: mockGraph as LGraph,
|
||||
selectItems: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createTestFile(name: string, type: string): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
describe('ComfyApp', () => {
|
||||
let app: ComfyApp
|
||||
let mockCanvas: LGraphCanvas
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
app = new ComfyApp()
|
||||
mockCanvas = createMockCanvas() as LGraphCanvas
|
||||
app.canvas = mockCanvas as LGraphCanvas
|
||||
})
|
||||
|
||||
describe('handleFileList', () => {
|
||||
it('should create image nodes for each file in the list', async () => {
|
||||
const mockNode1 = createMockNode({ id: 1 })
|
||||
const mockNode2 = createMockNode({ id: 2 })
|
||||
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
|
||||
|
||||
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2])
|
||||
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
|
||||
|
||||
const file1 = createTestFile('test1.png', 'image/png')
|
||||
const file2 = createTestFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const { files } = dataTransfer
|
||||
|
||||
await app.handleFileList(files)
|
||||
|
||||
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
|
||||
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
|
||||
mockNode1,
|
||||
mockNode2,
|
||||
mockBatchNode
|
||||
])
|
||||
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
|
||||
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
|
||||
})
|
||||
|
||||
it('should not proceed if batch node creation fails', async () => {
|
||||
const mockNode1 = createMockNode({ id: 1 })
|
||||
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1])
|
||||
vi.mocked(createNode).mockResolvedValue(null)
|
||||
|
||||
const file = createTestFile('test.png', 'image/png')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
await app.handleFileList(dataTransfer.files)
|
||||
|
||||
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
|
||||
expect(mockNode1.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const dataTransfer = new DataTransfer()
|
||||
await expect(app.handleFileList(dataTransfer.files)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should not process unsupported file types', async () => {
|
||||
const invalidFile = createTestFile('test.pdf', 'application/pdf')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(invalidFile)
|
||||
|
||||
await app.handleFileList(dataTransfer.files)
|
||||
|
||||
expect(pasteImageNodes).not.toHaveBeenCalled()
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('positionBatchNodes', () => {
|
||||
it('should position batch node to the right of first node', () => {
|
||||
const mockNode1 = createMockNode({
|
||||
pos: [100, 200],
|
||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
||||
})
|
||||
const mockBatchNode = createMockNode({ pos: [0, 0] })
|
||||
|
||||
app.positionBatchNodes([mockNode1], mockBatchNode)
|
||||
|
||||
expect(mockBatchNode.pos).toEqual([500, 230])
|
||||
})
|
||||
|
||||
it('should stack multiple image nodes vertically', () => {
|
||||
const mockNode1 = createMockNode({
|
||||
pos: [100, 200],
|
||||
type: 'LoadImage',
|
||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
||||
})
|
||||
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
||||
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
||||
const mockBatchNode = createMockNode({ pos: [0, 0] })
|
||||
|
||||
app.positionBatchNodes([mockNode1, mockNode2, mockNode3], mockBatchNode)
|
||||
|
||||
expect(mockNode1.pos).toEqual([100, 200])
|
||||
expect(mockNode2.pos).toEqual([100, 594])
|
||||
expect(mockNode3.pos).toEqual([100, 963])
|
||||
})
|
||||
|
||||
it('should call graph change once for all nodes', () => {
|
||||
const mockNode1 = createMockNode({
|
||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
||||
})
|
||||
const mockBatchNode = createMockNode()
|
||||
|
||||
app.positionBatchNodes([mockNode1], mockBatchNode)
|
||||
|
||||
expect(mockCanvas.graph?.change).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleFile', () => {
|
||||
it('should handle image files by creating LoadImage node', async () => {
|
||||
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
|
||||
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const imageFile = createTestFile('test.png', 'image/png')
|
||||
|
||||
await app.handleFile(imageFile)
|
||||
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(pasteImageNode).toHaveBeenCalledWith(
|
||||
mockCanvas,
|
||||
expect.any(DataTransferItemList),
|
||||
mockNode
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
executeWidgetsCallback,
|
||||
createNode,
|
||||
fixLinkInputSlots,
|
||||
isImageNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
@@ -108,7 +109,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'
|
||||
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
||||
|
||||
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
|
||||
|
||||
@@ -553,7 +554,13 @@ export class ComfyApp {
|
||||
const workspace = useWorkspaceStore()
|
||||
try {
|
||||
workspace.spinner = true
|
||||
await this.handleFile(fileMaybe, 'file_drop')
|
||||
if (fileMaybe instanceof File) {
|
||||
await this.handleFile(fileMaybe, 'file_drop')
|
||||
}
|
||||
|
||||
if (fileMaybe instanceof FileList) {
|
||||
await this.handleFileList(fileMaybe)
|
||||
}
|
||||
} finally {
|
||||
workspace.spinner = false
|
||||
}
|
||||
@@ -1488,7 +1495,8 @@ export class ComfyApp {
|
||||
if (file.type.startsWith('image')) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
pasteImageNode(this.canvas, transfer.items)
|
||||
const imageNode = await createNode(this.canvas, 'LoadImage')
|
||||
await pasteImageNode(this.canvas, transfer.items, imageNode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1567,6 +1575,50 @@ export class ComfyApp {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Loads multiple files, connects to a batch node, and selects them
|
||||
* @param {FileList} fileList
|
||||
*/
|
||||
async handleFileList(fileList: FileList) {
|
||||
if (fileList[0].type.startsWith('image')) {
|
||||
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
||||
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
||||
if (!batchImagesNode) return
|
||||
|
||||
this.positionBatchNodes(imageNodes, batchImagesNode)
|
||||
this.canvas.selectItems([...imageNodes, batchImagesNode])
|
||||
|
||||
Array.from(imageNodes).forEach((imageNode, index) => {
|
||||
imageNode.connect(0, batchImagesNode, index)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions batched nodes in drag and drop
|
||||
* @param nodes
|
||||
* @param batchNode
|
||||
*/
|
||||
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
|
||||
const [x, y, width] = nodes[0].getBounding()
|
||||
batchNode.pos = [ x + width + 100, y + 30 ]
|
||||
|
||||
// Retrieving Node Height is inconsistent
|
||||
let height = 0;
|
||||
if (nodes[0].type === 'LoadImage') {
|
||||
height = 344
|
||||
}
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
if (index > 0) {
|
||||
node.pos = [ x, y + (height * index) + (25 * (index + 1)) ]
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.graph?.change()
|
||||
}
|
||||
|
||||
// @deprecated
|
||||
isApiJson(data: unknown): data is ComfyApiWorkflow {
|
||||
if (!_.isObject(data) || Array.isArray(data)) {
|
||||
|
||||
Reference in New Issue
Block a user