mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-13 19:20:37 +00:00
Compare commits
25 Commits
codex/clou
...
austin/bat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc6ec8c9f | ||
|
|
e30b08131a | ||
|
|
0600af462e | ||
|
|
939c2f5ae2 | ||
|
|
7c1960f8cf | ||
|
|
6c6f0dba74 | ||
|
|
4252d58a04 | ||
|
|
2ca98501a8 | ||
|
|
758ed366c8 | ||
|
|
373af1390f | ||
|
|
daed3cb26e | ||
|
|
7f7f3b8c25 | ||
|
|
55634e4734 | ||
|
|
d64df325a4 | ||
|
|
3e69806cbb | ||
|
|
1f8d5faff1 | ||
|
|
7c00888398 | ||
|
|
5bb3550fa2 | ||
|
|
e26c0db8f0 | ||
|
|
bd6df613af | ||
|
|
55d38e87a7 | ||
|
|
790432038c | ||
|
|
2fc43055e3 | ||
|
|
44c4ebcc06 | ||
|
|
dfb6b6b35d |
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
200
src/scripts/app.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user