Compare commits

...

25 Commits

Author SHA1 Message Date
Austin Mroz
afc6ec8c9f Remove unused ts-expect-error 2026-02-11 15:36:58 -08:00
Austin Mroz
e30b08131a merge main 2026-02-11 15:21:03 -08:00
Brian Jemilo II
0600af462e Merge branch 'main' into batch-drag-and-drop-images 2026-02-10 02:04:13 -06:00
Brian Jemilo II
939c2f5ae2 Merge branch 'main' into batch-drag-and-drop-images
# Conflicts:
#	src/lib/litegraph/src/LGraph.ts
2026-02-09 13:21:52 -06:00
Brian Jemilo II
7c1960f8cf Merge branch 'main' into batch-drag-and-drop-images 2026-02-04 15:06:20 -06:00
Brian Jemilo II
6c6f0dba74 Merge branch 'main' into batch-drag-and-drop-images 2026-02-02 23:29:09 -06:00
Brian Jemilo II
4252d58a04 Remove unused import 2026-02-02 22:25:15 -06:00
Brian Jemilo II
2ca98501a8 Remove unused import 2026-02-02 22:24:28 -06:00
Brian Jemilo II
758ed366c8 Clean up types 2026-02-02 22:24:06 -06:00
Brian Jemilo II
373af1390f Update to add additional check 2026-02-02 17:57:12 -06:00
Brian Jemilo II
daed3cb26e Update logic / typescript with test 2026-02-02 16:29:38 -06:00
Brian Jemilo II
7f7f3b8c25 Update tests for positionBatchNodes 2026-02-02 15:38:01 -06:00
Brian Jemilo II
55634e4734 Don't use height form getBounding() 2026-02-02 15:26:10 -06:00
Brian Jemilo II
d64df325a4 Merge branch 'main' into batch-drag-and-drop-images 2026-02-02 15:23:16 -06:00
Brian Jemilo II
3e69806cbb Merge branch 'main' into batch-drag-and-drop-images 2026-01-28 17:23:57 -06:00
Alexander Brown
1f8d5faff1 Merge branch 'main' into batch-drag-and-drop-images 2026-01-28 13:41:28 -08:00
Brian Jemilo II
7c00888398 Call graph.change() once 2026-01-23 19:28:24 -06:00
Brian Jemilo II
5bb3550fa2 Merge branch 'main' into batch-drag-and-drop-images 2026-01-23 19:28:04 -06:00
Brian Jemilo II
e26c0db8f0 Merge branch 'main' into batch-drag-and-drop-images 2026-01-23 18:27:42 -06:00
Brian Jemilo II
bd6df613af Updating types 2026-01-23 17:25:06 -06:00
Brian Jemilo II
55d38e87a7 Merge branch 'refs/heads/main' into batch-drag-and-drop-images 2026-01-23 17:24:13 -06:00
Brian Jemilo II
790432038c Remove comment 2026-01-23 15:35:57 -06:00
Brian Jemilo II
2fc43055e3 Change to function signature 2026-01-23 15:17:57 -06:00
Brian Jemilo II
44c4ebcc06 Use items only, files is just read only, added comment 2026-01-23 15:11:45 -06:00
Brian Jemilo II
dfb6b6b35d Batch Drag & Drop Images 2026-01-23 14:03:10 -06:00
11 changed files with 622 additions and 55 deletions

View File

@@ -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', '<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)
})
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)
})
})

View File

@@ -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<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
}
/**
@@ -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) {

View File

@@ -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' })

View File

@@ -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(

200
src/scripts/app.test.ts Normal file
View File

@@ -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<LGraphCanvas> {
const mockGraph: Partial<LGraph> = {
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
)
})
})
})

View File

@@ -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)) {

View File

@@ -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

View File

@@ -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'

View File

@@ -1,14 +1,14 @@
export async function extractFileFromDragEvent(
event: DragEvent
): Promise<File | undefined> {
): Promise<File | FileList | undefined> {
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')
}

View File

@@ -1,13 +1,93 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } 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)
}))
describe('createNode', () => {
let mockCanvas: any
let mockGraph: any
beforeEach(() => {
vi.clearAllMocks()
mockGraph = {
add: vi.fn((node) => node),
change: vi.fn()
}
mockCanvas = {
graph: mockGraph,
graph_mouse: [100, 200]
}
})
it('should create a node successfully', async () => {
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as any)
const result = await createNode(mockCanvas, 'LoadImage')
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockGraph.add).toHaveBeenCalledWith(mockNode)
expect(mockGraph.change).toHaveBeenCalled()
expect(result).toBe(mockNode)
})
it('should return null when name is empty', async () => {
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] }
mockCanvas.graph = null
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as any)
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 () => {
mockCanvas.graph_mouse = [250, 350]
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as any)
await createNode(mockCanvas, 'LoadAudio')
expect(mockNode.pos).toEqual([250, 350])
})
})
describe('migrateWidgetsValues', () => {
it('should remove widget values for forceInput inputs', () => {
const inputDefs: Record<string, InputSpec> = {

View File

@@ -1,6 +1,11 @@
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 { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LGraphGroup,
LGraphNode,
@@ -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,35 @@ 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<LGraphNode | null> {
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 (