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:
pythongosssss
2026-02-17 07:45:22 +00:00
committed by GitHub
parent efe78b799f
commit f5f5a77435
9 changed files with 429 additions and 107 deletions

View File

@@ -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()

View File

@@ -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[] = []

View 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)
})
})
})

View File

@@ -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
} }

View File

@@ -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.

View File

@@ -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()

View File

@@ -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')

View File

@@ -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)
}) })
}) })
}) })

View 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')
} }