mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 06:44:32 +00:00
Add support for dragging in multiple workflow files at once (#8757)
## Summary Allows users to drag in multiple files that are/have embedded workflows and loads each of them as tabs. Previously it would only load the first one. ## Changes - **What**: - process all files from drop event - add defered errors so you don't get errors for non-visible workflows ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8757-Add-support-for-dragging-in-multiple-workflow-files-at-once-3026d73d365081c096e9dfb18ba01253) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -201,11 +201,10 @@ describe('pasteImageNodes', () => {
|
||||
|
||||
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
|
||||
[file1, file2]
|
||||
)
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
@@ -217,11 +216,9 @@ describe('pasteImageNodes', () => {
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function pasteImageNode(
|
||||
|
||||
export async function pasteImageNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: FileList
|
||||
fileList: File[]
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
|
||||
251
src/platform/workflow/core/services/workflowService.test.ts
Normal file
251
src/platform/workflow/core/services/workflowService.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { mockShowLoadWorkflowWarning, mockShowMissingModelsWarning } =
|
||||
vi.hoisted(() => ({
|
||||
mockShowLoadWorkflowWarning: vi.fn(),
|
||||
mockShowMissingModelsWarning: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showLoadWorkflowWarning: mockShowLoadWorkflowWarning,
|
||||
showMissingModelsWarning: mockShowMissingModelsWarning,
|
||||
prompt: vi.fn(),
|
||||
confirm: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { ds: { offset: [0, 0], scale: 1 } },
|
||||
rootGraph: { serialize: vi.fn(() => ({})) },
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/defaultGraph', () => ({
|
||||
defaultGraph: {},
|
||||
blankGraph: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
storeThumbnail: vi.fn(),
|
||||
getThumbnail: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStore', () => ({
|
||||
useWorkflowDraftStore: () => ({
|
||||
saveDraft: vi.fn(),
|
||||
getDraft: vi.fn(),
|
||||
removeDraft: vi.fn(),
|
||||
markDraftUsed: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
clear: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const MISSING_MODELS: PendingWarnings['missingModels'] = {
|
||||
missingModels: [
|
||||
{ name: 'model.safetensors', url: '', directory: 'checkpoints' }
|
||||
],
|
||||
paths: { checkpoints: ['/models/checkpoints'] }
|
||||
}
|
||||
|
||||
function createWorkflow(
|
||||
warnings: PendingWarnings | null = null,
|
||||
options: { loadable?: boolean; path?: string } = {}
|
||||
): ComfyWorkflow {
|
||||
return {
|
||||
pendingWarnings: warnings,
|
||||
...(options.loadable && {
|
||||
path: options.path ?? 'workflows/test.json',
|
||||
isLoaded: true,
|
||||
activeState: { nodes: [], links: [] },
|
||||
changeTracker: { reset: vi.fn(), restore: vi.fn() }
|
||||
})
|
||||
} as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function enableWarningSettings() {
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
(key: string): boolean => {
|
||||
if (key === 'Comfy.Workflow.ShowMissingNodesWarning') return true
|
||||
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
|
||||
return false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('useWorkflowService', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('showPendingWarnings', () => {
|
||||
beforeEach(() => {
|
||||
enableWarningSettings()
|
||||
})
|
||||
|
||||
it('should do nothing when workflow has no pending warnings', () => {
|
||||
const workflow = createWorkflow(null)
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
|
||||
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show missing nodes dialog and clear warnings', () => {
|
||||
const missingNodeTypes = ['CustomNode1', 'CustomNode2']
|
||||
const workflow = createWorkflow({ missingNodeTypes })
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
|
||||
missingNodeTypes
|
||||
})
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should show missing models dialog and clear warnings', () => {
|
||||
const workflow = createWorkflow({ missingModels: MISSING_MODELS })
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowMissingModelsWarning).toHaveBeenCalledWith(MISSING_MODELS)
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should not show dialogs when settings are disabled', () => {
|
||||
vi.spyOn(useSettingStore(), 'get').mockReturnValue(false)
|
||||
|
||||
const workflow = createWorkflow({
|
||||
missingNodeTypes: ['CustomNode1'],
|
||||
missingModels: MISSING_MODELS
|
||||
})
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
|
||||
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should only show warnings once across multiple calls', () => {
|
||||
const workflow = createWorkflow({
|
||||
missingNodeTypes: ['CustomNode1']
|
||||
})
|
||||
|
||||
const service = useWorkflowService()
|
||||
service.showPendingWarnings(workflow)
|
||||
service.showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openWorkflow deferred warnings', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
enableWarningSettings()
|
||||
workflowStore = useWorkflowStore()
|
||||
vi.mocked(app.loadGraphData).mockImplementation(
|
||||
async (_data, _clean, _restore, wf) => {
|
||||
;(
|
||||
workflowStore as unknown as Record<string, unknown>
|
||||
).activeWorkflow = wf
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should defer warnings during load and show on focus', async () => {
|
||||
const workflow = createWorkflow(
|
||||
{ missingNodeTypes: ['CustomNode1'] },
|
||||
{ loadable: true }
|
||||
)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
|
||||
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
true,
|
||||
true,
|
||||
workflow,
|
||||
expect.objectContaining({ deferWarnings: true })
|
||||
)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
|
||||
missingNodeTypes: ['CustomNode1']
|
||||
})
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should show each workflow warnings only when that tab is focused', async () => {
|
||||
const workflow1 = createWorkflow(
|
||||
{ missingNodeTypes: ['MissingNodeA'] },
|
||||
{ loadable: true, path: 'workflows/first.json' }
|
||||
)
|
||||
const workflow2 = createWorkflow(
|
||||
{ missingNodeTypes: ['MissingNodeB'] },
|
||||
{ loadable: true, path: 'workflows/second.json' }
|
||||
)
|
||||
|
||||
const service = useWorkflowService()
|
||||
|
||||
await service.openWorkflow(workflow1)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
|
||||
missingNodeTypes: ['MissingNodeA']
|
||||
})
|
||||
expect(workflow1.pendingWarnings).toBeNull()
|
||||
expect(workflow2.pendingWarnings).not.toBeNull()
|
||||
|
||||
await service.openWorkflow(workflow2)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(2)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenLastCalledWith({
|
||||
missingNodeTypes: ['MissingNodeB']
|
||||
})
|
||||
expect(workflow2.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should not show warnings when refocusing a cleared tab', async () => {
|
||||
const workflow = createWorkflow(
|
||||
{ missingNodeTypes: ['CustomNode1'] },
|
||||
{ loadable: true }
|
||||
)
|
||||
|
||||
const service = useWorkflowService()
|
||||
|
||||
await service.openWorkflow(workflow, { force: true })
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
|
||||
await service.openWorkflow(workflow, { force: true })
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -183,9 +183,11 @@ export const useWorkflowService = () => {
|
||||
{
|
||||
showMissingModelsDialog: loadFromRemote,
|
||||
showMissingNodesDialog: loadFromRemote,
|
||||
checkForRerouteMigration: false
|
||||
checkForRerouteMigration: false,
|
||||
deferWarnings: true
|
||||
}
|
||||
)
|
||||
showPendingWarnings()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,6 +439,32 @@ export const useWorkflowService = () => {
|
||||
await app.loadGraphData(state, true, true, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show and clear any pending warnings (missing nodes/models) stored on the
|
||||
* active workflow. Called after a workflow becomes visible so dialogs don't
|
||||
* overlap with subsequent loads.
|
||||
*/
|
||||
function showPendingWarnings(workflow?: ComfyWorkflow | null) {
|
||||
const wf = workflow ?? workflowStore.activeWorkflow
|
||||
if (!wf?.pendingWarnings) return
|
||||
|
||||
const { missingNodeTypes, missingModels } = wf.pendingWarnings
|
||||
wf.pendingWarnings = null
|
||||
|
||||
if (
|
||||
missingNodeTypes?.length &&
|
||||
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
|
||||
) {
|
||||
void dialogService.showLoadWorkflowWarning({ missingNodeTypes })
|
||||
}
|
||||
if (
|
||||
missingModels &&
|
||||
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
|
||||
) {
|
||||
void dialogService.showMissingModelsWarning(missingModels)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exportWorkflow,
|
||||
saveWorkflowAs,
|
||||
@@ -452,6 +480,7 @@ export const useWorkflowService = () => {
|
||||
loadNextOpenedWorkflow,
|
||||
loadPreviousOpenedWorkflow,
|
||||
duplicateWorkflow,
|
||||
showPendingWarnings,
|
||||
afterLoadNewGraph,
|
||||
beforeLoadNewGraph
|
||||
}
|
||||
|
||||
@@ -3,7 +3,19 @@ import { markRaw } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
ModelFile
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
export interface PendingWarnings {
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
missingModels?: {
|
||||
missingModels: ModelFile[]
|
||||
paths: Record<string, string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
@@ -17,6 +29,10 @@ export class ComfyWorkflow extends UserFile {
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
/**
|
||||
* Warnings deferred from load time, shown when the workflow is first focused.
|
||||
*/
|
||||
pendingWarnings: PendingWarnings | null = null
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
|
||||
@@ -85,11 +85,7 @@ describe('ComfyApp', () => {
|
||||
|
||||
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
|
||||
const files = [file1, file2]
|
||||
|
||||
await app.handleFileList(files)
|
||||
|
||||
@@ -110,26 +106,21 @@ describe('ComfyApp', () => {
|
||||
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)
|
||||
await app.handleFileList([file])
|
||||
|
||||
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()
|
||||
await expect(app.handleFileList([])).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)
|
||||
await app.handleFileList([invalidFile])
|
||||
|
||||
expect(pasteImageNodes).not.toHaveBeenCalled()
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
|
||||
import {
|
||||
@@ -107,7 +108,7 @@ import { ComfyAppMenu } from './ui/menu/index'
|
||||
import { clone } from './utils'
|
||||
import { type ComfyWidgetConstructor } from './widgets'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { extractFileFromDragEvent } from '@/utils/eventUtils'
|
||||
import { extractFilesFromDragEvent, hasImageType } from '@/utils/eventUtils'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
||||
|
||||
@@ -550,22 +551,25 @@ export class ComfyApp {
|
||||
// If you drag multiple files it will call it multiple times with the same file
|
||||
if (await n?.onDragDrop?.(event)) return
|
||||
|
||||
const fileMaybe = await extractFileFromDragEvent(event)
|
||||
if (!fileMaybe) return
|
||||
const files = await extractFilesFromDragEvent(event)
|
||||
if (files.length === 0) return
|
||||
|
||||
const workspace = useWorkspaceStore()
|
||||
try {
|
||||
workspace.spinner = true
|
||||
if (fileMaybe instanceof File) {
|
||||
await this.handleFile(fileMaybe, 'file_drop')
|
||||
}
|
||||
|
||||
if (fileMaybe instanceof FileList) {
|
||||
await this.handleFileList(fileMaybe)
|
||||
if (files.length > 1 && files.every(hasImageType)) {
|
||||
await this.handleFileList(files)
|
||||
} else {
|
||||
for (const file of files) {
|
||||
await this.handleFile(file, 'file_drop', {
|
||||
deferWarnings: true
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
workspace.spinner = false
|
||||
}
|
||||
useWorkflowService().showPendingWarnings()
|
||||
} catch (error: unknown) {
|
||||
useToastStore().addAlert(t('toastMessages.dropFileError', { error }))
|
||||
}
|
||||
@@ -1063,18 +1067,6 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
private showMissingModelsError(
|
||||
missingModels: ModelFile[],
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
if (useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
|
||||
useDialogService().showMissingModelsWarning({
|
||||
missingModels,
|
||||
paths
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async loadGraphData(
|
||||
graphData?: ComfyWorkflowJSON,
|
||||
clean: boolean = true,
|
||||
@@ -1085,13 +1077,15 @@ export class ComfyApp {
|
||||
showMissingModelsDialog?: boolean
|
||||
checkForRerouteMigration?: boolean
|
||||
openSource?: WorkflowOpenSource
|
||||
deferWarnings?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
showMissingNodesDialog = true,
|
||||
showMissingModelsDialog = true,
|
||||
checkForRerouteMigration = false,
|
||||
openSource
|
||||
openSource,
|
||||
deferWarnings = false
|
||||
} = options
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
@@ -1334,13 +1328,6 @@ export class ComfyApp {
|
||||
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
||||
})
|
||||
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
this.showMissingNodesError(missingNodeTypes)
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
this.showMissingModelsError(missingModels, paths)
|
||||
}
|
||||
await useExtensionService().invokeExtensionsAsync(
|
||||
'afterConfigureGraph',
|
||||
missingNodeTypes
|
||||
@@ -1359,6 +1346,27 @@ export class ComfyApp {
|
||||
workflow,
|
||||
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
|
||||
// Store pending warnings on the workflow for deferred display
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
if (activeWf) {
|
||||
const warnings: PendingWarnings = {}
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
warnings.missingNodeTypes = missingNodeTypes
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
warnings.missingModels = { missingModels: missingModels, paths }
|
||||
}
|
||||
if (warnings.missingNodeTypes || warnings.missingModels) {
|
||||
activeWf.pendingWarnings = warnings
|
||||
}
|
||||
}
|
||||
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.canvas.setDirty(true, true)
|
||||
})
|
||||
@@ -1500,7 +1508,11 @@ export class ComfyApp {
|
||||
* Loads workflow data from the specified file
|
||||
* @param {File} file
|
||||
*/
|
||||
async handleFile(file: File, openSource?: WorkflowOpenSource) {
|
||||
async handleFile(
|
||||
file: File,
|
||||
openSource?: WorkflowOpenSource,
|
||||
options?: { deferWarnings?: boolean }
|
||||
) {
|
||||
const fileName = file.name.replace(/\.\w+$/, '') // Strip file extension
|
||||
const workflowData = await getWorkflowDataFromFile(file)
|
||||
const { workflow, prompt, parameters, templates } = workflowData ?? {}
|
||||
@@ -1543,7 +1555,8 @@ export class ComfyApp {
|
||||
!Array.isArray(workflowObj)
|
||||
) {
|
||||
await this.loadGraphData(workflowObj, true, true, fileName, {
|
||||
openSource
|
||||
openSource,
|
||||
deferWarnings: options?.deferWarnings
|
||||
})
|
||||
return
|
||||
} else {
|
||||
@@ -1591,7 +1604,7 @@ export class ComfyApp {
|
||||
* Loads multiple files, connects to a batch node, and selects them
|
||||
* @param {FileList} fileList
|
||||
*/
|
||||
async handleFileList(fileList: FileList) {
|
||||
async handleFileList(fileList: File[]) {
|
||||
if (fileList[0].type.startsWith('image')) {
|
||||
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
||||
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
||||
|
||||
@@ -1,39 +1,68 @@
|
||||
import { extractFileFromDragEvent } from '@/utils/eventUtils'
|
||||
import { extractFilesFromDragEvent } from '@/utils/eventUtils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('eventUtils', () => {
|
||||
describe('extractFileFromDragEvent', () => {
|
||||
it('should handle drops with no data', async () => {
|
||||
const actual = await extractFileFromDragEvent(new FakeDragEvent('drop'))
|
||||
expect(actual).toBe(undefined)
|
||||
describe('extractFilesFromDragEvent', () => {
|
||||
it('should return empty array when no dataTransfer', async () => {
|
||||
const actual = await extractFilesFromDragEvent(new FakeDragEvent('drop'))
|
||||
expect(actual).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle drops with dataTransfer but no files', async () => {
|
||||
const actual = await extractFileFromDragEvent(
|
||||
it('should return empty array when dataTransfer has no files', async () => {
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer: new DataTransfer() })
|
||||
)
|
||||
expect(actual).toBe(undefined)
|
||||
expect(actual).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle drops with dataTransfer with files', async () => {
|
||||
const fileWithWorkflowMaybeWhoKnows = new File(
|
||||
[new Uint8Array()],
|
||||
'fake_workflow.json',
|
||||
{
|
||||
type: 'application/json'
|
||||
}
|
||||
)
|
||||
|
||||
it('should return single file from dataTransfer', async () => {
|
||||
const file = new File([new Uint8Array()], 'workflow.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(fileWithWorkflowMaybeWhoKnows)
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([file])
|
||||
})
|
||||
|
||||
it('should handle drops with multiple image files', async () => {
|
||||
it('should return multiple files from dataTransfer', async () => {
|
||||
const file1 = new File([new Uint8Array()], 'workflow1.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const file2 = new File([new Uint8Array()], 'workflow2.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([file1, file2])
|
||||
})
|
||||
|
||||
it('should filter out bmp files', async () => {
|
||||
const jsonFile = new File([new Uint8Array()], 'workflow.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const bmpFile = new File([new Uint8Array()], 'image.bmp', {
|
||||
type: 'image/bmp'
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(jsonFile)
|
||||
dataTransfer.items.add(bmpFile)
|
||||
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([jsonFile])
|
||||
})
|
||||
|
||||
it('should return multiple image files from dataTransfer', async () => {
|
||||
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
@@ -45,16 +74,13 @@ describe('eventUtils', () => {
|
||||
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)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([imageFile1, imageFile2])
|
||||
})
|
||||
|
||||
it('should return undefined when dropping multiple non-image files', async () => {
|
||||
it('should return multiple non-image files from dataTransfer', async () => {
|
||||
const file1 = new File([new Uint8Array()], 'file1.txt', {
|
||||
type: 'text/plain'
|
||||
})
|
||||
@@ -66,10 +92,10 @@ describe('eventUtils', () => {
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBe(undefined)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([file1, file2])
|
||||
})
|
||||
|
||||
// Skip until we can setup MSW
|
||||
@@ -77,14 +103,14 @@ describe('eventUtils', () => {
|
||||
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
|
||||
dataTransfer.setData('text/uri-list', urlWithWorkflow)
|
||||
dataTransfer.setData('text/x-moz-url', urlWithWorkflow)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBeInstanceOf(File)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual.length).toBe(1)
|
||||
expect(actual[0]).toBeInstanceOf(File)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
export async function extractFileFromDragEvent(
|
||||
export async function extractFilesFromDragEvent(
|
||||
event: DragEvent
|
||||
): Promise<File | FileList | undefined> {
|
||||
if (!event.dataTransfer) return
|
||||
): Promise<File[]> {
|
||||
if (!event.dataTransfer) return []
|
||||
|
||||
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
|
||||
}
|
||||
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
|
||||
const files = Array.from(event.dataTransfer.files).filter(
|
||||
(file) => file.type !== 'image/bmp'
|
||||
)
|
||||
|
||||
if (files.length > 0) return files
|
||||
|
||||
// Try loading the first URI in the transfer list
|
||||
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
||||
const match = [...event.dataTransfer.types].find((t) =>
|
||||
validTypes.includes(t)
|
||||
)
|
||||
if (!match) return
|
||||
if (!match) return []
|
||||
|
||||
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
|
||||
if (!uri) return
|
||||
if (!uri) return []
|
||||
|
||||
const response = await fetch(uri)
|
||||
const blob = await response.blob()
|
||||
return new File([blob], uri, { type: blob.type })
|
||||
return [new File([blob], uri, { type: blob.type })]
|
||||
}
|
||||
|
||||
function hasImageType({ type }: File): boolean {
|
||||
export function hasImageType({ type }: File): boolean {
|
||||
return type.startsWith('image')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user