diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx index d0ce007f61..a31528d00c 100644 --- a/web/app/components/workflow/shortcuts-name.tsx +++ b/web/app/components/workflow/shortcuts-name.tsx @@ -3,7 +3,7 @@ import { cn } from '@/utils/classnames' import { getKeyboardKeyNameBySystem } from './utils' type ShortcutsNameProps = { - keys: string[] + keys: readonly string[] className?: string textColor?: 'default' | 'secondary' } diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 5e24ff19de..7a3e811cbb 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -17,6 +17,7 @@ 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 { 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' @@ -127,18 +128,20 @@ const FileTree: React.FC = ({ className }) => { }, [storeApi]) const handleBlankAreaClick = useCallback(() => { + treeRef.current?.deselectAll() storeApi.getState().clearSelection() - }, [storeApi]) + }, [storeApi, treeRef]) const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault() + treeRef.current?.deselectAll() storeApi.getState().clearSelection() storeApi.getState().setContextMenu({ top: e.clientY, left: e.clientX, type: CONTEXT_MENU_TYPE.BLANK, }) - }, [storeApi]) + }, [storeApi, treeRef]) useSyncTreeWithActiveTab({ treeRef, @@ -147,6 +150,11 @@ const FileTree: React.FC = ({ className }) => { useSkillShortcuts({ treeRef }) + usePasteOperation({ + treeRef, + treeData: treeData ?? undefined, + }) + if (isLoading) { return (
@@ -208,6 +216,7 @@ const FileTree: React.FC = ({ className }) => { return ( <>
disabled?: boolean } & VariantProps diff --git a/web/app/components/workflow/skill/file-tree/node-menu.tsx b/web/app/components/workflow/skill/file-tree/node-menu.tsx index 911c9b566e..ba9cb802d1 100644 --- a/web/app/components/workflow/skill/file-tree/node-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/node-menu.tsx @@ -31,6 +31,10 @@ export const MENU_CONTAINER_STYLES = [ 'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]', ] as const +const KBD_COPY = ['ctrl', 'c'] as const +const KBD_CUT = ['ctrl', 'x'] as const +const KBD_PASTE = ['ctrl', 'v'] as const + type NodeMenuProps = { type: NodeMenuType nodeId?: string @@ -176,14 +180,14 @@ const NodeMenu: FC = ({ @@ -194,7 +198,7 @@ const NodeMenu: FC = ({ diff --git a/web/app/components/workflow/skill/hooks/use-paste-operation.ts b/web/app/components/workflow/skill/hooks/use-paste-operation.ts new file mode 100644 index 0000000000..bfba76ecbb --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-paste-operation.ts @@ -0,0 +1,127 @@ +'use client' + +import type { RefObject } from 'react' +import type { TreeApi } from 'react-arborist' +import type { TreeNodeData } from '../type' +import type { AppAssetTreeResponse } from '@/types/app-asset' +import { useCallback, useEffect, useRef } 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 { findNodeById, getTargetFolderIdFromSelection, toApiParentId } from '../utils/tree-utils' + +type UsePasteOperationOptions = { + treeRef: RefObject | null> + treeData?: AppAssetTreeResponse + enabled?: boolean +} + +type UsePasteOperationReturn = { + isPasting: boolean + handlePaste: () => void +} + +export function usePasteOperation({ + treeRef, + treeData, + enabled = true, +}: UsePasteOperationOptions): UsePasteOperationReturn { + const { t } = useTranslation('workflow') + const storeApi = useWorkflowStore() + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + const moveNode = useMoveAppAssetNode() + const isPastingRef = useRef(false) + + const handlePaste = useCallback(async () => { + if (isPastingRef.current) + return + + const clipboard = storeApi.getState().clipboard + if (!clipboard || clipboard.nodeIds.size === 0) + return + + const { operation, nodeIds } = clipboard + const tree = treeRef.current + const treeChildren = treeData?.children ?? [] + + const selectedId = tree?.selectedNodes[0]?.id ?? storeApi.getState().selectedTreeNodeId + const targetFolderId = getTargetFolderIdFromSelection(selectedId, treeChildren) + const targetParentId = toApiParentId(targetFolderId) + + if (operation === 'cut') { + const nodeIdsArray = [...nodeIds] + const isMovingToSelf = nodeIdsArray.some((nodeId) => { + const node = findNodeById(treeChildren, nodeId) + if (!node) + return false + if (node.node_type === 'folder' && nodeId === targetFolderId) + return true + return false + }) + + if (isMovingToSelf) { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.cannotMoveToSelf'), + }) + return + } + + isPastingRef.current = true + + try { + for (const nodeId of nodeIdsArray) { + await moveNode.mutateAsync({ + appId, + nodeId, + payload: { parent_id: targetParentId }, + }) + } + + storeApi.getState().clearClipboard() + + Toast.notify({ + type: 'success', + message: t('skillSidebar.menu.moved'), + }) + } + catch { + Toast.notify({ + type: 'error', + message: t('skillSidebar.menu.moveError'), + }) + } + finally { + isPastingRef.current = false + } + } + else { + Toast.notify({ + type: 'info', + message: t('skillSidebar.menu.copyNotSupported'), + }) + } + }, [appId, moveNode, storeApi, t, treeData?.children, treeRef]) + + useEffect(() => { + if (!enabled) + return + + const handlePasteEvent = () => { + handlePaste() + } + + window.addEventListener('skill:paste', handlePasteEvent) + return () => { + window.removeEventListener('skill:paste', handlePasteEvent) + } + }, [enabled, handlePaste]) + + return { + isPasting: moveNode.isPending, + handlePaste, + } +} diff --git a/web/app/components/workflow/skill/hooks/use-skill-shortcuts.ts b/web/app/components/workflow/skill/hooks/use-skill-shortcuts.ts index 9c5f83ea4c..bdb741c4d3 100644 --- a/web/app/components/workflow/skill/hooks/use-skill-shortcuts.ts +++ b/web/app/components/workflow/skill/hooks/use-skill-shortcuts.ts @@ -16,19 +16,28 @@ type UseSkillShortcutsOptions = { enabled?: boolean } +const TREE_CONTAINER_SELECTOR = '[data-skill-tree-container]' + export function useSkillShortcuts({ treeRef, enabled = true, }: UseSkillShortcutsOptions): void { const storeApi = useWorkflowStore() const enabledRef = useRef(enabled) - useEffect(() => { enabledRef.current = enabled }, [enabled]) + useEffect(() => { + enabledRef.current = enabled + }, [enabled]) const shouldHandle = useCallback((e: KeyboardEvent) => { if (!enabledRef.current) return false - return !isEventTargetInputArea(e.target as HTMLElement) - }, []) + if (isEventTargetInputArea(e.target as HTMLElement)) + return false + const target = e.target as HTMLElement + const isInTreeContainer = target.closest(TREE_CONTAINER_SELECTOR) !== null + const hasSelection = (treeRef.current?.selectedNodes.length ?? 0) > 0 + return isInTreeContainer || hasSelection + }, [treeRef]) const getSelectedNodeIds = useCallback(() => { const tree = treeRef.current diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 57e71ebaba..bf81a1607f 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1048,6 +1048,10 @@ "skillSidebar.menu.newFolder": "New Folder", "skillSidebar.menu.newFolderPrompt": "Enter folder name:", "skillSidebar.menu.paste": "Paste", + "skillSidebar.menu.moved": "Moved successfully", + "skillSidebar.menu.moveError": "Failed to move", + "skillSidebar.menu.cannotMoveToSelf": "Cannot move a folder into itself", + "skillSidebar.menu.copyNotSupported": "Copy is not supported yet", "skillSidebar.menu.rename": "Rename", "skillSidebar.menu.renameError": "Failed to rename", "skillSidebar.menu.renamed": "Renamed successfully", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 468e36df19..f0c47b410c 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1039,6 +1039,10 @@ "skillSidebar.menu.newFolder": "新建文件夹", "skillSidebar.menu.newFolderPrompt": "请输入文件夹名称:", "skillSidebar.menu.paste": "粘贴", + "skillSidebar.menu.moved": "移动成功", + "skillSidebar.menu.moveError": "移动失败", + "skillSidebar.menu.cannotMoveToSelf": "无法将文件夹移动到自身内部", + "skillSidebar.menu.copyNotSupported": "暂不支持复制功能", "skillSidebar.menu.rename": "重命名", "skillSidebar.menu.renameError": "重命名失败", "skillSidebar.menu.renamed": "重命名成功",