diff --git a/src/composables/usePaste.test.ts b/src/composables/usePaste.test.ts
index 4e1ac3503c..f380ea27c9 100644
--- a/src/composables/usePaste.test.ts
+++ b/src/composables/usePaste.test.ts
@@ -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', '
test html
')
+
+ const cloned = cloneDataTransfer(original)
+
+ expect(cloned.getData('text/plain')).toBe('test text')
+ expect(cloned.getData('text/html')).toBe('test html
')
+ })
+
+ 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)
+ })
+})
diff --git a/src/composables/usePaste.ts b/src/composables/usePaste.ts
index 1809eb838d..75d5e3af41 100644
--- a/src/composables/usePaste.ts
+++ b/src/composables/usePaste.ts
@@ -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 {
+ // 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 {
+ 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) {
diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts
index 208a454cd1..c8ec80872f 100644
--- a/src/lib/litegraph/src/LGraph.test.ts
+++ b/src/lib/litegraph/src/LGraph.test.ts
@@ -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' })
diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts
index 62c475c276..a51599e4c1 100644
--- a/src/lib/litegraph/src/LGraph.ts
+++ b/src/lib/litegraph/src/LGraph.ts
@@ -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(
diff --git a/src/scripts/app.test.ts b/src/scripts/app.test.ts
new file mode 100644
index 0000000000..ea63233331
--- /dev/null
+++ b/src/scripts/app.test.ts
@@ -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 {
+ const mockGraph: Partial = {
+ 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
+ )
+ })
+ })
+})
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index e16575d40e..f236a04975 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -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)) {
diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts
index 0155ead50b..6ed0927b5d 100644
--- a/src/services/litegraphService.ts
+++ b/src/services/litegraphService.ts
@@ -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
diff --git a/src/utils/__tests__/eventUtils.test.ts b/src/utils/__tests__/eventUtils.test.ts
index 2fc51ac677..ca20da96c9 100644
--- a/src/utils/__tests__/eventUtils.test.ts
+++ b/src/utils/__tests__/eventUtils.test.ts
@@ -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'
diff --git a/src/utils/eventUtils.ts b/src/utils/eventUtils.ts
index 133ccd709b..a9a789c723 100644
--- a/src/utils/eventUtils.ts
+++ b/src/utils/eventUtils.ts
@@ -1,14 +1,14 @@
export async function extractFileFromDragEvent(
event: DragEvent
-): Promise {
+): Promise {
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')
+}
diff --git a/src/utils/litegraphUtil.test.ts b/src/utils/litegraphUtil.test.ts
index 4640bd4b5c..a96028a3cd 100644
--- a/src/utils/litegraphUtil.test.ts
+++ b/src/utils/litegraphUtil.test.ts
@@ -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 {
+ const mockGraph = {
+ add: vi.fn((node) => node),
+ change: vi.fn()
+ } satisfies Partial as unknown as LGraph
+ const mockCanvas: Partial = {
+ 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 = {
diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts
index e47aab1d83..28e1818917 100644
--- a/src/utils/litegraphUtil.ts
+++ b/src/utils/litegraphUtil.ts
@@ -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 {
+ 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 (