feat: unified drag-and-drop for skill file tree

Implement unified drag system that supports both internal node moves
and external file uploads with consistent UI feedback. Uses native
HTML5 drag API with shared visual states (isDragOver, isBlinking,
DragActionTooltip showing 'Move to' or 'Upload to').
This commit is contained in:
yyh
2026-01-20 16:27:05 +08:00
parent 0e66b51ca0
commit b527921f3f
14 changed files with 297 additions and 34 deletions

View File

@ -34,6 +34,7 @@ export function useFileDrop() {
e.dataTransfer.dropEffect = 'copy'
// Use ROOT_ID to indicate dragging over root (to distinguish from null = "not dragging")
storeApi.getState().setCurrentDragType('upload')
storeApi.getState().setDragOverFolderId(target.folderId ?? ROOT_ID)
}, [storeApi])
@ -41,6 +42,7 @@ export function useFileDrop() {
e.preventDefault()
e.stopPropagation()
storeApi.getState().setCurrentDragType(null)
storeApi.getState().setDragOverFolderId(null)
}, [storeApi])
@ -48,6 +50,7 @@ export function useFileDrop() {
e.preventDefault()
e.stopPropagation()
storeApi.getState().setCurrentDragType(null)
storeApi.getState().setDragOverFolderId(null)
// Get files from dataTransfer, filter out directories (which have no type)

View File

@ -4,10 +4,11 @@
import type { NodeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import type { AppAssetTreeView } from '@/types/app-asset'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useStore } from '@/app/components/workflow/store'
import { isFileDrag } from '../utils/drag-utils'
import { useFileDrop } from './use-file-drop'
import { isDragEvent } from '../utils/drag-utils'
import { useUnifiedDrag } from './use-unified-drag'
type UseFolderFileDropReturn = {
isDragOver: boolean
@ -24,12 +25,17 @@ type UseFolderFileDropReturn = {
const BLINK_START_DELAY_MS = 1000
const AUTO_EXPAND_DELAY_MS = 2000
export function useFolderFileDrop(node: NodeApi<TreeNodeData>): UseFolderFileDropReturn {
type UseFolderFileDropOptions = {
node: NodeApi<TreeNodeData>
treeChildren: AppAssetTreeView[]
}
export function useFolderFileDrop({ node, treeChildren }: UseFolderFileDropOptions): 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 { handleDragOver, handleDrop } = useUnifiedDrag({ treeChildren })
const expandTimerRef = useRef<NodeJS.Timeout | null>(null)
const blinkTimerRef = useRef<NodeJS.Timeout | null>(null)
@ -80,7 +86,7 @@ export function useFolderFileDrop(node: NodeApi<TreeNodeData>): UseFolderFileDro
}, [clearExpandTimer])
const handleFolderDragEnter = useCallback((e: React.DragEvent) => {
if (!isFolder || !isFileDrag(e))
if (!isFolder || !isDragEvent(e))
return
dragCounterRef.current += 1
if (dragCounterRef.current === 1)
@ -88,13 +94,13 @@ export function useFolderFileDrop(node: NodeApi<TreeNodeData>): UseFolderFileDro
}, [isFolder, scheduleAutoExpand])
const handleFolderDragOver = useCallback((e: React.DragEvent) => {
if (!isFolder || !isFileDrag(e))
if (!isFolder || !isDragEvent(e))
return
handleDragOver(e, { folderId: node.data.id, isFolder: true })
}, [handleDragOver, isFolder, node.data.id])
const handleFolderDragLeave = useCallback((e: React.DragEvent) => {
if (!isFolder || !isFileDrag(e))
if (!isFolder || !isDragEvent(e))
return
dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0)
if (dragCounterRef.current === 0)

View File

@ -0,0 +1,119 @@
'use client'
// Internal tree node move handler (drag-and-drop within tree)
import type { AppAssetTreeView } from '@/types/app-asset'
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'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useMoveAppAssetNode } from '@/service/use-app-asset'
import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../constants'
import { findNodeById, isDescendantOf, toApiParentId } from '../utils/tree-utils'
type NodeMoveTarget = {
folderId: string | null
isFolder: boolean
}
type UseNodeMoveOptions = {
treeChildren: AppAssetTreeView[]
}
export function useNodeMove({ treeChildren }: UseNodeMoveOptions) {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const storeApi = useWorkflowStore()
const moveNode = useMoveAppAssetNode()
const handleDragStart = useCallback((e: React.DragEvent, nodeId: string) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(INTERNAL_NODE_DRAG_TYPE, nodeId)
storeApi.getState().setCurrentDragType('move')
}, [storeApi])
const handleDragEnd = useCallback(() => {
storeApi.getState().setCurrentDragType(null)
storeApi.getState().setDragOverFolderId(null)
}, [storeApi])
const handleDragOver = useCallback((e: React.DragEvent, target: NodeMoveTarget) => {
e.preventDefault()
e.stopPropagation()
if (!e.dataTransfer.types.includes(INTERNAL_NODE_DRAG_TYPE))
return
e.dataTransfer.dropEffect = 'move'
storeApi.getState().setDragOverFolderId(target.folderId ?? ROOT_ID)
}, [storeApi])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
storeApi.getState().setDragOverFolderId(null)
}, [storeApi])
const handleDrop = useCallback(async (e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault()
e.stopPropagation()
storeApi.getState().setDragOverFolderId(null)
storeApi.getState().setCurrentDragType(null)
const nodeId = e.dataTransfer.getData(INTERNAL_NODE_DRAG_TYPE)
if (!nodeId)
return
// Prevent dropping node into itself
if (nodeId === targetFolderId) {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.cannotMoveToSelf'),
})
return
}
// Prevent circular move (dropping folder into its descendant)
const draggedNode = findNodeById(treeChildren, nodeId)
if (draggedNode?.node_type === 'folder' && targetFolderId) {
if (isDescendantOf(targetFolderId, nodeId, treeChildren)) {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.cannotMoveToDescendant'),
})
return
}
}
try {
await moveNode.mutateAsync({
appId,
nodeId,
payload: { parent_id: toApiParentId(targetFolderId) },
})
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.moved'),
})
}
catch {
Toast.notify({
type: 'error',
message: t('skillSidebar.menu.moveError'),
})
}
}, [appId, moveNode, t, storeApi, treeChildren])
return {
handleDragStart,
handleDragEnd,
handleDragOver,
handleDragLeave,
handleDrop,
isMoving: moveNode.isPending,
}
}

View File

@ -2,9 +2,10 @@
// Root-level file drop handler with drag counter to handle nested DOM events
import type { AppAssetTreeView } from '@/types/app-asset'
import { useCallback, useRef } from 'react'
import { isFileDrag } from '../utils/drag-utils'
import { useFileDrop } from './use-file-drop'
import { isDragEvent } from '../utils/drag-utils'
import { useUnifiedDrag } from './use-unified-drag'
type UseRootFileDropReturn = {
handleRootDragEnter: (e: React.DragEvent) => void
@ -14,12 +15,16 @@ type UseRootFileDropReturn = {
resetRootDragCounter: () => void
}
export function useRootFileDrop(): UseRootFileDropReturn {
const { handleDragOver, handleDragLeave, handleDrop } = useFileDrop()
type UseRootFileDropOptions = {
treeChildren: AppAssetTreeView[]
}
export function useRootFileDrop({ treeChildren }: UseRootFileDropOptions): UseRootFileDropReturn {
const { handleDragOver, handleDragLeave, handleDrop } = useUnifiedDrag({ treeChildren })
const dragCounterRef = useRef(0)
const handleRootDragEnter = useCallback((e: React.DragEvent) => {
if (!isFileDrag(e))
if (!isDragEvent(e))
return
dragCounterRef.current += 1
}, [])
@ -29,7 +34,7 @@ export function useRootFileDrop(): UseRootFileDropReturn {
}, [handleDragOver])
const handleRootDragLeave = useCallback((e: React.DragEvent) => {
if (!isFileDrag(e))
if (!isDragEvent(e))
return
dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0)
if (dragCounterRef.current === 0)

View File

@ -0,0 +1,62 @@
'use client'
// Unified drag handler that routes to file upload or node move based on drag type
import type { AppAssetTreeView } from '@/types/app-asset'
import { useCallback } from 'react'
import { getDragActionType, isFileDrag, isNodeDrag } from '../utils/drag-utils'
import { useFileDrop } from './use-file-drop'
import { useNodeMove } from './use-node-move'
type DragTarget = {
folderId: string | null
isFolder: boolean
}
type UseUnifiedDragOptions = {
treeChildren: AppAssetTreeView[]
}
export function useUnifiedDrag({ treeChildren }: UseUnifiedDragOptions) {
const fileDrop = useFileDrop()
const nodeMove = useNodeMove({ treeChildren })
const handleDragOver = useCallback((e: React.DragEvent, target: DragTarget) => {
const actionType = getDragActionType(e)
if (actionType === 'upload') {
fileDrop.handleDragOver(e, target)
}
else if (actionType === 'move') {
nodeMove.handleDragOver(e, target)
}
}, [fileDrop, nodeMove])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (isFileDrag(e)) {
fileDrop.handleDragLeave(e)
}
else if (isNodeDrag(e)) {
nodeMove.handleDragLeave(e)
}
}, [fileDrop, nodeMove])
const handleDrop = useCallback((e: React.DragEvent, targetFolderId: string | null) => {
if (isFileDrag(e)) {
return fileDrop.handleDrop(e, targetFolderId)
}
else if (isNodeDrag(e)) {
return nodeMove.handleDrop(e, targetFolderId)
}
}, [fileDrop, nodeMove])
return {
handleDragOver,
handleDragLeave,
handleDrop,
// Expose individual handlers for specific needs
handleNodeDragStart: nodeMove.handleDragStart,
handleNodeDragEnd: nodeMove.handleDragEnd,
isUploading: fileDrop.isUploading,
isMoving: nodeMove.isMoving,
}
}