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:
Brian Jemilo II
2026-02-11 19:39:41 -06:00
committed by GitHub
parent 0f5aca6726
commit a80f6d7922
11 changed files with 629 additions and 55 deletions

View File

@@ -7,8 +7,13 @@ import type {
} 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'
import { createNode, isImageNode } from '@/utils/litegraphUtil'
import {
cloneDataTransfer,
pasteImageNode,
pasteImageNodes,
usePaste
} from './usePaste'
function createMockNode() {
return {
@@ -86,6 +91,7 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
}))
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
isAudioNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn()
@@ -99,34 +105,32 @@ describe('pasteImageNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should create new LoadImage node when no image node provided', () => {
it('should create new LoadImage node when no image node provided', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
await 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(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should use existing image node when provided', () => {
it('should use existing image node when provided', async () => {
const mockNode = createMockNode()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -136,13 +140,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
})
it('should handle multiple image files', () => {
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])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -152,11 +156,11 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
})
it('should do nothing when no image files present', () => {
it('should do nothing when no image files present', async () => {
const mockNode = createMockNode()
const dataTransfer = createDataTransfer()
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -166,13 +170,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
})
it('should filter non-image items', () => {
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])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -183,21 +187,61 @@ describe('pasteImageNode', () => {
})
})
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) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should handle image paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
usePaste()
@@ -207,7 +251,7 @@ describe('usePaste', () => {
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
@@ -312,3 +356,62 @@ describe('usePaste', () => {
})
})
})
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)
})
})

View File

@@ -6,9 +6,41 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import {
createNode,
isAudioNode,
isImageNode,
isVideoNode
} from '@/utils/litegraphUtil'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
export function cloneDataTransfer(original: DataTransfer): DataTransfer {
const persistent = new DataTransfer()
// Copy string data
for (const type of original.types) {
const data = original.getData(type)
if (data) {
persistent.setData(type, data)
}
}
for (const item of original.items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
persistent.items.add(file)
}
}
}
// Preserve dropEffect and effectAllowed
persistent.dropEffect = original.dropEffect
persistent.effectAllowed = original.effectAllowed
return persistent
}
function pasteClipboardItems(data: DataTransfer): boolean {
const rawData = data.getData('text/html')
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
@@ -48,27 +80,37 @@ function pasteItemsOnNode(
)
}
export function pasteImageNode(
export async function pasteImageNode(
canvas: LGraphCanvas,
items: DataTransferItemList,
imageNode: LGraphNode | null = null
): void {
const {
graph,
graph_mouse: [posX, posY]
} = canvas
): Promise<LGraphNode | null> {
// No image node selected: add a new one
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()
imageNode = await createNode(canvas, 'LoadImage')
}
pasteItemsOnNode(items, imageNode, 'image')
return imageNode
}
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: FileList
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []
for (const file of fileList) {
const transfer = new DataTransfer()
transfer.items.add(file)
const imageNode = await pasteImageNode(canvas, transfer.items)
if (imageNode) {
nodes.push(imageNode)
}
}
return nodes
}
/**
@@ -93,6 +135,7 @@ export const usePaste = () => {
const { graph } = canvas
let data: DataTransfer | string | null = e.clipboardData
if (!data) throw new Error('No clipboard data on clipboard event')
data = cloneDataTransfer(data)
const { items } = data
@@ -114,7 +157,7 @@ export const usePaste = () => {
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {

View File

@@ -48,6 +48,17 @@ describe('LGraph', () => {
expect(result1).toEqual(result2)
})
it('should handle adding null node gracefully', () => {
const graph = new LGraph()
const initialNodeCount = graph.nodes.length
const result = graph.add(null)
expect(result).toBeUndefined()
expect(graph.nodes.length).toBe(initialNodeCount)
})
test('can be instantiated', ({ expect }) => {
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
const graph = new LGraph({ extra: 'TestGraph' })

View File

@@ -896,7 +896,7 @@ export class LGraph
* @deprecated Use options object instead
*/
add(
node: LGraphNode | LGraphGroup,
node: LGraphNode | LGraphGroup | null,
skipComputeOrder?: boolean
): LGraphNode | null | undefined
add(

200
src/scripts/app.test.ts Normal file
View 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
)
})
})
})

View File

@@ -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)) {

View File

@@ -880,7 +880,6 @@ export const useLitegraphService = () => {
const graph = useWorkflowStore().activeSubgraph ?? app.graph
// @ts-expect-error fixme ts strict error
graph.add(node)
// @ts-expect-error fixme ts strict error
return node

View File

@@ -33,6 +33,45 @@ describe('eventUtils', () => {
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
})
it('should handle drops with multiple image files', async () => {
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
type: 'image/png'
})
const imageFile2 = new File([new Uint8Array()], 'image2.jpg', {
type: 'image/jpeg'
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(imageFile1)
dataTransfer.items.add(imageFile2)
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBeDefined()
expect((actual as FileList).length).toBe(2)
expect((actual as FileList)[0]).toBe(imageFile1)
expect((actual as FileList)[1]).toBe(imageFile2)
})
it('should return undefined when dropping multiple non-image files', async () => {
const file1 = new File([new Uint8Array()], 'file1.txt', {
type: 'text/plain'
})
const file2 = new File([new Uint8Array()], 'file2.txt', {
type: 'text/plain'
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file1)
dataTransfer.items.add(file2)
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBe(undefined)
})
// Skip until we can setup MSW
it.skip('should handle drops with URLs', async () => {
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'

View File

@@ -1,14 +1,14 @@
export async function extractFileFromDragEvent(
event: DragEvent
): Promise<File | undefined> {
): Promise<File | FileList | undefined> {
if (!event.dataTransfer) return
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
if (
event.dataTransfer.files.length &&
event.dataTransfer.files[0].type !== 'image/bmp'
) {
return event.dataTransfer.files[0]
const { files } = event.dataTransfer
// Dragging from Chrome->Firefox there is a file, but it's a bmp, so ignore it
if (files.length === 1 && files[0].type !== 'image/bmp') {
return files[0]
} else if (files.length > 1 && Array.from(files).every(hasImageType)) {
return files
}
// Try loading the first URI in the transfer list
@@ -25,3 +25,7 @@ export async function extractFileFromDragEvent(
const blob = await response.blob()
return new File([blob], uri, { type: blob.type })
}
function hasImageType({ type }: File): boolean {
return type.startsWith('image')
}

View File

@@ -1,13 +1,97 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
compressWidgetInputSlots,
createNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => key)
}))
function createMockCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
const mockGraph = {
add: vi.fn((node) => node),
change: vi.fn()
} satisfies Partial<LGraph> as unknown as LGraph
const mockCanvas: Partial<LGraphCanvas> = {
graph_mouse: [100, 200],
graph: mockGraph,
...overrides
}
return mockCanvas as LGraphCanvas
}
describe('createNode', () => {
beforeEach(vi.clearAllMocks)
it('should create a node successfully', async () => {
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, 'LoadImage')
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(result).toBe(mockNode)
})
it('should return null when name is empty', async () => {
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, '')
expect(LiteGraph.createNode).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('should handle graph being null', async () => {
const mockNode = { pos: [0, 0] }
const mockCanvas = createMockCanvas({ graph: null })
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const result = await createNode(mockCanvas, 'LoadImage')
expect(mockNode.pos).toEqual([0, 0])
expect(result).toBeNull()
})
it('should set position based on canvas graph_mouse', async () => {
const mockCanvas = createMockCanvas({ graph_mouse: [250, 350] })
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
await createNode(mockCanvas, 'LoadAudio')
expect(mockNode.pos).toEqual([250, 350])
})
})
describe('migrateWidgetsValues', () => {
it('should remove widget values for forceInput inputs', () => {
const inputDefs: Record<string, InputSpec> = {

View File

@@ -1,9 +1,14 @@
import _ from 'es-toolkit/compat'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type {
ColorOption,
LGraph,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
LGraphGroup,
LGraphNode,
LiteGraph,
Reroute,
isColorable
} from '@/lib/litegraph/src/litegraph'
@@ -18,6 +23,8 @@ import type {
WidgetCallbackOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { t } from '@/i18n'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
@@ -25,6 +32,38 @@ type VideoNode = LGraphNode & {
imgs: HTMLVideoElement[] | undefined
}
/**
* Extract & Promisify Litegraph.createNode to allow for positioning
* @param canvas
* @param name
*/
export async function createNode(
canvas: LGraphCanvas,
name: string
): Promise<LGraphNode | null> {
if (!name) {
return null
}
const {
graph,
graph_mouse: [posX, posY]
} = canvas
const newNode = LiteGraph.createNode(name)
await new Promise((r) => setTimeout(r, 0))
if (newNode && graph) {
newNode.pos = [posX, posY]
const addedNode = graph.add(newNode) ?? null
if (addedNode) graph.change()
return addedNode
} else {
useToastStore().addAlert(t('assetBrowser.failedToCreateNode'))
return null
}
}
export function isImageNode(node: LGraphNode | undefined): node is ImageNode {
if (!node) return false
return (