diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 8023e24b3b..1700e01a76 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -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 = ({ 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 = ({ className, searchTerm = '' }) => { >
ref={treeRef} 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 9a5257dbb8..fecd25c290 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -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) 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) 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) '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, })} > diff --git a/web/app/components/workflow/skill/hooks/use-file-drop.ts b/web/app/components/workflow/skill/hooks/use-file-drop.ts index 6bedadbff1..511b39b050 100644 --- a/web/app/components/workflow/skill/hooks/use-file-drop.ts +++ b/web/app/components/workflow/skill/hooks/use-file-drop.ts @@ -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(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, diff --git a/web/app/components/workflow/skill/hooks/use-folder-file-drop.ts b/web/app/components/workflow/skill/hooks/use-folder-file-drop.ts new file mode 100644 index 0000000000..fa0cc79d74 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-folder-file-drop.ts @@ -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): 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(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, + } +} 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 new file mode 100644 index 0000000000..e2e9a31bbc --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-root-file-drop.ts @@ -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, + } +} diff --git a/web/app/components/workflow/skill/utils/drag-utils.ts b/web/app/components/workflow/skill/utils/drag-utils.ts new file mode 100644 index 0000000000..f8db5df7ed --- /dev/null +++ b/web/app/components/workflow/skill/utils/drag-utils.ts @@ -0,0 +1,5 @@ +import type * as React from 'react' + +export const isFileDrag = (e: React.DragEvent): boolean => { + return e.dataTransfer.types.includes('Files') +}