From a80f6d7922280cd1c012ca4dde839feece74d9d9 Mon Sep 17 00:00:00 2001 From: Brian Jemilo II Date: Wed, 11 Feb 2026 19:39:41 -0600 Subject: [PATCH] Batch Drag & Drop Images (#8282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary 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 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. 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 Co-authored-by: Austin Mroz --- src/composables/usePaste.test.ts | 153 +++++++++++++++---- src/composables/usePaste.ts | 75 ++++++++-- src/lib/litegraph/src/LGraph.test.ts | 11 ++ src/lib/litegraph/src/LGraph.ts | 2 +- src/scripts/app.test.ts | 200 +++++++++++++++++++++++++ src/scripts/app.ts | 58 ++++++- src/services/litegraphService.ts | 1 - src/utils/__tests__/eventUtils.test.ts | 39 +++++ src/utils/eventUtils.ts | 18 ++- src/utils/litegraphUtil.test.ts | 86 ++++++++++- src/utils/litegraphUtil.ts | 41 ++++- 11 files changed, 629 insertions(+), 55 deletions(-) create mode 100644 src/scripts/app.test.ts 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 (