From df1eb329079d321c82fb7d9fba244ae97ca863e3 Mon Sep 17 00:00:00 2001 From: Brian Jemilo II Date: Sat, 10 Jan 2026 14:55:19 -0600 Subject: [PATCH] Drag image to load image (#7898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Added feature to drag image into workflow to create a load image node if the image does not have workflow meta data. Also added tests for usePaste.ts as I extracted code to be reusable there and there wasn't any tests. ## Changes - **What**: app.ts handleFile updated, usePaste.ts usePaste updated with new method pasteImageNode ## Review Focus Not sure if it has an issue, just has a notion task. https://www.notion.so/comfy-org/Drag-in-an-image-that-s-not-a-workflow-and-being-able-to-directly-loading-it-as-Load-Image-2156d73d365080c4851ffc1425e06caf ## Screenshots (if applicable) https://github.com/user-attachments/assets/0403e4f1-2a99-4939-bf01-3d9e8f9834bb ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7898-Drag-image-to-load-image-2e26d73d36508187abdff986e8087370) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown --- src/composables/usePaste.test.ts | 314 +++++++++++++++++++++++++++++++ src/composables/usePaste.ts | 81 ++++---- src/scripts/app.ts | 8 + 3 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 src/composables/usePaste.test.ts diff --git a/src/composables/usePaste.test.ts b/src/composables/usePaste.test.ts new file mode 100644 index 000000000..4e1ac3503 --- /dev/null +++ b/src/composables/usePaste.test.ts @@ -0,0 +1,314 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + LGraphCanvas, + LGraph, + LGraphGroup, + LGraphNode +} 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' + +function createMockNode() { + return { + pos: [0, 0], + pasteFile: vi.fn(), + pasteFiles: vi.fn() + } +} + +function createImageFile( + name: string = 'test.png', + type: string = 'image/png' +): File { + return new File([''], name, { type }) +} + +function createAudioFile( + name: string = 'test.mp3', + type: string = 'audio/mpeg' +): File { + return new File([''], name, { type }) +} + +function createDataTransfer(files: File[] = []): DataTransfer { + const dataTransfer = new DataTransfer() + files.forEach((file) => dataTransfer.items.add(file)) + return dataTransfer +} + +const mockCanvas = { + current_node: null as LGraphNode | null, + graph: { + add: vi.fn(), + change: vi.fn() + } as Partial as LGraph, + graph_mouse: [100, 200], + pasteFromClipboard: vi.fn(), + _deserializeItems: vi.fn() +} as Partial as LGraphCanvas + +const mockCanvasStore = { + canvas: mockCanvas, + getCanvas: vi.fn(() => mockCanvas) +} + +const mockWorkspaceStore = { + shiftDown: false +} + +vi.mock('@vueuse/core', () => ({ + useEventListener: vi.fn((target, event, handler) => { + target.addEventListener(event, handler) + return () => target.removeEventListener(event, handler) + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => mockCanvasStore +})) + +vi.mock('@/stores/workspaceStore', () => ({ + useWorkspaceStore: () => mockWorkspaceStore +})) + +vi.mock('@/scripts/app', () => ({ + app: { + loadGraphData: vi.fn() + } +})) + +vi.mock('@/lib/litegraph/src/litegraph', () => ({ + LiteGraph: { + createNode: vi.fn() + } +})) + +vi.mock('@/utils/litegraphUtil', () => ({ + isAudioNode: vi.fn(), + isImageNode: vi.fn(), + isVideoNode: vi.fn() +})) + +vi.mock('@/workbench/eventHelpers', () => ({ + shouldIgnoreCopyPaste: vi.fn() +})) + +describe('pasteImageNode', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockCanvas.graph!.add).mockImplementation( + (node: LGraphNode | LGraphGroup) => node as LGraphNode + ) + }) + + it('should create new LoadImage node when no image node provided', () => { + const mockNode = createMockNode() + vi.mocked(LiteGraph.createNode).mockReturnValue( + mockNode as unknown as LGraphNode + ) + + const file = createImageFile() + const dataTransfer = createDataTransfer([file]) + + 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(mockNode.pasteFile).toHaveBeenCalledWith(file) + }) + + it('should use existing image node when provided', () => { + const mockNode = createMockNode() + const file = createImageFile() + const dataTransfer = createDataTransfer([file]) + + pasteImageNode( + mockCanvas as unknown as LGraphCanvas, + dataTransfer.items, + mockNode as unknown as LGraphNode + ) + + expect(mockNode.pasteFile).toHaveBeenCalledWith(file) + expect(mockNode.pasteFiles).toHaveBeenCalledWith([file]) + }) + + it('should handle multiple image files', () => { + const mockNode = createMockNode() + const file1 = createImageFile('test1.png') + const file2 = createImageFile('test2.jpg', 'image/jpeg') + const dataTransfer = createDataTransfer([file1, file2]) + + pasteImageNode( + mockCanvas as unknown as LGraphCanvas, + dataTransfer.items, + mockNode as unknown as LGraphNode + ) + + expect(mockNode.pasteFile).toHaveBeenCalledWith(file1) + expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2]) + }) + + it('should do nothing when no image files present', () => { + const mockNode = createMockNode() + const dataTransfer = createDataTransfer() + + pasteImageNode( + mockCanvas as unknown as LGraphCanvas, + dataTransfer.items, + mockNode as unknown as LGraphNode + ) + + expect(mockNode.pasteFile).not.toHaveBeenCalled() + expect(mockNode.pasteFiles).not.toHaveBeenCalled() + }) + + it('should filter non-image items', () => { + const mockNode = createMockNode() + const imageFile = createImageFile() + const textFile = new File([''], 'test.txt', { type: 'text/plain' }) + const dataTransfer = createDataTransfer([textFile, imageFile]) + + pasteImageNode( + mockCanvas as unknown as LGraphCanvas, + dataTransfer.items, + mockNode as unknown as LGraphNode + ) + + expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile) + expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile]) + }) +}) + +describe('usePaste', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCanvas.current_node = null + mockWorkspaceStore.shiftDown = false + vi.mocked(mockCanvas.graph!.add).mockImplementation( + (node: LGraphNode | LGraphGroup) => node as LGraphNode + ) + }) + + it('should handle image paste', async () => { + const mockNode = createMockNode() + vi.mocked(LiteGraph.createNode).mockReturnValue( + mockNode as unknown as LGraphNode + ) + + usePaste() + + const file = createImageFile() + const dataTransfer = createDataTransfer([file]) + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + await vi.waitFor(() => { + expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage') + expect(mockNode.pasteFile).toHaveBeenCalledWith(file) + }) + }) + + it('should handle audio paste', async () => { + const mockNode = createMockNode() + vi.mocked(LiteGraph.createNode).mockReturnValue( + mockNode as unknown as LGraphNode + ) + + usePaste() + + const file = createAudioFile() + const dataTransfer = createDataTransfer([file]) + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + await vi.waitFor(() => { + expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio') + expect(mockNode.pasteFile).toHaveBeenCalledWith(file) + }) + }) + + it('should handle workflow JSON paste', async () => { + const workflow = { version: '1.0', nodes: [], extra: {} } + + usePaste() + + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', JSON.stringify(workflow)) + + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + await vi.waitFor(() => { + expect(app.loadGraphData).toHaveBeenCalledWith(workflow) + }) + }) + + it('should ignore paste when shift is down', () => { + mockWorkspaceStore.shiftDown = true + + usePaste() + + const file = createImageFile() + const dataTransfer = createDataTransfer([file]) + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + expect(LiteGraph.createNode).not.toHaveBeenCalled() + }) + + it('should use existing image node when selected', () => { + const mockNode = { + is_selected: true, + pasteFile: vi.fn(), + pasteFiles: vi.fn() + } as unknown as Partial as LGraphNode + mockCanvas.current_node = mockNode + vi.mocked(isImageNode).mockReturnValue(true) + + usePaste() + + const file = createImageFile() + const dataTransfer = createDataTransfer([file]) + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + expect(mockNode.pasteFile).toHaveBeenCalledWith(file) + }) + + it('should call canvas pasteFromClipboard for non-workflow text', () => { + usePaste() + + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'just some text') + + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled() + }) + + it('should handle clipboard items with metadata', async () => { + const data = { test: 'data' } + const encoded = btoa(JSON.stringify(data)) + const html = `
` + + usePaste() + + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/html', html) + + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + await vi.waitFor(() => { + expect(mockCanvas._deserializeItems).toHaveBeenCalledWith( + data, + expect.any(Object) + ) + }) + }) +}) diff --git a/src/composables/usePaste.ts b/src/composables/usePaste.ts index 9a40fab9a..6bfefab81 100644 --- a/src/composables/usePaste.ts +++ b/src/composables/usePaste.ts @@ -1,7 +1,7 @@ import { useEventListener } from '@vueuse/core' +import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' @@ -26,6 +26,48 @@ function pasteClipboardItems(data: DataTransfer): boolean { return false } +function pasteItemsOnNode( + items: DataTransferItemList, + node: LGraphNode | null, + contentType: string +): void { + if (!node) return + + const filteredItems = Array.from(items).filter((item) => + item.type.startsWith(contentType) + ) + + const blob = filteredItems[0]?.getAsFile() + if (!blob) return + + node.pasteFile?.(blob) + node.pasteFiles?.( + Array.from(filteredItems) + .map((i) => i.getAsFile()) + .filter((f) => f !== null) + ) +} + +export function pasteImageNode( + canvas: LGraphCanvas, + items: DataTransferItemList, + imageNode: LGraphNode | null = null +): void { + const { graph, graph_mouse: [posX, posY] } = canvas + + 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() + } + + pasteItemsOnNode(items, imageNode, 'image') +} + /** * Adds a handler on paste that extracts and loads images or workflows from pasted JSON data */ @@ -33,28 +75,6 @@ export const usePaste = () => { const workspaceStore = useWorkspaceStore() const canvasStore = useCanvasStore() - const pasteItemsOnNode = ( - items: DataTransferItemList, - node: LGraphNode | null, - contentType: string - ) => { - if (!node) return - - const filteredItems = Array.from(items).filter((item) => - item.type.startsWith(contentType) - ) - - const blob = filteredItems[0]?.getAsFile() - if (!blob) return - - node.pasteFile?.(blob) - node.pasteFiles?.( - Array.from(filteredItems) - .map((i) => i.getAsFile()) - .filter((f) => f !== null) - ) - } - useEventListener(document, 'paste', async (e) => { if (shouldIgnoreCopyPaste(e.target)) { // Default system copy @@ -80,8 +100,10 @@ export const usePaste = () => { const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode) const isAudioNodeSelected = isNodeSelected && isAudioNode(currentNode) - let imageNode: LGraphNode | null = isImageNodeSelected ? currentNode : null let audioNode: LGraphNode | null = isAudioNodeSelected ? currentNode : null + const imageNode: LGraphNode | null = isImageNodeSelected + ? currentNode + : null const videoNode: LGraphNode | null = isVideoNodeSelected ? currentNode : null @@ -89,16 +111,7 @@ export const usePaste = () => { // Look for image paste data for (const item of items) { if (item.type.startsWith('image/')) { - if (!imageNode) { - // No image node selected: add a new one - const newNode = LiteGraph.createNode('LoadImage') - if (newNode) { - newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]] - imageNode = graph?.add(newNode) ?? null - } - graph?.change() - } - pasteItemsOnNode(items, imageNode, 'image') + pasteImageNode(canvas as LGraphCanvas, items, imageNode) return } else if (item.type.startsWith('video/')) { if (!videoNode) { diff --git a/src/scripts/app.ts b/src/scripts/app.ts index cd75be7f7..0701f694d 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -96,6 +96,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' export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview' @@ -1441,6 +1442,13 @@ export class ComfyApp { const fileName = file.name.replace(/\.\w+$/, '') // Strip file extension const workflowData = await getWorkflowDataFromFile(file) if (!workflowData) { + if (file.type.startsWith('image')) { + const transfer = new DataTransfer() + transfer.items.add(file) + pasteImageNode(this.canvas, transfer.items) + return + } + this.showErrorOnFileLoad(file) return }