feat(skill-editor): add blank area context menu and align search/add styles

Add right-click context menu for file tree blank area with New File,
New Folder, and Upload Files options. Also align search input and
add button styles to match Figma design specs (24px height, 6px radius).
This commit is contained in:
yyh
2026-01-19 11:38:59 +08:00
parent 5f707c5585
commit 021f055c36
6 changed files with 125 additions and 23 deletions

View File

@ -0,0 +1,74 @@
'use client'
import type { FC } from 'react'
import {
RiFileAddLine,
RiFolderAddLine,
RiUploadLine,
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { useFileOperations } from '../hooks/use-file-operations'
import MenuItem from './menu-item'
type BlankAreaMenuProps = {
onClose: () => void
className?: string
}
const BlankAreaMenu: FC<BlankAreaMenuProps> = ({
onClose,
className,
}) => {
const { t } = useTranslation('workflow')
const {
fileInputRef,
isLoading,
handleNewFile,
handleNewFolder,
handleFileChange,
} = useFileOperations({ nodeId: 'root', onClose })
return (
<div className={cn(
'min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border',
'bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]',
className,
)}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<MenuItem
icon={RiFileAddLine}
label={t('skillSidebar.menu.newFile')}
onClick={handleNewFile}
disabled={isLoading}
/>
<MenuItem
icon={RiFolderAddLine}
label={t('skillSidebar.menu.newFolder')}
onClick={handleNewFolder}
disabled={isLoading}
/>
<div className="my-1 h-px bg-divider-subtle" />
<MenuItem
icon={RiUploadLine}
label={t('skillSidebar.menu.uploadFile')}
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
/>
</div>
)
}
export default React.memo(BlankAreaMenu)

View File

@ -91,6 +91,15 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
})
}, [appId, renameNode, t])
const handleBlankAreaContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
type: 'blank',
})
}, [storeApi])
const searchMatch = useCallback(
(node: NodeApi<TreeNodeData>, term: string) => {
return node.data.name.toLowerCase().includes(term.toLowerCase())
@ -146,6 +155,7 @@ const FileTree: React.FC<FileTreeProps> = ({ className, searchTerm = '' }) => {
<div
ref={containerRef}
className="flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1"
onContextMenu={handleBlankAreaContextMenu}
>
<Tree<TreeNodeData>
ref={treeRef}

View File

@ -9,6 +9,7 @@ import { useCallback, useMemo, useRef } from 'react'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
import { findNodeById } from '../utils/tree-utils'
import BlankAreaMenu from './blank-area-menu'
import NodeMenu from './node-menu'
type TreeContextMenuProps = {
@ -29,13 +30,17 @@ const TreeContextMenu: FC<TreeContextMenuProps> = ({ treeRef }) => {
handleClose()
}, ref)
const nodeId = contextMenu?.nodeId
const treeChildren = treeData?.children
const targetNode = useMemo(() => {
if (!contextMenu?.nodeId || !treeData?.children)
if (!nodeId || !treeChildren)
return null
return findNodeById(treeData.children, contextMenu.nodeId)
}, [contextMenu?.nodeId, treeData?.children])
return findNodeById(treeChildren, nodeId)
}, [nodeId, treeChildren])
const isFolder = targetNode?.node_type === 'folder'
const isBlankArea = contextMenu?.type === 'blank'
if (!contextMenu)
return null
@ -49,12 +54,18 @@ const TreeContextMenu: FC<TreeContextMenuProps> = ({ treeRef }) => {
left: contextMenu.left,
}}
>
<NodeMenu
type={isFolder ? 'folder' : 'file'}
nodeId={contextMenu.nodeId}
onClose={handleClose}
treeRef={treeRef}
/>
{isBlankArea
? (
<BlankAreaMenu onClose={handleClose} />
)
: (
<NodeMenu
type={isFolder ? 'folder' : 'file'}
nodeId={contextMenu.nodeId}
onClose={handleClose}
treeRef={treeRef}
/>
)}
</div>
)
}

View File

@ -76,6 +76,7 @@ export function useTreeNodeHandlers({
storeApi.getState().setContextMenu({
top: e.clientY,
left: e.clientX,
type: 'node',
nodeId: node.data.id,
})
}, [node.data.id, storeApi])

View File

@ -66,12 +66,13 @@ const SidebarSearchAdd: FC<SidebarSearchAddProps> = ({ onSearchChange }) => {
const { data: treeData } = useSkillAssetTreeData()
const activeTabId = useStore(s => s.activeTabId)
const treeChildren = treeData?.children
const targetFolderId = useMemo(() => {
if (!treeData?.children)
if (!treeChildren)
return 'root'
return getTargetFolderIdFromSelection(activeTabId, treeData.children)
}, [activeTabId, treeData?.children])
return getTargetFolderIdFromSelection(activeTabId, treeChildren)
}, [activeTabId, treeChildren])
const menuOffset = useMemo(() => ({ mainAxis: 4 }), [])
const {
@ -88,11 +89,11 @@ const SidebarSearchAdd: FC<SidebarSearchAddProps> = ({ onSearchChange }) => {
})
return (
<div className="flex items-center gap-1 bg-components-panel-bg p-2">
<div className="flex items-center gap-1 p-2">
<SearchInput
value={searchValue}
onChange={setSearchValue}
className="h-8 flex-1"
className="!h-6 flex-1 !rounded-md"
placeholder={t('skillSidebar.searchPlaceholder')}
/>
<PortalToFollowElem
@ -104,11 +105,11 @@ const SidebarSearchAdd: FC<SidebarSearchAddProps> = ({ onSearchChange }) => {
<PortalToFollowElemTrigger onClick={() => setShowMenu(!showMenu)}>
<Button
variant="primary"
size="medium"
className={cn('!h-8 !w-8 !px-0')}
size="small"
className="!size-6 shrink-0 !p-1"
aria-label={t('operation.add', { ns: 'common' })}
>
<RiAddLine className="h-4 w-4" aria-hidden="true" />
<RiAddLine className="size-4" aria-hidden="true" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[30]">

View File

@ -43,13 +43,18 @@ export type MetadataSliceShape = {
getFileMetadata: (fileId: string) => Record<string, unknown> | undefined
}
export type ContextMenuType = 'node' | 'blank'
export type ContextMenuState = {
top: number
left: number
type: ContextMenuType
nodeId?: string
}
export type FileOperationsMenuSliceShape = {
contextMenu: {
top: number
left: number
nodeId: string
} | null
setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void
contextMenu: ContextMenuState | null
setContextMenu: (menu: ContextMenuState | null) => void
}
export type SkillEditorSliceShape