Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
dfb6b6b
Batch Drag & Drop Images
JemiloII Jan 23, 2026
44c4ebc
Use items only, files is just read only, added comment
JemiloII Jan 23, 2026
2fc4305
Change to function signature
JemiloII Jan 23, 2026
7904320
Remove comment
JemiloII Jan 23, 2026
55d38e8
Merge branch 'refs/heads/main' into batch-drag-and-drop-images
JemiloII Jan 23, 2026
bd6df61
Updating types
JemiloII Jan 23, 2026
e26c0db
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Jan 24, 2026
5bb3550
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Jan 24, 2026
7c00888
Call graph.change() once
JemiloII Jan 24, 2026
1f8d5fa
Merge branch 'main' into batch-drag-and-drop-images
DrJKL Jan 28, 2026
3e69806
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Jan 28, 2026
d64df32
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Feb 2, 2026
55634e4
Don't use height form getBounding()
JemiloII Feb 2, 2026
7f7f3b8
Update tests for positionBatchNodes
JemiloII Feb 2, 2026
daed3cb
Update logic / typescript with test
JemiloII Feb 2, 2026
373af13
Update to add additional check
JemiloII Feb 2, 2026
758ed36
Clean up types
JemiloII Feb 3, 2026
2ca9850
Remove unused import
JemiloII Feb 3, 2026
4252d58
Remove unused import
JemiloII Feb 3, 2026
6c6f0db
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Feb 3, 2026
7c1960f
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Feb 4, 2026
939c2f5
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Feb 9, 2026
0600af4
Merge branch 'main' into batch-drag-and-drop-images
JemiloII Feb 10, 2026
e30b081
merge main
AustinMroz Feb 11, 2026
afc6ec8
Remove unused ts-expect-error
AustinMroz Feb 11, 2026
98fc6c6
Fix lints
AustinMroz Feb 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 128 additions & 25 deletions src/composables/usePaste.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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)

Comment thread
JemiloII marked this conversation as resolved.
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()

Expand All @@ -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)
})
})
Expand Down Expand Up @@ -312,3 +356,62 @@ describe('usePaste', () => {
})
})
})

describe('cloneDataTransfer', () => {
it('should clone string data', () => {
const original = new DataTransfer()
original.setData('text/plain', 'test text')
original.setData('text/html', '<p>test html</p>')

const cloned = cloneDataTransfer(original)

expect(cloned.getData('text/plain')).toBe('test text')
expect(cloned.getData('text/html')).toBe('<p>test html</p>')
})

it('should clone files', () => {
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const original = createDataTransfer([file1, file2])

const cloned = cloneDataTransfer(original)

// Files are added from both .files and .items, causing duplicates
expect(cloned.files.length).toBeGreaterThanOrEqual(2)
expect(Array.from(cloned.files)).toContain(file1)
expect(Array.from(cloned.files)).toContain(file2)
})
Comment thread
JemiloII marked this conversation as resolved.

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)
})
})
75 changes: 59 additions & 16 deletions src/composables/usePaste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function pasteClipboardItems(data: DataTransfer): boolean {
const rawData = data.getData('text/html')
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
Expand Down Expand Up @@ -48,27 +80,37 @@ function pasteItemsOnNode(
)
}

export function pasteImageNode(
export async function pasteImageNode(
canvas: LGraphCanvas,
items: DataTransferItemList,
imageNode: LGraphNode | null = null
): void {
const {
graph,
graph_mouse: [posX, posY]
} = canvas

): Promise<LGraphNode | null> {
// No image node selected: add a new one
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [posX, posY]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
imageNode = await createNode(canvas, 'LoadImage')
}

pasteItemsOnNode(items, imageNode, 'image')
return imageNode
}

export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: FileList
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []

for (const file of fileList) {
const transfer = new DataTransfer()
transfer.items.add(file)
const imageNode = await pasteImageNode(canvas, transfer.items)

if (imageNode) {
nodes.push(imageNode)
}
}

return nodes
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
Expand All @@ -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

Expand All @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I know it's not introduced here, but can we clean up the type assertion?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was planning on cleaning up all the type assertion when I got to the audio/video story as it's the last drag and drop / copy & paste task. Unless there is some other type beyond that I should add to that task. I already have another task waiting with the text drag/drop/copy/paste

return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/litegraph/src/LGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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' })
Expand Down
3 changes: 2 additions & 1 deletion src/lib/litegraph/src/LGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,9 +862,10 @@ export class LGraph
/**
* Adds a new node instance to this graph
* @param node the instance of the node
* @param skip_compute_order
*/
add(
node: LGraphNode | LGraphGroup,
node: LGraphNode | LGraphGroup | null,
skip_compute_order?: boolean
): LGraphNode | null | undefined {
if (!node) return
Expand Down
Loading