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 (