mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +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 file1 = createImageFile('test1.png')
|
||||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||||
const fileList = createDataTransfer([file1, file2]).files
|
|
||||||
|
|
||||||
const result = await pasteImageNodes(
|
const result = await pasteImageNodes(
|
||||||
mockCanvas as unknown as LGraphCanvas,
|
mockCanvas as unknown as LGraphCanvas,
|
||||||
fileList
|
[file1, file2]
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(createNode).toHaveBeenCalledTimes(2)
|
expect(createNode).toHaveBeenCalledTimes(2)
|
||||||
@@ -217,11 +216,9 @@ describe('pasteImageNodes', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty file list', async () => {
|
it('should handle empty file list', async () => {
|
||||||
const fileList = createDataTransfer([]).files
|
|
||||||
|
|
||||||
const result = await pasteImageNodes(
|
const result = await pasteImageNodes(
|
||||||
mockCanvas as unknown as LGraphCanvas,
|
mockCanvas as unknown as LGraphCanvas,
|
||||||
fileList
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(createNode).not.toHaveBeenCalled()
|
expect(createNode).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export async function pasteImageNode(
|
|||||||
|
|
||||||
export async function pasteImageNodes(
|
export async function pasteImageNodes(
|
||||||
canvas: LGraphCanvas,
|
canvas: LGraphCanvas,
|
||||||
fileList: FileList
|
fileList: File[]
|
||||||
): Promise<LGraphNode[]> {
|
): Promise<LGraphNode[]> {
|
||||||
const nodes: 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,
|
showMissingModelsDialog: loadFromRemote,
|
||||||
showMissingNodesDialog: loadFromRemote,
|
showMissingNodesDialog: loadFromRemote,
|
||||||
checkForRerouteMigration: false
|
checkForRerouteMigration: false,
|
||||||
|
deferWarnings: true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
showPendingWarnings()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -437,6 +439,32 @@ export const useWorkflowService = () => {
|
|||||||
await app.loadGraphData(state, true, true, filename)
|
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 {
|
return {
|
||||||
exportWorkflow,
|
exportWorkflow,
|
||||||
saveWorkflowAs,
|
saveWorkflowAs,
|
||||||
@@ -452,6 +480,7 @@ export const useWorkflowService = () => {
|
|||||||
loadNextOpenedWorkflow,
|
loadNextOpenedWorkflow,
|
||||||
loadPreviousOpenedWorkflow,
|
loadPreviousOpenedWorkflow,
|
||||||
duplicateWorkflow,
|
duplicateWorkflow,
|
||||||
|
showPendingWarnings,
|
||||||
afterLoadNewGraph,
|
afterLoadNewGraph,
|
||||||
beforeLoadNewGraph
|
beforeLoadNewGraph
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,19 @@ import { markRaw } from 'vue'
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||||
import { UserFile } from '@/stores/userFileStore'
|
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 {
|
export class ComfyWorkflow extends UserFile {
|
||||||
static readonly basePath: string = 'workflows/'
|
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.
|
* Whether the workflow has been modified comparing to the initial state.
|
||||||
*/
|
*/
|
||||||
_isModified: boolean = false
|
_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.
|
* @param options The path, modified, and size of the workflow.
|
||||||
|
|||||||
@@ -85,11 +85,7 @@ describe('ComfyApp', () => {
|
|||||||
|
|
||||||
const file1 = createTestFile('test1.png', 'image/png')
|
const file1 = createTestFile('test1.png', 'image/png')
|
||||||
const file2 = createTestFile('test2.jpg', 'image/jpeg')
|
const file2 = createTestFile('test2.jpg', 'image/jpeg')
|
||||||
const dataTransfer = new DataTransfer()
|
const files = [file1, file2]
|
||||||
dataTransfer.items.add(file1)
|
|
||||||
dataTransfer.items.add(file2)
|
|
||||||
|
|
||||||
const { files } = dataTransfer
|
|
||||||
|
|
||||||
await app.handleFileList(files)
|
await app.handleFileList(files)
|
||||||
|
|
||||||
@@ -110,26 +106,21 @@ describe('ComfyApp', () => {
|
|||||||
vi.mocked(createNode).mockResolvedValue(null)
|
vi.mocked(createNode).mockResolvedValue(null)
|
||||||
|
|
||||||
const file = createTestFile('test.png', 'image/png')
|
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(mockCanvas.selectItems).not.toHaveBeenCalled()
|
||||||
expect(mockNode1.connect).not.toHaveBeenCalled()
|
expect(mockNode1.connect).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty file list', async () => {
|
it('should handle empty file list', async () => {
|
||||||
const dataTransfer = new DataTransfer()
|
await expect(app.handleFileList([])).rejects.toThrow()
|
||||||
await expect(app.handleFileList(dataTransfer.files)).rejects.toThrow()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not process unsupported file types', async () => {
|
it('should not process unsupported file types', async () => {
|
||||||
const invalidFile = createTestFile('test.pdf', 'application/pdf')
|
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(pasteImageNodes).not.toHaveBeenCalled()
|
||||||
expect(createNode).not.toHaveBeenCalled()
|
expect(createNode).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
|||||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
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 { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
|
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
|
||||||
import {
|
import {
|
||||||
@@ -107,7 +108,7 @@ import { ComfyAppMenu } from './ui/menu/index'
|
|||||||
import { clone } from './utils'
|
import { clone } from './utils'
|
||||||
import { type ComfyWidgetConstructor } from './widgets'
|
import { type ComfyWidgetConstructor } from './widgets'
|
||||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
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 { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||||
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
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 you drag multiple files it will call it multiple times with the same file
|
||||||
if (await n?.onDragDrop?.(event)) return
|
if (await n?.onDragDrop?.(event)) return
|
||||||
|
|
||||||
const fileMaybe = await extractFileFromDragEvent(event)
|
const files = await extractFilesFromDragEvent(event)
|
||||||
if (!fileMaybe) return
|
if (files.length === 0) return
|
||||||
|
|
||||||
const workspace = useWorkspaceStore()
|
const workspace = useWorkspaceStore()
|
||||||
try {
|
try {
|
||||||
workspace.spinner = true
|
workspace.spinner = true
|
||||||
if (fileMaybe instanceof File) {
|
if (files.length > 1 && files.every(hasImageType)) {
|
||||||
await this.handleFile(fileMaybe, 'file_drop')
|
await this.handleFileList(files)
|
||||||
}
|
} else {
|
||||||
|
for (const file of files) {
|
||||||
if (fileMaybe instanceof FileList) {
|
await this.handleFile(file, 'file_drop', {
|
||||||
await this.handleFileList(fileMaybe)
|
deferWarnings: true
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
workspace.spinner = false
|
workspace.spinner = false
|
||||||
}
|
}
|
||||||
|
useWorkflowService().showPendingWarnings()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
useToastStore().addAlert(t('toastMessages.dropFileError', { error }))
|
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(
|
async loadGraphData(
|
||||||
graphData?: ComfyWorkflowJSON,
|
graphData?: ComfyWorkflowJSON,
|
||||||
clean: boolean = true,
|
clean: boolean = true,
|
||||||
@@ -1085,13 +1077,15 @@ export class ComfyApp {
|
|||||||
showMissingModelsDialog?: boolean
|
showMissingModelsDialog?: boolean
|
||||||
checkForRerouteMigration?: boolean
|
checkForRerouteMigration?: boolean
|
||||||
openSource?: WorkflowOpenSource
|
openSource?: WorkflowOpenSource
|
||||||
|
deferWarnings?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
showMissingNodesDialog = true,
|
showMissingNodesDialog = true,
|
||||||
showMissingModelsDialog = true,
|
showMissingModelsDialog = true,
|
||||||
checkForRerouteMigration = false,
|
checkForRerouteMigration = false,
|
||||||
openSource
|
openSource,
|
||||||
|
deferWarnings = false
|
||||||
} = options
|
} = options
|
||||||
useWorkflowService().beforeLoadNewGraph()
|
useWorkflowService().beforeLoadNewGraph()
|
||||||
|
|
||||||
@@ -1334,13 +1328,6 @@ export class ComfyApp {
|
|||||||
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
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(
|
await useExtensionService().invokeExtensionsAsync(
|
||||||
'afterConfigureGraph',
|
'afterConfigureGraph',
|
||||||
missingNodeTypes
|
missingNodeTypes
|
||||||
@@ -1359,6 +1346,27 @@ export class ComfyApp {
|
|||||||
workflow,
|
workflow,
|
||||||
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
this.canvas.setDirty(true, true)
|
this.canvas.setDirty(true, true)
|
||||||
})
|
})
|
||||||
@@ -1500,7 +1508,11 @@ export class ComfyApp {
|
|||||||
* Loads workflow data from the specified file
|
* Loads workflow data from the specified file
|
||||||
* @param {File} 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 fileName = file.name.replace(/\.\w+$/, '') // Strip file extension
|
||||||
const workflowData = await getWorkflowDataFromFile(file)
|
const workflowData = await getWorkflowDataFromFile(file)
|
||||||
const { workflow, prompt, parameters, templates } = workflowData ?? {}
|
const { workflow, prompt, parameters, templates } = workflowData ?? {}
|
||||||
@@ -1543,7 +1555,8 @@ export class ComfyApp {
|
|||||||
!Array.isArray(workflowObj)
|
!Array.isArray(workflowObj)
|
||||||
) {
|
) {
|
||||||
await this.loadGraphData(workflowObj, true, true, fileName, {
|
await this.loadGraphData(workflowObj, true, true, fileName, {
|
||||||
openSource
|
openSource,
|
||||||
|
deferWarnings: options?.deferWarnings
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
@@ -1591,7 +1604,7 @@ export class ComfyApp {
|
|||||||
* Loads multiple files, connects to a batch node, and selects them
|
* Loads multiple files, connects to a batch node, and selects them
|
||||||
* @param {FileList} fileList
|
* @param {FileList} fileList
|
||||||
*/
|
*/
|
||||||
async handleFileList(fileList: FileList) {
|
async handleFileList(fileList: File[]) {
|
||||||
if (fileList[0].type.startsWith('image')) {
|
if (fileList[0].type.startsWith('image')) {
|
||||||
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
||||||
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
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'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
describe('eventUtils', () => {
|
describe('eventUtils', () => {
|
||||||
describe('extractFileFromDragEvent', () => {
|
describe('extractFilesFromDragEvent', () => {
|
||||||
it('should handle drops with no data', async () => {
|
it('should return empty array when no dataTransfer', async () => {
|
||||||
const actual = await extractFileFromDragEvent(new FakeDragEvent('drop'))
|
const actual = await extractFilesFromDragEvent(new FakeDragEvent('drop'))
|
||||||
expect(actual).toBe(undefined)
|
expect(actual).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle drops with dataTransfer but no files', async () => {
|
it('should return empty array when dataTransfer has no files', async () => {
|
||||||
const actual = await extractFileFromDragEvent(
|
const actual = await extractFilesFromDragEvent(
|
||||||
new FakeDragEvent('drop', { dataTransfer: new DataTransfer() })
|
new FakeDragEvent('drop', { dataTransfer: new DataTransfer() })
|
||||||
)
|
)
|
||||||
expect(actual).toBe(undefined)
|
expect(actual).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle drops with dataTransfer with files', async () => {
|
it('should return single file from dataTransfer', async () => {
|
||||||
const fileWithWorkflowMaybeWhoKnows = new File(
|
const file = new File([new Uint8Array()], 'workflow.json', {
|
||||||
[new Uint8Array()],
|
type: 'application/json'
|
||||||
'fake_workflow.json',
|
})
|
||||||
{
|
|
||||||
type: 'application/json'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const dataTransfer = new DataTransfer()
|
const dataTransfer = new DataTransfer()
|
||||||
dataTransfer.items.add(fileWithWorkflowMaybeWhoKnows)
|
dataTransfer.items.add(file)
|
||||||
|
|
||||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
const actual = await extractFilesFromDragEvent(
|
||||||
|
new FakeDragEvent('drop', { dataTransfer })
|
||||||
const actual = await extractFileFromDragEvent(event)
|
)
|
||||||
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
|
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', {
|
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
|
||||||
type: 'image/png'
|
type: 'image/png'
|
||||||
})
|
})
|
||||||
@@ -45,16 +74,13 @@ describe('eventUtils', () => {
|
|||||||
dataTransfer.items.add(imageFile1)
|
dataTransfer.items.add(imageFile1)
|
||||||
dataTransfer.items.add(imageFile2)
|
dataTransfer.items.add(imageFile2)
|
||||||
|
|
||||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
const actual = await extractFilesFromDragEvent(
|
||||||
|
new FakeDragEvent('drop', { dataTransfer })
|
||||||
const actual = await extractFileFromDragEvent(event)
|
)
|
||||||
expect(actual).toBeDefined()
|
expect(actual).toEqual([imageFile1, imageFile2])
|
||||||
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 () => {
|
it('should return multiple non-image files from dataTransfer', async () => {
|
||||||
const file1 = new File([new Uint8Array()], 'file1.txt', {
|
const file1 = new File([new Uint8Array()], 'file1.txt', {
|
||||||
type: 'text/plain'
|
type: 'text/plain'
|
||||||
})
|
})
|
||||||
@@ -66,10 +92,10 @@ describe('eventUtils', () => {
|
|||||||
dataTransfer.items.add(file1)
|
dataTransfer.items.add(file1)
|
||||||
dataTransfer.items.add(file2)
|
dataTransfer.items.add(file2)
|
||||||
|
|
||||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
const actual = await extractFilesFromDragEvent(
|
||||||
|
new FakeDragEvent('drop', { dataTransfer })
|
||||||
const actual = await extractFileFromDragEvent(event)
|
)
|
||||||
expect(actual).toBe(undefined)
|
expect(actual).toEqual([file1, file2])
|
||||||
})
|
})
|
||||||
|
|
||||||
// Skip until we can setup MSW
|
// Skip until we can setup MSW
|
||||||
@@ -77,14 +103,14 @@ describe('eventUtils', () => {
|
|||||||
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'
|
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'
|
||||||
|
|
||||||
const dataTransfer = new DataTransfer()
|
const dataTransfer = new DataTransfer()
|
||||||
|
|
||||||
dataTransfer.setData('text/uri-list', urlWithWorkflow)
|
dataTransfer.setData('text/uri-list', urlWithWorkflow)
|
||||||
dataTransfer.setData('text/x-moz-url', urlWithWorkflow)
|
dataTransfer.setData('text/x-moz-url', urlWithWorkflow)
|
||||||
|
|
||||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
const actual = await extractFilesFromDragEvent(
|
||||||
|
new FakeDragEvent('drop', { dataTransfer })
|
||||||
const actual = await extractFileFromDragEvent(event)
|
)
|
||||||
expect(actual).toBeInstanceOf(File)
|
expect(actual.length).toBe(1)
|
||||||
|
expect(actual[0]).toBeInstanceOf(File)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
export async function extractFileFromDragEvent(
|
export async function extractFilesFromDragEvent(
|
||||||
event: DragEvent
|
event: DragEvent
|
||||||
): Promise<File | FileList | undefined> {
|
): Promise<File[]> {
|
||||||
if (!event.dataTransfer) return
|
if (!event.dataTransfer) return []
|
||||||
|
|
||||||
const { files } = event.dataTransfer
|
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
|
||||||
// Dragging from Chrome->Firefox there is a file, but it's a bmp, so ignore it
|
const files = Array.from(event.dataTransfer.files).filter(
|
||||||
if (files.length === 1 && files[0].type !== 'image/bmp') {
|
(file) => file.type !== 'image/bmp'
|
||||||
return files[0]
|
)
|
||||||
} else if (files.length > 1 && Array.from(files).every(hasImageType)) {
|
|
||||||
return files
|
if (files.length > 0) return files
|
||||||
}
|
|
||||||
|
|
||||||
// Try loading the first URI in the transfer list
|
// Try loading the first URI in the transfer list
|
||||||
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
||||||
const match = [...event.dataTransfer.types].find((t) =>
|
const match = [...event.dataTransfer.types].find((t) =>
|
||||||
validTypes.includes(t)
|
validTypes.includes(t)
|
||||||
)
|
)
|
||||||
if (!match) return
|
if (!match) return []
|
||||||
|
|
||||||
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
|
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
|
||||||
if (!uri) return
|
if (!uri) return []
|
||||||
|
|
||||||
const response = await fetch(uri)
|
const response = await fetch(uri)
|
||||||
const blob = await response.blob()
|
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')
|
return type.startsWith('image')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user