mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
feat: add external file drag-and-drop upload to file tree
Enable users to drag files from their system directly into the file tree to upload them. Files can be dropped on the tree container (uploads to root) or on specific folders. Hovering over a closed folder for 2 seconds auto- expands it. Uses Zustand for drag state management instead of React Context for better performance.
This commit is contained in:
@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileDrop } from '../hooks/use-file-drop'
|
||||
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
||||
@ -47,6 +48,22 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
const { data: treeData, isLoading, error } = useSkillAssetTreeData()
|
||||
const isMutating = useIsMutating() > 0
|
||||
|
||||
// External file drop handling
|
||||
const {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useFileDrop()
|
||||
|
||||
// Handle drag events on the container (drop to root)
|
||||
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
|
||||
handleDragOver(e, { folderId: null, isFolder: false })
|
||||
}, [handleDragOver])
|
||||
|
||||
const handleContainerDrop = useCallback((e: React.DragEvent) => {
|
||||
handleDrop(e, null)
|
||||
}, [handleDrop])
|
||||
|
||||
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const selectedTreeNodeId = useStore(s => s.selectedTreeNodeId)
|
||||
@ -148,6 +165,9 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
ref={containerRef}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1"
|
||||
onContextMenu={handleBlankAreaContextMenu}
|
||||
onDragOver={handleContainerDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleContainerDrop}
|
||||
>
|
||||
<Tree<TreeNodeData>
|
||||
ref={treeRef}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFileDrop } from '../hooks/use-file-drop'
|
||||
import { useTreeNodeHandlers } from '../hooks/use-tree-node-handlers'
|
||||
import { getFileIconType } from '../utils/file-utils'
|
||||
import NodeMenu from './node-menu'
|
||||
@ -29,6 +30,10 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
|
||||
const hasContextMenu = contextMenuNodeId === node.data.id
|
||||
|
||||
// Drag over state from Zustand store (only subscribe for folders)
|
||||
const dragOverFolderId = useStore(s => s.dragOverFolderId)
|
||||
const isDragOver = isFolder && dragOverFolderId === node.data.id
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
const fileIconType = !isFolder ? getFileIconType(node.data.name) : null
|
||||
@ -41,6 +46,26 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
handleKeyDown,
|
||||
} = useTreeNodeHandlers({ node })
|
||||
|
||||
// External file drop handlers
|
||||
const {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useFileDrop()
|
||||
|
||||
// Folder-specific drag handlers
|
||||
const handleFolderDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder)
|
||||
return
|
||||
handleDragOver(e, { folderId: node.data.id, isFolder: true })
|
||||
}, [isFolder, node.data.id, handleDragOver])
|
||||
|
||||
const handleFolderDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder)
|
||||
return
|
||||
handleDrop(e, node.data.id)
|
||||
}, [isFolder, node.data.id, handleDrop])
|
||||
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDropdown(prev => !prev)
|
||||
@ -60,9 +85,16 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
isSelected && 'bg-state-base-active',
|
||||
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
||||
// Drag over highlight for folders (matching Figma design)
|
||||
isDragOver && 'border border-state-accent-solid bg-state-accent-hover',
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...(isFolder && {
|
||||
onDragOver: handleFolderDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleFolderDrop,
|
||||
})}
|
||||
>
|
||||
<TreeGuideLines level={node.level} />
|
||||
{/* Main content area - isolated click/double-click handling */}
|
||||
|
||||
122
web/app/components/workflow/skill/hooks/use-file-drop.ts
Normal file
122
web/app/components/workflow/skill/hooks/use-file-drop.ts
Normal file
@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useCreateAppAssetFile } from '@/service/use-app-asset'
|
||||
|
||||
type FileDropTarget = {
|
||||
folderId: string | null
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
export function useFileDrop() {
|
||||
const { t } = useTranslation('workflow')
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const storeApi = useWorkflowStore()
|
||||
const createFile = useCreateAppAssetFile()
|
||||
|
||||
const expandTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearExpandTimer = useCallback(() => {
|
||||
if (expandTimerRef.current) {
|
||||
clearTimeout(expandTimerRef.current)
|
||||
expandTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, target: FileDropTarget) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only handle file drops from the system (not internal tree drags)
|
||||
if (!e.dataTransfer.types.includes('Files'))
|
||||
return
|
||||
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
|
||||
storeApi.getState().setDragOverFolderId(target.folderId)
|
||||
|
||||
// Auto-expand closed folder after 2 seconds of hovering
|
||||
if (target.isFolder && target.folderId) {
|
||||
clearExpandTimer()
|
||||
expandTimerRef.current = setTimeout(() => {
|
||||
const expandedFolders = storeApi.getState().expandedFolderIds
|
||||
if (!expandedFolders.has(target.folderId!))
|
||||
storeApi.getState().toggleFolder(target.folderId!)
|
||||
}, 2000)
|
||||
}
|
||||
}, [storeApi, clearExpandTimer])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
clearExpandTimer()
|
||||
storeApi.getState().setDragOverFolderId(null)
|
||||
}, [clearExpandTimer, storeApi])
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent, targetFolderId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
clearExpandTimer()
|
||||
storeApi.getState().setDragOverFolderId(null)
|
||||
|
||||
// Get files from dataTransfer, filter out directories (which have no type)
|
||||
const items = Array.from(e.dataTransfer.items || [])
|
||||
const files: File[] = []
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry?.()
|
||||
// Skip directories - they have isDirectory = true
|
||||
if (entry?.isDirectory) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.folderDropNotSupported'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
if (file)
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0)
|
||||
return
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId: targetFolderId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.filesUploaded', { count: files.length }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
}, [appId, createFile, t, clearExpandTimer, storeApi])
|
||||
|
||||
return {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
isUploading: createFile.isPending,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user