refactor file drop handlers into hooks

This commit is contained in:
yyh
2026-01-19 17:57:57 +08:00
parent a432fa5fcf
commit 144ca11c03
6 changed files with 197 additions and 75 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import type * as React from 'react'
export const isFileDrag = (e: React.DragEvent): boolean => {
return e.dataTransfer.types.includes('Files')
}