From f5f5a774355b8f8c03e6d17b56ba15142eb472cb Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:45:22 +0000 Subject: [PATCH] Add support for dragging in multiple workflow files at once (#8757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/composables/usePaste.test.ts | 7 +- src/composables/usePaste.ts | 2 +- .../core/services/workflowService.test.ts | 251 ++++++++++++++++++ .../workflow/core/services/workflowService.ts | 31 ++- .../management/stores/comfyWorkflow.ts | 18 +- src/scripts/app.test.ts | 17 +- src/scripts/app.ts | 77 +++--- src/utils/__tests__/eventUtils.test.ts | 106 +++++--- src/utils/eventUtils.ts | 27 +- 9 files changed, 429 insertions(+), 107 deletions(-) create mode 100644 src/platform/workflow/core/services/workflowService.test.ts diff --git a/src/composables/usePaste.test.ts b/src/composables/usePaste.test.ts index f380ea27c9..0c36ff0093 100644 --- a/src/composables/usePaste.test.ts +++ b/src/composables/usePaste.test.ts @@ -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() diff --git a/src/composables/usePaste.ts b/src/composables/usePaste.ts index 75d5e3af41..ccf478e246 100644 --- a/src/composables/usePaste.ts +++ b/src/composables/usePaste.ts @@ -96,7 +96,7 @@ export async function pasteImageNode( export async function pasteImageNodes( canvas: LGraphCanvas, - fileList: FileList + fileList: File[] ): Promise { const nodes: LGraphNode[] = [] diff --git a/src/platform/workflow/core/services/workflowService.test.ts b/src/platform/workflow/core/services/workflowService.test.ts new file mode 100644 index 0000000000..c597c80a53 --- /dev/null +++ b/src/platform/workflow/core/services/workflowService.test.ts @@ -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 + + beforeEach(() => { + enableWarningSettings() + workflowStore = useWorkflowStore() + vi.mocked(app.loadGraphData).mockImplementation( + async (_data, _clean, _restore, wf) => { + ;( + workflowStore as unknown as Record + ).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) + }) + }) +}) diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts index c4647cc8ad..14d15f05af 100644 --- a/src/platform/workflow/core/services/workflowService.ts +++ b/src/platform/workflow/core/services/workflowService.ts @@ -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 } diff --git a/src/platform/workflow/management/stores/comfyWorkflow.ts b/src/platform/workflow/management/stores/comfyWorkflow.ts index 553e5384d3..ee539edb69 100644 --- a/src/platform/workflow/management/stores/comfyWorkflow.ts +++ b/src/platform/workflow/management/stores/comfyWorkflow.ts @@ -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 + } +} 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. diff --git a/src/scripts/app.test.ts b/src/scripts/app.test.ts index 7127f0bead..7fc8ae3193 100644 --- a/src/scripts/app.test.ts +++ b/src/scripts/app.test.ts @@ -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() diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 5a14b3a644..8233c0d38d 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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 - ): 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') diff --git a/src/utils/__tests__/eventUtils.test.ts b/src/utils/__tests__/eventUtils.test.ts index ca20da96c9..e861dc497e 100644 --- a/src/utils/__tests__/eventUtils.test.ts +++ b/src/utils/__tests__/eventUtils.test.ts @@ -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) }) }) }) diff --git a/src/utils/eventUtils.ts b/src/utils/eventUtils.ts index a9a789c723..25382865cf 100644 --- a/src/utils/eventUtils.ts +++ b/src/utils/eventUtils.ts @@ -1,31 +1,30 @@ -export async function extractFileFromDragEvent( +export async function extractFilesFromDragEvent( event: DragEvent -): Promise { - if (!event.dataTransfer) return +): Promise { + 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') }