mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 20:54:56 +00:00
Compare commits
14 Commits
feat/toolt
...
feat/batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eeaeaec23 | ||
|
|
75022f302b | ||
|
|
959f1365dd | ||
|
|
216c707c87 | ||
|
|
47a2f312c8 | ||
|
|
80a4b31da4 | ||
|
|
93bb2d0d6f | ||
|
|
f04809d124 | ||
|
|
6171bd9ac4 | ||
|
|
218cf60f5f | ||
|
|
fc56f7ee85 | ||
|
|
c664b5bc38 | ||
|
|
724d60822a | ||
|
|
a15d3ce49b |
@@ -4,6 +4,17 @@ import type { Page } from '@playwright/test'
|
|||||||
|
|
||||||
import type { Position } from '../types'
|
import type { Position } from '../types'
|
||||||
|
|
||||||
|
function getFileType(fileName: string): string {
|
||||||
|
if (fileName.endsWith('.png')) return 'image/png'
|
||||||
|
if (fileName.endsWith('.svg')) return 'image/svg+xml'
|
||||||
|
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||||
|
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||||
|
if (fileName.endsWith('.json')) return 'application/json'
|
||||||
|
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||||
|
if (fileName.endsWith('.avif')) return 'image/avif'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
export class DragDropHelper {
|
export class DragDropHelper {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly page: Page,
|
private readonly page: Page,
|
||||||
@@ -48,17 +59,6 @@ export class DragDropHelper {
|
|||||||
const filePath = this.assetPath(fileName)
|
const filePath = this.assetPath(fileName)
|
||||||
const buffer = readFileSync(filePath)
|
const buffer = readFileSync(filePath)
|
||||||
|
|
||||||
const getFileType = (fileName: string) => {
|
|
||||||
if (fileName.endsWith('.png')) return 'image/png'
|
|
||||||
if (fileName.endsWith('.svg')) return 'image/svg+xml'
|
|
||||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
|
||||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
|
||||||
if (fileName.endsWith('.json')) return 'application/json'
|
|
||||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
|
||||||
if (fileName.endsWith('.avif')) return 'image/avif'
|
|
||||||
return 'application/octet-stream'
|
|
||||||
}
|
|
||||||
|
|
||||||
evaluateParams.fileName = fileName
|
evaluateParams.fileName = fileName
|
||||||
evaluateParams.fileType = getFileType(fileName)
|
evaluateParams.fileType = getFileType(fileName)
|
||||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||||
@@ -155,6 +155,104 @@ export class DragDropHelper {
|
|||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async dragAndDropFiles(
|
||||||
|
fileNames: string[],
|
||||||
|
options: {
|
||||||
|
dropPosition?: Position
|
||||||
|
waitForUploadCount?: number
|
||||||
|
} = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const { dropPosition = { x: 100, y: 100 }, waitForUploadCount = 0 } =
|
||||||
|
options
|
||||||
|
|
||||||
|
const files = fileNames.map((fileName) => {
|
||||||
|
const filePath = this.assetPath(fileName)
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
fileType: getFileType(fileName),
|
||||||
|
buffer: [...new Uint8Array(buffer)]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let uploadResponsePromise: Promise<unknown> | null = null
|
||||||
|
if (waitForUploadCount > 0) {
|
||||||
|
let uploadCount = 0
|
||||||
|
uploadResponsePromise = new Promise<void>((resolve) => {
|
||||||
|
const handler = (resp: { url(): string; status(): number }) => {
|
||||||
|
if (resp.url().includes('/upload/') && resp.status() === 200) {
|
||||||
|
uploadCount++
|
||||||
|
if (uploadCount >= waitForUploadCount) {
|
||||||
|
this.page.off('response', handler)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.page.on('response', handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.page.evaluate(
|
||||||
|
async (params) => {
|
||||||
|
const dataTransfer = new DataTransfer()
|
||||||
|
|
||||||
|
for (const f of params.files) {
|
||||||
|
const file = new File([new Uint8Array(f.buffer)], f.fileName, {
|
||||||
|
type: f.fileType
|
||||||
|
})
|
||||||
|
dataTransfer.items.add(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetElement = document.elementFromPoint(
|
||||||
|
params.dropPosition.x,
|
||||||
|
params.dropPosition.y
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!targetElement) {
|
||||||
|
throw new Error(
|
||||||
|
`No element found at drop position: (${params.dropPosition.x}, ${params.dropPosition.y}).`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventOptions = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
dataTransfer,
|
||||||
|
clientX: params.dropPosition.x,
|
||||||
|
clientY: params.dropPosition.y
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphCanvasElement = document.querySelector('#graph-canvas')
|
||||||
|
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
|
||||||
|
graphCanvasElement.dispatchEvent(
|
||||||
|
new DragEvent('dragover', eventOptions)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropEvent = new DragEvent('drop', eventOptions)
|
||||||
|
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||||
|
value: () => {},
|
||||||
|
writable: false
|
||||||
|
})
|
||||||
|
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||||
|
value: () => {},
|
||||||
|
writable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
targetElement.dispatchEvent(new DragEvent('dragover', eventOptions))
|
||||||
|
targetElement.dispatchEvent(dropEvent)
|
||||||
|
},
|
||||||
|
{ files, dropPosition }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uploadResponsePromise) {
|
||||||
|
await uploadResponsePromise
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
async dragAndDropFile(
|
async dragAndDropFile(
|
||||||
fileName: string,
|
fileName: string,
|
||||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||||
|
|||||||
90
browser_tests/tests/batchImageImport.spec.ts
Normal file
90
browser_tests/tests/batchImageImport.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||||
|
import type { WorkspaceStore } from '../types/globals'
|
||||||
|
|
||||||
|
test.describe('Batch Image Import', () => {
|
||||||
|
test('Dropping multiple images creates LoadImage nodes and a BatchImagesNode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||||
|
|
||||||
|
await comfyPage.dragDrop.dragAndDropFiles(
|
||||||
|
['image32x32.webp', 'image64x64.webp'],
|
||||||
|
{ waitForUploadCount: 2 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
|
||||||
|
.toBe(initialCount + 3)
|
||||||
|
|
||||||
|
const batchNodes =
|
||||||
|
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
|
||||||
|
expect(batchNodes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Dropping a single image does not create a BatchImagesNode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||||
|
|
||||||
|
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
|
||||||
|
waitForUpload: true
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
|
||||||
|
.toBe(initialCount + 1)
|
||||||
|
|
||||||
|
const batchNodes =
|
||||||
|
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
|
||||||
|
expect(batchNodes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Batch image import produces a single undo entry', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||||
|
const initialUndoSize = await comfyPage.workflow.getUndoQueueSize()
|
||||||
|
|
||||||
|
await comfyPage.dragDrop.dragAndDropFiles(
|
||||||
|
['image32x32.webp', 'image64x64.webp'],
|
||||||
|
{ waitForUploadCount: 2 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
|
||||||
|
.toBe(initialCount + 3)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.workflow.getUndoQueueSize(), { timeout: 5000 })
|
||||||
|
.toBe((initialUndoSize ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Batch image import can be undone as a single action', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||||
|
|
||||||
|
await comfyPage.dragDrop.dragAndDropFiles(
|
||||||
|
['image32x32.webp', 'image64x64.webp'],
|
||||||
|
{ waitForUploadCount: 2 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
|
||||||
|
.toBe(initialCount + 3)
|
||||||
|
|
||||||
|
// Call undo directly on the change tracker to avoid keyboard focus issues
|
||||||
|
await comfyPage.page.evaluate(async () => {
|
||||||
|
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||||
|
.activeWorkflow
|
||||||
|
await workflow?.changeTracker.undo()
|
||||||
|
})
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
|
||||||
|
.toBe(initialCount)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -17,13 +17,13 @@ export const useNodePaste = <T>(
|
|||||||
) => {
|
) => {
|
||||||
const { onPaste, fileFilter = () => true, allow_batch = false } = options
|
const { onPaste, fileFilter = () => true, allow_batch = false } = options
|
||||||
|
|
||||||
node.pasteFiles = function (files: File[]) {
|
node.pasteFiles = async function (files: File[]) {
|
||||||
const filteredFiles = Array.from(files).filter(fileFilter)
|
const filteredFiles = Array.from(files).filter(fileFilter)
|
||||||
if (!filteredFiles.length) return false
|
if (!filteredFiles.length) return false
|
||||||
|
|
||||||
const paste = allow_batch ? filteredFiles : filteredFiles.slice(0, 1)
|
const paste = allow_batch ? filteredFiles : filteredFiles.slice(0, 1)
|
||||||
|
|
||||||
void onPaste(paste)
|
await onPaste(paste)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type {
|
import type {
|
||||||
LGraphCanvas,
|
LGraphCanvas,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
} from '@/utils/litegraphUtil'
|
} from '@/utils/litegraphUtil'
|
||||||
import {
|
import {
|
||||||
cloneDataTransfer,
|
cloneDataTransfer,
|
||||||
|
collectMediaFiles,
|
||||||
pasteAudioNode,
|
pasteAudioNode,
|
||||||
pasteAudioNodes,
|
pasteAudioNodes,
|
||||||
pasteImageNode,
|
pasteImageNode,
|
||||||
@@ -28,7 +30,8 @@ function createMockNode(): LGraphNode {
|
|||||||
return createMockLGraphNode({
|
return createMockLGraphNode({
|
||||||
pos: [0, 0],
|
pos: [0, 0],
|
||||||
pasteFile: vi.fn(),
|
pasteFile: vi.fn(),
|
||||||
pasteFiles: vi.fn()
|
pasteFiles: vi.fn().mockResolvedValue(true),
|
||||||
|
connect: vi.fn()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +70,10 @@ const mockCanvas = {
|
|||||||
} as Partial<LGraph> as LGraph,
|
} as Partial<LGraph> as LGraph,
|
||||||
graph_mouse: [100, 200],
|
graph_mouse: [100, 200],
|
||||||
pasteFromClipboard: vi.fn(),
|
pasteFromClipboard: vi.fn(),
|
||||||
_deserializeItems: vi.fn()
|
_deserializeItems: vi.fn(),
|
||||||
|
emitBeforeChange: vi.fn(),
|
||||||
|
emitAfterChange: vi.fn(),
|
||||||
|
selectItems: vi.fn()
|
||||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||||
|
|
||||||
const mockCanvasStore = {
|
const mockCanvasStore = {
|
||||||
@@ -96,7 +102,9 @@ vi.mock('@/stores/workspaceStore', () => ({
|
|||||||
|
|
||||||
vi.mock('@/scripts/app', () => ({
|
vi.mock('@/scripts/app', () => ({
|
||||||
app: {
|
app: {
|
||||||
loadGraphData: vi.fn()
|
loadGraphData: vi.fn(),
|
||||||
|
positionNodes: vi.fn(),
|
||||||
|
positionBatchNodes: vi.fn()
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -201,20 +209,21 @@ describe('pasteImageNodes', () => {
|
|||||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||||
|
|
||||||
const result = await pasteImageNodes(mockCanvas, [file1, file2])
|
const result = await pasteImageNodes(mockCanvas, [file1, file2])
|
||||||
|
await result.completion
|
||||||
|
|
||||||
expect(createNode).toHaveBeenCalledTimes(2)
|
expect(createNode).toHaveBeenCalledTimes(2)
|
||||||
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
|
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
|
||||||
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
|
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
|
||||||
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
||||||
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
||||||
expect(result).toEqual([mockNode1, mockNode2])
|
expect(result.nodes).toEqual([mockNode1, mockNode2])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty file list', async () => {
|
it('should handle empty file list', async () => {
|
||||||
const result = await pasteImageNodes(mockCanvas, [])
|
const result = await pasteImageNodes(mockCanvas, [])
|
||||||
|
|
||||||
expect(createNode).not.toHaveBeenCalled()
|
expect(createNode).not.toHaveBeenCalled()
|
||||||
expect(result).toEqual([])
|
expect(result.nodes).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -597,6 +606,138 @@ describe('usePaste', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('collectMediaFiles', () => {
|
||||||
|
it('should collect image files from DataTransferItemList', () => {
|
||||||
|
const dt = createDataTransfer([
|
||||||
|
createImageFile('a.png'),
|
||||||
|
createImageFile('b.jpg', 'image/jpeg')
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = collectMediaFiles(dt.items)
|
||||||
|
|
||||||
|
expect(result.images).toHaveLength(2)
|
||||||
|
expect(result.videos).toHaveLength(0)
|
||||||
|
expect(result.audios).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should collect mixed media types', () => {
|
||||||
|
const dt = createDataTransfer([
|
||||||
|
createImageFile(),
|
||||||
|
createVideoFile(),
|
||||||
|
createAudioFile()
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = collectMediaFiles(dt.items)
|
||||||
|
|
||||||
|
expect(result.images).toHaveLength(1)
|
||||||
|
expect(result.videos).toHaveLength(1)
|
||||||
|
expect(result.audios).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty arrays when no media items', () => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
dt.setData('text/plain', 'hello')
|
||||||
|
|
||||||
|
const result = collectMediaFiles(dt.items)
|
||||||
|
|
||||||
|
expect(result.images).toHaveLength(0)
|
||||||
|
expect(result.videos).toHaveLength(0)
|
||||||
|
expect(result.audios).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('batch paste undo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockCanvas.current_node = null
|
||||||
|
mockWorkspaceStore.shiftDown = false
|
||||||
|
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||||
|
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getPasteHandler(): (e: ClipboardEvent) => Promise<void> {
|
||||||
|
const calls = vi.mocked(useEventListener).mock.calls
|
||||||
|
const pasteCall = calls.find(([_target, event]) => event === 'paste')
|
||||||
|
return pasteCall![2] as (e: ClipboardEvent) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchPaste(files: File[]): Promise<void> {
|
||||||
|
const dt = createDataTransfer(files)
|
||||||
|
const event = {
|
||||||
|
clipboardData: dt,
|
||||||
|
target: document.createElement('div')
|
||||||
|
} as unknown as ClipboardEvent
|
||||||
|
await getPasteHandler()(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should wrap multiple image paste in emitBeforeChange/emitAfterChange', async () => {
|
||||||
|
const mockNode1 = createMockNode()
|
||||||
|
const mockNode2 = createMockNode()
|
||||||
|
const mockBatchNode = createMockNode()
|
||||||
|
vi.mocked(createNode)
|
||||||
|
.mockResolvedValueOnce(mockNode1)
|
||||||
|
.mockResolvedValueOnce(mockNode2)
|
||||||
|
.mockResolvedValueOnce(mockBatchNode)
|
||||||
|
|
||||||
|
usePaste()
|
||||||
|
await dispatchPaste([createImageFile('a.png'), createImageFile('b.png')])
|
||||||
|
|
||||||
|
expect(mockCanvas.emitBeforeChange).toHaveBeenCalledOnce()
|
||||||
|
expect(mockCanvas.emitAfterChange).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create BatchImagesNode and connect nodes for multiple images', async () => {
|
||||||
|
const mockNodes = Array.from({ length: 10 }, () => createMockNode())
|
||||||
|
let callIndex = 0
|
||||||
|
vi.mocked(createNode).mockImplementation(async () => {
|
||||||
|
return mockNodes[callIndex++]
|
||||||
|
})
|
||||||
|
|
||||||
|
usePaste()
|
||||||
|
await dispatchPaste([createImageFile('a.png'), createImageFile('b.png')])
|
||||||
|
|
||||||
|
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
|
||||||
|
|
||||||
|
const createNodeCalls = vi.mocked(createNode).mock.calls
|
||||||
|
const imageNodeCount = createNodeCalls.filter(
|
||||||
|
([, name]) => name === 'LoadImage'
|
||||||
|
).length
|
||||||
|
const batchNodeCallIndex = createNodeCalls.findIndex(
|
||||||
|
([, name]) => name === 'BatchImagesNode'
|
||||||
|
)
|
||||||
|
const batchNode = mockNodes[batchNodeCallIndex]
|
||||||
|
|
||||||
|
expect(imageNodeCount).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(batchNodeCallIndex).toBeGreaterThan(-1)
|
||||||
|
expect(app.positionBatchNodes).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const imageNodes = createNodeCalls
|
||||||
|
.filter(([, name]) => name === 'LoadImage')
|
||||||
|
.map((_, i) => mockNodes[i])
|
||||||
|
imageNodes.forEach((node, index) => {
|
||||||
|
expect(node.connect).toHaveBeenCalledWith(0, batchNode, index)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not batch when image node is selected', async () => {
|
||||||
|
const mockNode = createMockLGraphNode({
|
||||||
|
is_selected: true,
|
||||||
|
pasteFile: vi.fn(),
|
||||||
|
pasteFiles: vi.fn(),
|
||||||
|
connect: vi.fn()
|
||||||
|
})
|
||||||
|
mockCanvas.current_node = mockNode
|
||||||
|
vi.mocked(isImageNode).mockReturnValue(true)
|
||||||
|
|
||||||
|
usePaste()
|
||||||
|
await dispatchPaste([createImageFile('a.png'), createImageFile('b.png')])
|
||||||
|
|
||||||
|
expect(mockNode.pasteFile).toHaveBeenCalled()
|
||||||
|
expect(mockCanvas.emitBeforeChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('cloneDataTransfer', () => {
|
describe('cloneDataTransfer', () => {
|
||||||
it('should clone string data', () => {
|
it('should clone string data', () => {
|
||||||
const original = new DataTransfer()
|
const original = new DataTransfer()
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ function pasteClipboardItems(data: DataTransfer): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function pasteItemsOnNode(
|
async function pasteItemsOnNode(
|
||||||
items: DataTransferItemList,
|
items: DataTransferItemList,
|
||||||
node: LGraphNode | null,
|
node: LGraphNode | null,
|
||||||
contentType: string
|
contentType: string
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
const filteredItems = Array.from(items).filter((item) =>
|
const filteredItems = Array.from(items).filter((item) =>
|
||||||
@@ -72,10 +72,12 @@ function pasteItemsOnNode(
|
|||||||
if (!blob) return
|
if (!blob) return
|
||||||
|
|
||||||
node.pasteFile?.(blob)
|
node.pasteFile?.(blob)
|
||||||
node.pasteFiles?.(
|
await Promise.resolve(
|
||||||
Array.from(filteredItems)
|
node.pasteFiles?.(
|
||||||
.map((i) => i.getAsFile())
|
Array.from(filteredItems)
|
||||||
.filter((f) => f !== null)
|
.map((i) => i.getAsFile())
|
||||||
|
.filter((f) => f !== null)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,27 +91,37 @@ export async function pasteImageNode(
|
|||||||
imageNode = await createNode(canvas, 'LoadImage')
|
imageNode = await createNode(canvas, 'LoadImage')
|
||||||
}
|
}
|
||||||
|
|
||||||
pasteItemsOnNode(items, imageNode, 'image')
|
await pasteItemsOnNode(items, imageNode, 'image')
|
||||||
return imageNode
|
return imageNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PasteNodesResult {
|
||||||
|
nodes: LGraphNode[]
|
||||||
|
completion: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
export async function pasteImageNodes(
|
export async function pasteImageNodes(
|
||||||
canvas: LGraphCanvas,
|
canvas: LGraphCanvas,
|
||||||
fileList: File[]
|
fileList: File[]
|
||||||
): Promise<LGraphNode[]> {
|
): Promise<PasteNodesResult> {
|
||||||
const nodes: LGraphNode[] = []
|
const nodes: LGraphNode[] = []
|
||||||
|
const uploads: Promise<void>[] = []
|
||||||
|
|
||||||
for (const file of fileList) {
|
for (const file of fileList) {
|
||||||
|
const node = await createNode(canvas, 'LoadImage')
|
||||||
|
if (!node) continue
|
||||||
|
|
||||||
|
nodes.push(node)
|
||||||
|
|
||||||
const transfer = new DataTransfer()
|
const transfer = new DataTransfer()
|
||||||
transfer.items.add(file)
|
transfer.items.add(file)
|
||||||
const imageNode = await pasteImageNode(canvas, transfer.items)
|
uploads.push(pasteItemsOnNode(transfer.items, node, 'image'))
|
||||||
|
|
||||||
if (imageNode) {
|
|
||||||
nodes.push(imageNode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes
|
return {
|
||||||
|
nodes,
|
||||||
|
completion: Promise.all(uploads).then(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pasteAudioNode(
|
export async function pasteAudioNode(
|
||||||
@@ -120,7 +132,7 @@ export async function pasteAudioNode(
|
|||||||
if (!audioNode) {
|
if (!audioNode) {
|
||||||
audioNode = await createNode(canvas, 'LoadAudio')
|
audioNode = await createNode(canvas, 'LoadAudio')
|
||||||
}
|
}
|
||||||
pasteItemsOnNode(items, audioNode, 'audio')
|
await pasteItemsOnNode(items, audioNode, 'audio')
|
||||||
return audioNode
|
return audioNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +163,7 @@ export async function pasteVideoNode(
|
|||||||
if (!videoNode) {
|
if (!videoNode) {
|
||||||
videoNode = await createNode(canvas, 'LoadVideo')
|
videoNode = await createNode(canvas, 'LoadVideo')
|
||||||
}
|
}
|
||||||
pasteItemsOnNode(items, videoNode, 'video')
|
await pasteItemsOnNode(items, videoNode, 'video')
|
||||||
return videoNode
|
return videoNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +186,78 @@ export async function pasteVideoNodes(
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaFiles {
|
||||||
|
images: File[]
|
||||||
|
videos: File[]
|
||||||
|
audios: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectMediaFiles(items: DataTransferItemList): MediaFiles {
|
||||||
|
const result: MediaFiles = { images: [], videos: [], audios: [] }
|
||||||
|
for (const item of items) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
if (item.type.startsWith('image/')) result.images.push(file)
|
||||||
|
else if (item.type.startsWith('video/')) result.videos.push(file)
|
||||||
|
else if (item.type.startsWith('audio/')) result.audios.push(file)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMediaPaste(
|
||||||
|
canvas: LGraphCanvas,
|
||||||
|
canvasStore: ReturnType<typeof useCanvasStore>,
|
||||||
|
files: File[],
|
||||||
|
selectedNode: LGraphNode | null,
|
||||||
|
mediaType: 'image' | 'video' | 'audio'
|
||||||
|
): Promise<void> {
|
||||||
|
if (selectedNode || files.length === 1) {
|
||||||
|
const transfer = new DataTransfer()
|
||||||
|
transfer.items.add(files[0])
|
||||||
|
if (mediaType === 'image')
|
||||||
|
await pasteImageNode(canvas, transfer.items, selectedNode)
|
||||||
|
else if (mediaType === 'video')
|
||||||
|
await pasteVideoNode(canvas, transfer.items, selectedNode)
|
||||||
|
else await pasteAudioNode(canvas, transfer.items, selectedNode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lgCanvas = canvasStore.getCanvas()
|
||||||
|
lgCanvas.emitBeforeChange()
|
||||||
|
try {
|
||||||
|
if (mediaType === 'image') {
|
||||||
|
const { nodes, completion } = await pasteImageNodes(canvas, files)
|
||||||
|
if (nodes.length > 1) {
|
||||||
|
const batchImagesNode = await createNode(canvas, 'BatchImagesNode')
|
||||||
|
if (batchImagesNode) {
|
||||||
|
app.positionBatchNodes(nodes, batchImagesNode)
|
||||||
|
lgCanvas.selectItems([...nodes, batchImagesNode])
|
||||||
|
nodes.forEach((imageNode, index) => {
|
||||||
|
imageNode.connect(0, batchImagesNode, index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (nodes.length > 0) {
|
||||||
|
lgCanvas.selectItems(nodes)
|
||||||
|
}
|
||||||
|
await completion
|
||||||
|
} else if (mediaType === 'video') {
|
||||||
|
const nodes = await pasteVideoNodes(canvas, files)
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
app.positionNodes(nodes)
|
||||||
|
lgCanvas.selectItems(nodes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const nodes = await pasteAudioNodes(canvas, files)
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
app.positionNodes(nodes)
|
||||||
|
lgCanvas.selectItems(nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lgCanvas.emitAfterChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
|
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
|
||||||
*/
|
*/
|
||||||
@@ -216,18 +300,38 @@ export const usePaste = () => {
|
|||||||
? currentNode
|
? currentNode
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Look for image paste data
|
// Look for media paste data
|
||||||
for (const item of items) {
|
const mediaFiles = collectMediaFiles(items)
|
||||||
if (item.type.startsWith('image/')) {
|
|
||||||
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
if (mediaFiles.images.length > 0) {
|
||||||
return
|
await handleMediaPaste(
|
||||||
} else if (item.type.startsWith('video/')) {
|
canvas as LGraphCanvas,
|
||||||
await pasteVideoNode(canvas as LGraphCanvas, items, videoNode)
|
canvasStore,
|
||||||
return
|
mediaFiles.images,
|
||||||
} else if (item.type.startsWith('audio/')) {
|
imageNode,
|
||||||
await pasteAudioNode(canvas as LGraphCanvas, items, audioNode)
|
'image'
|
||||||
return
|
)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
if (mediaFiles.videos.length > 0) {
|
||||||
|
await handleMediaPaste(
|
||||||
|
canvas as LGraphCanvas,
|
||||||
|
canvasStore,
|
||||||
|
mediaFiles.videos,
|
||||||
|
videoNode,
|
||||||
|
'video'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mediaFiles.audios.length > 0) {
|
||||||
|
await handleMediaPaste(
|
||||||
|
canvas as LGraphCanvas,
|
||||||
|
canvasStore,
|
||||||
|
mediaFiles.audios,
|
||||||
|
audioNode,
|
||||||
|
'audio'
|
||||||
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (pasteClipboardItems(data)) return
|
if (pasteClipboardItems(data)) return
|
||||||
|
|
||||||
|
|||||||
@@ -3972,14 +3972,41 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @todo Refactor to where it belongs - e.g. Deleting / creating nodes is not actually canvas event. */
|
/**
|
||||||
|
* Signals the start of a compound graph operation. All graph mutations
|
||||||
|
* between this call and the matching {@link emitAfterChange} are treated
|
||||||
|
* as a single undoable action by the change tracking system.
|
||||||
|
*
|
||||||
|
* Emits a `litegraph:canvas` DOM event with `subType: 'before-change'`,
|
||||||
|
* which `ChangeTracker` listens for to suppress intermediate state
|
||||||
|
* snapshots. Calls are nestable — only the outermost pair triggers a
|
||||||
|
* state check.
|
||||||
|
*
|
||||||
|
* Always pair with {@link emitAfterChange} in a `try/finally` block.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* canvas.emitBeforeChange()
|
||||||
|
* try {
|
||||||
|
* // multiple graph mutations...
|
||||||
|
* } finally {
|
||||||
|
* canvas.emitAfterChange()
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
emitBeforeChange(): void {
|
emitBeforeChange(): void {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
subType: 'before-change'
|
subType: 'before-change'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @todo See {@link emitBeforeChange} */
|
/**
|
||||||
|
* Signals the end of a compound graph operation started by
|
||||||
|
* {@link emitBeforeChange}. When the outermost pair completes, the
|
||||||
|
* change tracking system takes a single state snapshot and records
|
||||||
|
* one undo entry for all mutations since the matching
|
||||||
|
* `emitBeforeChange`.
|
||||||
|
*/
|
||||||
emitAfterChange(): void {
|
emitAfterChange(): void {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
subType: 'after-change'
|
subType: 'after-change'
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ function createMockCanvas(): Partial<LGraphCanvas> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
graph: mockGraph as LGraph,
|
graph: mockGraph as LGraph,
|
||||||
selectItems: vi.fn()
|
selectItems: vi.fn(),
|
||||||
|
emitBeforeChange: vi.fn(),
|
||||||
|
emitAfterChange: vi.fn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,10 @@ describe('ComfyApp', () => {
|
|||||||
const mockNode2 = createMockNode({ id: 2 })
|
const mockNode2 = createMockNode({ id: 2 })
|
||||||
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
|
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
|
||||||
|
|
||||||
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2])
|
vi.mocked(pasteImageNodes).mockResolvedValue({
|
||||||
|
nodes: [mockNode1, mockNode2],
|
||||||
|
completion: Promise.resolve()
|
||||||
|
})
|
||||||
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
|
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
|
||||||
|
|
||||||
const file1 = createTestFile('test1.png', 'image/png')
|
const file1 = createTestFile('test1.png', 'image/png')
|
||||||
@@ -102,18 +107,21 @@ describe('ComfyApp', () => {
|
|||||||
|
|
||||||
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
|
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
|
||||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
|
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
|
||||||
|
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
|
||||||
|
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
|
||||||
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
|
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
|
||||||
mockNode1,
|
mockNode1,
|
||||||
mockNode2,
|
mockNode2,
|
||||||
mockBatchNode
|
mockBatchNode
|
||||||
])
|
])
|
||||||
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
|
|
||||||
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should select single image node without batch node', async () => {
|
it('should select single image node without batch node', async () => {
|
||||||
const mockNode1 = createMockNode({ id: 1 })
|
const mockNode1 = createMockNode({ id: 1 })
|
||||||
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1])
|
vi.mocked(pasteImageNodes).mockResolvedValue({
|
||||||
|
nodes: [mockNode1],
|
||||||
|
completion: Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
const file = createTestFile('test.png', 'image/png')
|
const file = createTestFile('test.png', 'image/png')
|
||||||
|
|
||||||
@@ -199,7 +207,7 @@ describe('ComfyApp', () => {
|
|||||||
it('should position batch node to the right of first node', () => {
|
it('should position batch node to the right of first node', () => {
|
||||||
const mockNode1 = createMockNode({
|
const mockNode1 = createMockNode({
|
||||||
pos: [100, 200],
|
pos: [100, 200],
|
||||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
size: [300, 400]
|
||||||
})
|
})
|
||||||
const mockBatchNode = createMockNode({ pos: [0, 0] })
|
const mockBatchNode = createMockNode({ pos: [0, 0] })
|
||||||
|
|
||||||
@@ -211,8 +219,8 @@ describe('ComfyApp', () => {
|
|||||||
it('should stack multiple image nodes vertically', () => {
|
it('should stack multiple image nodes vertically', () => {
|
||||||
const mockNode1 = createMockNode({
|
const mockNode1 = createMockNode({
|
||||||
pos: [100, 200],
|
pos: [100, 200],
|
||||||
type: 'LoadImage',
|
size: [300, 400],
|
||||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
type: 'LoadImage'
|
||||||
})
|
})
|
||||||
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
||||||
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
||||||
@@ -227,7 +235,8 @@ describe('ComfyApp', () => {
|
|||||||
|
|
||||||
it('should call graph change once for all nodes', () => {
|
it('should call graph change once for all nodes', () => {
|
||||||
const mockNode1 = createMockNode({
|
const mockNode1 = createMockNode({
|
||||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
pos: [100, 200],
|
||||||
|
size: [300, 400]
|
||||||
})
|
})
|
||||||
const mockBatchNode = createMockNode()
|
const mockBatchNode = createMockNode()
|
||||||
|
|
||||||
|
|||||||
@@ -1720,21 +1720,28 @@ export class ComfyApp {
|
|||||||
if (fileList.length === 0) return
|
if (fileList.length === 0) return
|
||||||
if (!fileList[0].type.startsWith('image')) return
|
if (!fileList[0].type.startsWith('image')) return
|
||||||
|
|
||||||
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
this.canvas.emitBeforeChange()
|
||||||
if (imageNodes.length === 0) return
|
try {
|
||||||
|
const { nodes, completion } = await pasteImageNodes(this.canvas, fileList)
|
||||||
|
if (nodes.length === 0) return
|
||||||
|
|
||||||
if (imageNodes.length > 1) {
|
if (nodes.length > 1) {
|
||||||
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
||||||
if (!batchImagesNode) return
|
if (!batchImagesNode) return
|
||||||
|
|
||||||
this.positionBatchNodes(imageNodes, batchImagesNode)
|
this.positionBatchNodes(nodes, batchImagesNode)
|
||||||
this.canvas.selectItems([...imageNodes, batchImagesNode])
|
this.canvas.selectItems([...nodes, batchImagesNode])
|
||||||
|
|
||||||
imageNodes.forEach((imageNode, index) => {
|
nodes.forEach((imageNode, index) => {
|
||||||
imageNode.connect(0, batchImagesNode, index)
|
imageNode.connect(0, batchImagesNode, index)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.canvas.selectItems(imageNodes)
|
this.canvas.selectItems(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
await completion
|
||||||
|
} finally {
|
||||||
|
this.canvas.emitAfterChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1775,8 +1782,9 @@ export class ComfyApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
|
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
|
||||||
const [x, y, width] = nodes[0].getBounding()
|
const [x, y] = nodes[0].pos
|
||||||
batchNode.pos = [x + width + 100, y + 30]
|
const nodeWidth = nodes[0].size[0]
|
||||||
|
batchNode.pos = [x + nodeWidth + 100, y + 30]
|
||||||
|
|
||||||
// Retrieving Node Height is inconsistent
|
// Retrieving Node Height is inconsistent
|
||||||
let height = 0
|
let height = 0
|
||||||
|
|||||||
66
src/scripts/changeTracker.test.ts
Normal file
66
src/scripts/changeTracker.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
|
|
||||||
|
import { ChangeTracker } from './changeTracker'
|
||||||
|
|
||||||
|
vi.mock('@/scripts/app', () => ({
|
||||||
|
app: {
|
||||||
|
graph: {},
|
||||||
|
rootGraph: { serialize: () => ({ nodes: [], links: [] }) },
|
||||||
|
canvas: { ds: { scale: 1, offset: [0, 0] } }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: {
|
||||||
|
dispatchCustomEvent: vi.fn(),
|
||||||
|
apiURL: vi.fn((path: string) => path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||||
|
useWorkflowStore: () => ({ getWorkflowByPath: () => null })
|
||||||
|
}))
|
||||||
|
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||||
|
useSubgraphNavigationStore: () => ({ exportState: () => [] })
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ChangeTracker', () => {
|
||||||
|
let tracker: ChangeTracker
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockWorkflow = { path: 'test.json' } as unknown as ComfyWorkflow
|
||||||
|
const initialState = {
|
||||||
|
version: 1,
|
||||||
|
nodes: [],
|
||||||
|
links: [],
|
||||||
|
last_node_id: 0,
|
||||||
|
last_link_id: 0
|
||||||
|
} as unknown as ComfyWorkflowJSON
|
||||||
|
tracker = new ChangeTracker(mockWorkflow, initialState)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('beforeChange / afterChange batching', () => {
|
||||||
|
it('calls checkState only when outermost afterChange completes', () => {
|
||||||
|
const checkStateSpy = vi.spyOn(tracker, 'checkState')
|
||||||
|
|
||||||
|
tracker.beforeChange()
|
||||||
|
tracker.afterChange()
|
||||||
|
|
||||||
|
expect(checkStateSpy).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses checkState for nested calls until fully unwound', () => {
|
||||||
|
const checkStateSpy = vi.spyOn(tracker, 'checkState')
|
||||||
|
|
||||||
|
tracker.beforeChange()
|
||||||
|
tracker.beforeChange()
|
||||||
|
|
||||||
|
tracker.afterChange()
|
||||||
|
expect(checkStateSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
tracker.afterChange()
|
||||||
|
expect(checkStateSpy).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,6 +34,13 @@ export class ChangeTracker {
|
|||||||
activeState: ComfyWorkflowJSON
|
activeState: ComfyWorkflowJSON
|
||||||
undoQueue: ComfyWorkflowJSON[] = []
|
undoQueue: ComfyWorkflowJSON[] = []
|
||||||
redoQueue: ComfyWorkflowJSON[] = []
|
redoQueue: ComfyWorkflowJSON[] = []
|
||||||
|
/**
|
||||||
|
* Nesting counter for compound operations. While greater than zero,
|
||||||
|
* {@link checkState} is suppressed. Incremented by {@link beforeChange},
|
||||||
|
* decremented by {@link afterChange}. When it returns to zero,
|
||||||
|
* `checkState()` runs and captures a single undo entry for all mutations
|
||||||
|
* since the first `beforeChange`.
|
||||||
|
*/
|
||||||
changeCount: number = 0
|
changeCount: number = 0
|
||||||
/**
|
/**
|
||||||
* Whether the redo/undo restoring is in progress.
|
* Whether the redo/undo restoring is in progress.
|
||||||
@@ -203,10 +210,24 @@ export class ChangeTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the start of a compound operation. Increments the nesting
|
||||||
|
* counter to suppress {@link checkState} until the matching
|
||||||
|
* {@link afterChange} call completes.
|
||||||
|
*
|
||||||
|
* Typically called via `LGraphCanvas.emitBeforeChange()` through the
|
||||||
|
* `litegraph:canvas` DOM event, rather than directly.
|
||||||
|
*/
|
||||||
beforeChange() {
|
beforeChange() {
|
||||||
this.changeCount++
|
this.changeCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the end of a compound operation. Decrements the nesting
|
||||||
|
* counter; when it reaches zero, calls {@link checkState} to capture
|
||||||
|
* a single undo entry for all mutations since the first
|
||||||
|
* {@link beforeChange}.
|
||||||
|
*/
|
||||||
afterChange() {
|
afterChange() {
|
||||||
if (!--this.changeCount) {
|
if (!--this.changeCount) {
|
||||||
this.checkState()
|
this.checkState()
|
||||||
|
|||||||
2
src/types/litegraph-augmentation.d.ts
vendored
2
src/types/litegraph-augmentation.d.ts
vendored
@@ -200,7 +200,7 @@ declare module '@/lib/litegraph/src/litegraph' {
|
|||||||
/** Callback for pasting an image file into the node */
|
/** Callback for pasting an image file into the node */
|
||||||
pasteFile?(file: File): void
|
pasteFile?(file: File): void
|
||||||
/** Callback for pasting multiple files into the node */
|
/** Callback for pasting multiple files into the node */
|
||||||
pasteFiles?(files: File[]): void
|
pasteFiles?(files: File[]): boolean | Promise<boolean>
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Only used by the Primitive node. Primitive node is using the widget property
|
* Only used by the Primitive node. Primitive node is using the widget property
|
||||||
|
|||||||
Reference in New Issue
Block a user