From 2151676db174acafa6c8df0834931f3a3566e871 Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 20 Jan 2026 17:52:18 +0800 Subject: [PATCH] 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 --- .../workflow/skill/file-tree/index.tsx | 60 ++++++++++++- .../workflow/skill/file-tree/tree-node.tsx | 54 ++++++++--- .../skill/hooks/use-folder-file-drop.ts | 4 +- .../workflow/skill/hooks/use-node-move.ts | 90 ++----------------- .../skill/hooks/use-root-file-drop.ts | 4 +- .../workflow/skill/hooks/use-unified-drag.ts | 44 +++------ 6 files changed, 121 insertions(+), 135 deletions(-) diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 85f6c055b4..bae0ecdf18 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -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 = ({ 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>(({ 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 + dragNodes: NodeApi[] + 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) => { return }, [treeChildren]) @@ -259,10 +313,10 @@ const FileTree: React.FC = ({ className }) => { onSelect={handleSelect} onActivate={handleActivate} onRename={handleRename} + onMove={handleMove} searchTerm={searchTerm} searchMatch={searchMatch} - disableDrag - disableDrop + disableDrop={handleDisableDrop} > {renderTreeNode} diff --git a/web/app/components/workflow/skill/file-tree/tree-node.tsx b/web/app/components/workflow/skill/file-tree/tree-node.tsx index 02a61b27be..ed71e357ea 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -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 & { 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 (
s.dragOverFolderId) const isDragOver = isFolder && dragOverFolderId === node.data.id - const { handleDragOver, handleDrop } = useUnifiedDrag({ treeChildren }) + const { handleDragOver, handleDrop } = useUnifiedDrag() const expandTimerRef = useRef(null) const blinkTimerRef = useRef(null) diff --git a/web/app/components/workflow/skill/hooks/use-node-move.ts b/web/app/components/workflow/skill/hooks/use-node-move.ts index d50a2aff1a..ec698e5757 100644 --- a/web/app/components/workflow/skill/hooks/use-node-move.ts +++ b/web/app/components/workflow/skill/hooks/use-node-move.ts @@ -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, } } diff --git a/web/app/components/workflow/skill/hooks/use-root-file-drop.ts b/web/app/components/workflow/skill/hooks/use-root-file-drop.ts index 7ac6284c0f..3f5be0bfcd 100644 --- a/web/app/components/workflow/skill/hooks/use-root-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-root-file-drop.ts @@ -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) => { diff --git a/web/app/components/workflow/skill/hooks/use-unified-drag.ts b/web/app/components/workflow/skill/hooks/use-unified-drag.ts index 54aef387d6..d4f967fb6d 100644 --- a/web/app/components/workflow/skill/hooks/use-unified-drag.ts +++ b/web/app/components/workflow/skill/hooks/use-unified-drag.ts @@ -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, } }