mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 00:18:03 +08:00
refactor: use react-arborist built-in drag for internal node moves
Switch from native HTML5 drag to react-arborist's built-in drag system for internal node drag-and-drop. The HTML5Backend used by react-arborist was intercepting dragstart events, preventing native drag from working. - Add onMove callback and disableDrop validation to Tree component - Sync react-arborist drag state (isDragging, willReceiveDrop) to Zustand - Simplify use-node-move to only handle API execution - Update use-unified-drag to only handle external file uploads - External file drops continue to work via native HTML5 events
This commit is contained in:
@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { NodeApi, NodeRendererProps, TreeApi } from 'react-arborist'
|
||||
import type { MoveHandler, NodeApi, NodeRendererProps, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import type { OpensObject } from '@/app/components/workflow/store/workflow/skill-editor/file-tree-slice'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { RiDragDropLine } from '@remixicon/react'
|
||||
import { useIsMutating } from '@tanstack/react-query'
|
||||
import { useSize } from 'ahooks'
|
||||
@ -17,11 +18,13 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { CONTEXT_MENU_TYPE, ROOT_ID } from '../constants'
|
||||
import { useInlineCreateNode } from '../hooks/use-inline-create-node'
|
||||
import { useNodeMove } from '../hooks/use-node-move'
|
||||
import { usePasteOperation } from '../hooks/use-paste-operation'
|
||||
import { useRootFileDrop } from '../hooks/use-root-file-drop'
|
||||
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
|
||||
import { useSkillShortcuts } from '../hooks/use-skill-shortcuts'
|
||||
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
|
||||
import { isDescendantOf } from '../utils/tree-utils'
|
||||
import ArtifactsSection from './artifacts-section'
|
||||
import DragActionTooltip from './drag-action-tooltip'
|
||||
import TreeContextMenu from './tree-context-menu'
|
||||
@ -145,6 +148,57 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
||||
})
|
||||
}, [storeApi, treeRef])
|
||||
|
||||
// Node move API (for internal drag-drop)
|
||||
const { executeMoveNode } = useNodeMove()
|
||||
|
||||
// react-arborist onMove callback - called when internal drag completes
|
||||
const handleMove = useCallback<MoveHandler<TreeNodeData>>(({ dragIds, parentId }) => {
|
||||
// Only support single node drag for now
|
||||
const nodeId = dragIds[0]
|
||||
if (!nodeId)
|
||||
return
|
||||
// parentId from react-arborist is null for root, otherwise folder ID
|
||||
executeMoveNode(nodeId, parentId)
|
||||
}, [executeMoveNode])
|
||||
|
||||
// react-arborist disableDrop callback - returns true to prevent drop
|
||||
const handleDisableDrop = useCallback((args: {
|
||||
parentNode: NodeApi<TreeNodeData>
|
||||
dragNodes: NodeApi<TreeNodeData>[]
|
||||
index: number
|
||||
}) => {
|
||||
const { dragNodes, parentNode, index } = args
|
||||
|
||||
// 1. Only allow dropping INTO folders (index = 0), not between items
|
||||
// When index is not 0, it means dropping between items (reordering)
|
||||
// We only want to allow dropping over the folder (willReceiveDrop)
|
||||
if (index !== 0)
|
||||
return true
|
||||
|
||||
// 2. Files cannot be drop targets - only folders can receive drops
|
||||
if (parentNode.data.node_type === 'file')
|
||||
return true
|
||||
|
||||
// 3. Cannot drop node into itself
|
||||
const draggedNode = dragNodes[0]
|
||||
if (!draggedNode)
|
||||
return true
|
||||
if (draggedNode.id === parentNode.id)
|
||||
return true
|
||||
|
||||
// 4. Prevent circular move (folder into its descendant)
|
||||
if (draggedNode.data.node_type === 'folder') {
|
||||
const treeChildrenTyped = treeChildren as AppAssetTreeView[]
|
||||
if (isDescendantOf(parentNode.id, draggedNode.id, treeChildrenTyped))
|
||||
return true
|
||||
}
|
||||
|
||||
// Note: We don't prevent dropping to same parent (no-op move)
|
||||
// The API handles this gracefully
|
||||
|
||||
return false
|
||||
}, [treeChildren])
|
||||
|
||||
const renderTreeNode = useCallback((props: NodeRendererProps<TreeNodeData>) => {
|
||||
return <TreeNode {...props} treeChildren={treeChildren} />
|
||||
}, [treeChildren])
|
||||
@ -259,10 +313,10 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
|
||||
onSelect={handleSelect}
|
||||
onActivate={handleActivate}
|
||||
onRename={handleRename}
|
||||
onMove={handleMove}
|
||||
searchTerm={searchTerm}
|
||||
searchMatch={searchMatch}
|
||||
disableDrag
|
||||
disableDrop
|
||||
disableDrop={handleDisableDrop}
|
||||
>
|
||||
{renderTreeNode}
|
||||
</Tree>
|
||||
|
||||
@ -4,18 +4,17 @@ import type { NodeRendererProps } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFolderFileDrop } from '../hooks/use-folder-file-drop'
|
||||
import { useTreeNodeHandlers } from '../hooks/use-tree-node-handlers'
|
||||
import { useUnifiedDrag } from '../hooks/use-unified-drag'
|
||||
import NodeMenu from './node-menu'
|
||||
import TreeEditInput from './tree-edit-input'
|
||||
import TreeGuideLines from './tree-guide-lines'
|
||||
@ -25,7 +24,7 @@ type TreeNodeProps = NodeRendererProps<TreeNodeData> & {
|
||||
treeChildren: TreeNodeData[]
|
||||
}
|
||||
|
||||
const TreeNode = ({ node, style, treeChildren }: TreeNodeProps) => {
|
||||
const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const isFolder = node.data.node_type === 'folder'
|
||||
const isSelected = node.isSelected
|
||||
@ -33,9 +32,42 @@ const TreeNode = ({ node, style, treeChildren }: TreeNodeProps) => {
|
||||
const isCut = useStore(s => s.isCutNode(node.data.id))
|
||||
const contextMenuNodeId = useStore(s => s.contextMenu?.nodeId)
|
||||
const hasContextMenu = contextMenuNodeId === node.data.id
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
// Sync react-arborist drag state to Zustand for DragActionTooltip
|
||||
const prevIsDragging = useRef(node.isDragging)
|
||||
useEffect(() => {
|
||||
// When drag starts
|
||||
if (node.isDragging && !prevIsDragging.current)
|
||||
storeApi.getState().setCurrentDragType('move')
|
||||
|
||||
// When drag ends
|
||||
if (!node.isDragging && prevIsDragging.current) {
|
||||
storeApi.getState().setCurrentDragType(null)
|
||||
storeApi.getState().setDragOverFolderId(null)
|
||||
}
|
||||
prevIsDragging.current = node.isDragging
|
||||
}, [node.isDragging, storeApi])
|
||||
|
||||
// Sync react-arborist willReceiveDrop to Zustand for DragActionTooltip
|
||||
const prevWillReceiveDrop = useRef(node.willReceiveDrop)
|
||||
useEffect(() => {
|
||||
// When willReceiveDrop becomes true, set dragOverFolderId
|
||||
if (isFolder && node.willReceiveDrop && !prevWillReceiveDrop.current)
|
||||
storeApi.getState().setDragOverFolderId(node.data.id)
|
||||
|
||||
// When willReceiveDrop becomes false, clear if this node was the target
|
||||
if (isFolder && !node.willReceiveDrop && prevWillReceiveDrop.current) {
|
||||
const currentDragOverId = storeApi.getState().dragOverFolderId
|
||||
if (currentDragOverId === node.data.id)
|
||||
storeApi.getState().setDragOverFolderId(null)
|
||||
}
|
||||
|
||||
prevWillReceiveDrop.current = node.willReceiveDrop
|
||||
}, [isFolder, node.willReceiveDrop, node.data.id, storeApi])
|
||||
|
||||
const {
|
||||
handleClick,
|
||||
handleDoubleClick,
|
||||
@ -44,13 +76,11 @@ const TreeNode = ({ node, style, treeChildren }: TreeNodeProps) => {
|
||||
handleKeyDown,
|
||||
} = useTreeNodeHandlers({ node })
|
||||
|
||||
const { isDragOver, isBlinking, dragHandlers } = useFolderFileDrop({ node, treeChildren })
|
||||
const { handleNodeDragStart, handleNodeDragEnd } = useUnifiedDrag({ treeChildren })
|
||||
// Get file drop visual state (for external file uploads)
|
||||
const { isDragOver: isFileDragOver, isBlinking, dragHandlers } = useFolderFileDrop({ node, treeChildren })
|
||||
|
||||
// Currently only supports single node drag
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
handleNodeDragStart(e, node.data.id)
|
||||
}, [handleNodeDragStart, node.data.id])
|
||||
// Combine internal drag target (willReceiveDrop) with external file drag (isFileDragOver)
|
||||
const isDragOver = isFileDragOver || (isFolder && node.willReceiveDrop)
|
||||
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@ -59,14 +89,12 @@ const TreeNode = ({ node, style, treeChildren }: TreeNodeProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
draggable={true}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleNodeDragEnd}
|
||||
className={cn(
|
||||
'group relative flex h-6 cursor-pointer items-center rounded-md px-2',
|
||||
'hover:bg-state-base-hover',
|
||||
|
||||
@ -30,12 +30,12 @@ type UseFolderFileDropOptions = {
|
||||
treeChildren: AppAssetTreeView[]
|
||||
}
|
||||
|
||||
export function useFolderFileDrop({ node, treeChildren }: UseFolderFileDropOptions): UseFolderFileDropReturn {
|
||||
export function useFolderFileDrop({ node, treeChildren: _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 } = useUnifiedDrag({ treeChildren })
|
||||
const { handleDragOver, handleDrop } = useUnifiedDrag()
|
||||
|
||||
const expandTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const blinkTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
@ -1,93 +1,23 @@
|
||||
'use client'
|
||||
|
||||
// Internal tree node move handler (drag-and-drop within tree)
|
||||
// Internal tree node move handler - API execution logic only
|
||||
// Drag state syncing is handled by react-arborist + TreeNode useEffect
|
||||
|
||||
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'
|
||||
import { toApiParentId } from '../utils/tree-utils'
|
||||
|
||||
type NodeMoveTarget = {
|
||||
folderId: string | null
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
type UseNodeMoveOptions = {
|
||||
treeChildren: AppAssetTreeView[]
|
||||
}
|
||||
|
||||
export function useNodeMove({ treeChildren }: UseNodeMoveOptions) {
|
||||
export function useNodeMove() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Execute move API call - validation is handled by react-arborist's disableDrop callback
|
||||
const executeMoveNode = useCallback(async (nodeId: string, targetFolderId: string | null) => {
|
||||
try {
|
||||
await moveNode.mutateAsync({
|
||||
appId,
|
||||
@ -106,14 +36,10 @@ export function useNodeMove({ treeChildren }: UseNodeMoveOptions) {
|
||||
message: t('skillSidebar.menu.moveError'),
|
||||
})
|
||||
}
|
||||
}, [appId, moveNode, t, storeApi, treeChildren])
|
||||
}, [appId, moveNode, t])
|
||||
|
||||
return {
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
executeMoveNode,
|
||||
isMoving: moveNode.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,8 +19,8 @@ type UseRootFileDropOptions = {
|
||||
treeChildren: AppAssetTreeView[]
|
||||
}
|
||||
|
||||
export function useRootFileDrop({ treeChildren }: UseRootFileDropOptions): UseRootFileDropReturn {
|
||||
const { handleDragOver, handleDragLeave, handleDrop } = useUnifiedDrag({ treeChildren })
|
||||
export function useRootFileDrop({ treeChildren: _treeChildren }: UseRootFileDropOptions): UseRootFileDropReturn {
|
||||
const { handleDragOver, handleDragLeave, handleDrop } = useUnifiedDrag()
|
||||
const dragCounterRef = useRef(0)
|
||||
|
||||
const handleRootDragEnter = useCallback((e: React.DragEvent) => {
|
||||
|
||||
@ -1,62 +1,40 @@
|
||||
'use client'
|
||||
|
||||
// Unified drag handler that routes to file upload or node move based on drag type
|
||||
// Unified drag handler for external file uploads
|
||||
// Internal node drag-move is now handled by react-arborist's built-in drag system
|
||||
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { useCallback } from 'react'
|
||||
import { getDragActionType, isFileDrag, isNodeDrag } from '../utils/drag-utils'
|
||||
import { isFileDrag } 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) {
|
||||
export function useUnifiedDrag() {
|
||||
const fileDrop = useFileDrop()
|
||||
const nodeMove = useNodeMove({ treeChildren })
|
||||
|
||||
// Only handle external file drags - internal node drags are handled by react-arborist
|
||||
const handleDragOver = useCallback((e: React.DragEvent, target: DragTarget) => {
|
||||
const actionType = getDragActionType(e)
|
||||
if (actionType === 'upload') {
|
||||
if (isFileDrag(e))
|
||||
fileDrop.handleDragOver(e, target)
|
||||
}
|
||||
else if (actionType === 'move') {
|
||||
nodeMove.handleDragOver(e, target)
|
||||
}
|
||||
}, [fileDrop, nodeMove])
|
||||
}, [fileDrop])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (isFileDrag(e)) {
|
||||
if (isFileDrag(e))
|
||||
fileDrop.handleDragLeave(e)
|
||||
}
|
||||
else if (isNodeDrag(e)) {
|
||||
nodeMove.handleDragLeave(e)
|
||||
}
|
||||
}, [fileDrop, nodeMove])
|
||||
}, [fileDrop])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetFolderId: string | null) => {
|
||||
if (isFileDrag(e)) {
|
||||
if (isFileDrag(e))
|
||||
return fileDrop.handleDrop(e, targetFolderId)
|
||||
}
|
||||
else if (isNodeDrag(e)) {
|
||||
return nodeMove.handleDrop(e, targetFolderId)
|
||||
}
|
||||
}, [fileDrop, nodeMove])
|
||||
}, [fileDrop])
|
||||
|
||||
return {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
// Expose individual handlers for specific needs
|
||||
handleNodeDragStart: nodeMove.handleDragStart,
|
||||
handleNodeDragEnd: nodeMove.handleDragEnd,
|
||||
isUploading: fileDrop.isUploading,
|
||||
isMoving: nodeMove.isMoving,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user