mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
refactor file drop handlers into hooks
This commit is contained in:
@ -7,14 +7,14 @@ import { RiDragDropLine } from '@remixicon/react'
|
||||
import { useIsMutating } from '@tanstack/react-query'
|
||||
import { useSize } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Tree } from 'react-arborist'
|
||||
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 { useRootFileDrop } from '../hooks/use-root-file-drop'
|
||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
||||
import TreeContextMenu from './tree-context-menu'
|
||||
@ -48,27 +48,28 @@ 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])
|
||||
handleRootDragEnter,
|
||||
handleRootDragLeave,
|
||||
handleRootDragOver,
|
||||
handleRootDrop,
|
||||
resetRootDragCounter,
|
||||
} = useRootFileDrop()
|
||||
|
||||
const expandedFolderIds = useStore(s => s.expandedFolderIds)
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const selectedTreeNodeId = useStore(s => s.selectedTreeNodeId)
|
||||
const dragOverFolderId = useStore(s => s.dragOverFolderId)
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
// Root dropzone highlight (when dragging to root, not to a specific folder)
|
||||
const isRootDropzone = dragOverFolderId === '__root__'
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragOverFolderId)
|
||||
resetRootDragCounter()
|
||||
}, [dragOverFolderId, resetRootDragCounter])
|
||||
|
||||
const treeChildren = treeData?.children ?? emptyTreeNodes
|
||||
const {
|
||||
treeNodes,
|
||||
@ -163,11 +164,16 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1',
|
||||
// Root dropzone highlight - dashed border without layout shift
|
||||
isRootDropzone && 'relative rounded-lg bg-state-accent-hover after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:border-[1.5px] after:border-dashed after:border-state-accent-solid after:content-[\'\']',
|
||||
)}
|
||||
onContextMenu={handleBlankAreaContextMenu}
|
||||
onDragOver={handleContainerDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleContainerDrop}
|
||||
onDragEnter={handleRootDragEnter}
|
||||
onDragOver={handleRootDragOver}
|
||||
onDragLeave={handleRootDragLeave}
|
||||
onDrop={handleRootDrop}
|
||||
>
|
||||
<Tree<TreeNodeData>
|
||||
ref={treeRef}
|
||||
|
||||
@ -15,7 +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 { useFolderFileDrop } from '../hooks/use-folder-file-drop'
|
||||
import { useTreeNodeHandlers } from '../hooks/use-tree-node-handlers'
|
||||
import { getFileIconType } from '../utils/file-utils'
|
||||
import NodeMenu from './node-menu'
|
||||
@ -30,10 +30,6 @@ 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
|
||||
@ -46,25 +42,7 @@ 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 { isDragOver, dragHandlers } = useFolderFileDrop(node)
|
||||
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@ -85,15 +63,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',
|
||||
// Drag over highlight for folders - use ring instead of border to avoid layout shift
|
||||
isDragOver && 'bg-state-accent-hover ring-1 ring-inset ring-state-accent-solid',
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...(isFolder && {
|
||||
onDragOver: handleFolderDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleFolderDrop,
|
||||
onDragEnter: dragHandlers.onDragEnter,
|
||||
onDragOver: dragHandlers.onDragOver,
|
||||
onDrop: dragHandlers.onDrop,
|
||||
onDragLeave: dragHandlers.onDragLeave,
|
||||
})}
|
||||
>
|
||||
<TreeGuideLines level={node.level} />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@ -19,15 +19,6 @@ export function useFileDrop() {
|
||||
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()
|
||||
@ -38,32 +29,21 @@ export function useFileDrop() {
|
||||
|
||||
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])
|
||||
// Use '__root__' to indicate dragging over root (to distinguish from "not dragging")
|
||||
storeApi.getState().setDragOverFolderId(target.folderId ?? '__root__')
|
||||
}, [storeApi])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
clearExpandTimer()
|
||||
storeApi.getState().setDragOverFolderId(null)
|
||||
}, [clearExpandTimer, storeApi])
|
||||
}, [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)
|
||||
@ -111,7 +91,7 @@ export function useFileDrop() {
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
}, [appId, createFile, t, clearExpandTimer, storeApi])
|
||||
}, [appId, createFile, t, storeApi])
|
||||
|
||||
return {
|
||||
handleDragOver,
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import type { NodeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { isFileDrag } from '../utils/drag-utils'
|
||||
import { useFileDrop } from './use-file-drop'
|
||||
|
||||
type UseFolderFileDropReturn = {
|
||||
isDragOver: boolean
|
||||
dragHandlers: {
|
||||
onDragEnter: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_EXPAND_DELAY_MS = 2000
|
||||
|
||||
export function useFolderFileDrop(node: NodeApi<TreeNodeData>): UseFolderFileDropReturn {
|
||||
const isFolder = node.data.node_type === 'folder'
|
||||
const dragOverFolderId = useStore(s => s.dragOverFolderId)
|
||||
const isDragOver = isFolder && dragOverFolderId === node.data.id
|
||||
|
||||
const { handleDragOver, handleDrop } = useFileDrop()
|
||||
|
||||
const expandTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
|
||||
const clearExpandTimer = useCallback(() => {
|
||||
if (expandTimerRef.current) {
|
||||
clearTimeout(expandTimerRef.current)
|
||||
expandTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleAutoExpand = useCallback(() => {
|
||||
if (!isFolder || node.isOpen)
|
||||
return
|
||||
clearExpandTimer()
|
||||
expandTimerRef.current = setTimeout(() => {
|
||||
expandTimerRef.current = null
|
||||
if (!node.isOpen)
|
||||
node.open()
|
||||
}, AUTO_EXPAND_DELAY_MS)
|
||||
}, [clearExpandTimer, isFolder, node])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearExpandTimer()
|
||||
}
|
||||
}, [clearExpandTimer])
|
||||
|
||||
const handleFolderDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder || !isFileDrag(e))
|
||||
return
|
||||
dragCounterRef.current += 1
|
||||
if (dragCounterRef.current === 1)
|
||||
scheduleAutoExpand()
|
||||
}, [isFolder, scheduleAutoExpand])
|
||||
|
||||
const handleFolderDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder)
|
||||
return
|
||||
handleDragOver(e, { folderId: node.data.id, isFolder: true })
|
||||
}, [handleDragOver, isFolder, node.data.id])
|
||||
|
||||
const handleFolderDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder || !isFileDrag(e))
|
||||
return
|
||||
dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0)
|
||||
if (dragCounterRef.current === 0)
|
||||
clearExpandTimer()
|
||||
}, [clearExpandTimer, isFolder])
|
||||
|
||||
const handleFolderDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!isFolder)
|
||||
return
|
||||
dragCounterRef.current = 0
|
||||
clearExpandTimer()
|
||||
handleDrop(e, node.data.id)
|
||||
}, [clearExpandTimer, handleDrop, isFolder, node.data.id])
|
||||
|
||||
const dragHandlers = useMemo(() => {
|
||||
return {
|
||||
onDragEnter: handleFolderDragEnter,
|
||||
onDragOver: handleFolderDragOver,
|
||||
onDragLeave: handleFolderDragLeave,
|
||||
onDrop: handleFolderDrop,
|
||||
}
|
||||
}, [handleFolderDragEnter, handleFolderDragLeave, handleFolderDragOver, handleFolderDrop])
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dragHandlers,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { isFileDrag } from '../utils/drag-utils'
|
||||
import { useFileDrop } from './use-file-drop'
|
||||
|
||||
type UseRootFileDropReturn = {
|
||||
handleRootDragEnter: (e: React.DragEvent) => void
|
||||
handleRootDragOver: (e: React.DragEvent) => void
|
||||
handleRootDragLeave: (e: React.DragEvent) => void
|
||||
handleRootDrop: (e: React.DragEvent) => void
|
||||
resetRootDragCounter: () => void
|
||||
}
|
||||
|
||||
export function useRootFileDrop(): UseRootFileDropReturn {
|
||||
const { handleDragOver, handleDragLeave, handleDrop } = useFileDrop()
|
||||
const dragCounterRef = useRef(0)
|
||||
|
||||
const handleRootDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (!isFileDrag(e))
|
||||
return
|
||||
dragCounterRef.current += 1
|
||||
}, [])
|
||||
|
||||
const handleRootDragOver = useCallback((e: React.DragEvent) => {
|
||||
handleDragOver(e, { folderId: null, isFolder: false })
|
||||
}, [handleDragOver])
|
||||
|
||||
const handleRootDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!isFileDrag(e))
|
||||
return
|
||||
dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0)
|
||||
if (dragCounterRef.current === 0)
|
||||
handleDragLeave(e)
|
||||
}, [handleDragLeave])
|
||||
|
||||
const handleRootDrop = useCallback((e: React.DragEvent) => {
|
||||
dragCounterRef.current = 0
|
||||
handleDrop(e, null)
|
||||
}, [handleDrop])
|
||||
|
||||
const resetRootDragCounter = useCallback(() => {
|
||||
dragCounterRef.current = 0
|
||||
}, [])
|
||||
|
||||
return {
|
||||
handleRootDragEnter,
|
||||
handleRootDragOver,
|
||||
handleRootDragLeave,
|
||||
handleRootDrop,
|
||||
resetRootDragCounter,
|
||||
}
|
||||
}
|
||||
5
web/app/components/workflow/skill/utils/drag-utils.ts
Normal file
5
web/app/components/workflow/skill/utils/drag-utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
export const isFileDrag = (e: React.DragEvent): boolean => {
|
||||
return e.dataTransfer.types.includes('Files')
|
||||
}
|
||||
Reference in New Issue
Block a user