diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 57de577619..29708f6937 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -12,6 +12,7 @@ import type { ToolDefaultValue, ToolValue } from './types' import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import type { OnSelectBlock } from '@/app/components/workflow/types' import { RiArrowRightUpLine } from '@remixicon/react' +import { useEventListener } from 'ahooks' import Link from 'next/link' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -55,6 +56,8 @@ type AllToolsProps = { onFeaturedInstallSuccess?: () => Promise | void hideFeaturedTool?: boolean hideSelectedInfo?: boolean + enableKeyboardNavigation?: boolean + onClose?: () => void } const DEFAULT_TAGS: AllToolsProps['tags'] = [] @@ -80,12 +83,17 @@ const AllTools = ({ onFeaturedInstallSuccess, hideFeaturedTool = false, hideSelectedInfo = false, + enableKeyboardNavigation = false, + onClose, }: AllToolsProps) => { const { t } = useTranslation() const language = useGetLanguage() const tabs = useToolTabs() const [activeTab, setActiveTab] = useState(ToolTypeEnum.All) const [activeView, setActiveView] = useState(ViewType.flat) + const activeIndexRef = useRef(-1) + const itemElementsRef = useRef([]) + const highlightedElementRef = useRef(null) const trimmedSearchText = searchText.trim() const hasSearchText = trimmedSearchText.length > 0 const hasTags = tags.length > 0 @@ -188,6 +196,34 @@ const AllTools = ({ const pluginRef = useRef(null) const wrapElemRef = useRef(null) const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab) + const refreshKeyboardItems = useCallback(() => { + if (!wrapElemRef.current) { + itemElementsRef.current = [] + return [] + } + const items = Array.from(wrapElemRef.current.querySelectorAll('[data-tool-picker-item="true"]')) + itemElementsRef.current = items + return items + }, []) + const clearHighlight = useCallback(() => { + if (highlightedElementRef.current) + highlightedElementRef.current.classList.remove('bg-state-base-hover') + highlightedElementRef.current = null + activeIndexRef.current = -1 + }, []) + const applyHighlight = useCallback((index: number, items: HTMLElement[]) => { + if (highlightedElementRef.current) + highlightedElementRef.current.classList.remove('bg-state-base-hover') + const nextItem = items[index] + if (nextItem) { + nextItem.classList.add('bg-state-base-hover') + highlightedElementRef.current = nextItem + activeIndexRef.current = index + return + } + highlightedElementRef.current = null + activeIndexRef.current = -1 + }, []) const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolTypeEnum.All && !hasFilter const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations @@ -201,6 +237,45 @@ const AllTools = ({ && !hideFeaturedTool const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter + useEffect(() => { + if (!enableKeyboardNavigation) { + itemElementsRef.current = [] + clearHighlight() + return + } + const items = refreshKeyboardItems() + if (activeIndexRef.current >= items.length) + clearHighlight() + }, [enableKeyboardNavigation, refreshKeyboardItems, clearHighlight, tools, activeTab, activeView, hasSearchText]) + + useEventListener('keydown', (event: KeyboardEvent) => { + if (!enableKeyboardNavigation) + return + const items = refreshKeyboardItems() + if (items.length === 0) + return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) + return + event.preventDefault() + event.stopPropagation() + if (event.key === 'Escape') { + onClose?.() + return + } + if (event.key === 'Enter') { + const index = activeIndexRef.current + if (index < 0 || index >= items.length) + return + items[index]?.click() + return + } + const delta = event.key === 'ArrowDown' ? 1 : -1 + const baseIndex = activeIndexRef.current < 0 ? -1 : activeIndexRef.current + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), items.length - 1) + applyHighlight(nextIndex, items) + items[nextIndex]?.scrollIntoView({ block: 'nearest' }) + }, { target: typeof document !== 'undefined' ? document : undefined, capture: true }) + const handleRAGSelect = useCallback((type, pluginDefaultValue) => { if (!pluginDefaultValue) return diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index 1f6dca104f..12502baf0b 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -57,6 +57,7 @@ type Props = { searchText?: string onSearchTextChange?: (value: string) => void hideSearchBox?: boolean + enableKeyboardNavigation?: boolean } const ToolPicker: FC = ({ @@ -79,6 +80,7 @@ const ToolPicker: FC = ({ searchText: controlledSearchText, onSearchTextChange, hideSearchBox = false, + enableKeyboardNavigation = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') @@ -239,6 +241,8 @@ const ToolPicker: FC = ({ showFeatured={scope === 'all' && enable_marketplace} hideFeaturedTool={hideFeaturedTool} hideSelectedInfo={hideSelectedInfo} + enableKeyboardNavigation={enableKeyboardNavigation} + onClose={() => onShowChange(false)} onFeaturedInstallSuccess={async () => { invalidateBuiltInTools() invalidateCustomTools() diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index 4ffa99b05d..4bd0cf8666 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -78,6 +78,7 @@ const ToolItem: FC = ({ >
{ if (disabled) diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index 5fa2da1abc..462a3aa988 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -181,6 +181,7 @@ const Tool: FC = ({
{ if (hasAction) { setFold(!isFold) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx index a82c938104..55bcad86f5 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx @@ -5,7 +5,9 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { LexicalTypeaheadMenuPlugin, MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' import { $createTextNode, + $getSelection, $insertNodes, + $isRangeSelection, } from 'lexical' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' @@ -44,6 +46,20 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => { const options = useMemo(() => [new ToolPickerMenuOption()], []) + const getMatchFromSelection = useCallback(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) + return null + const anchor = selection.anchor + if (anchor.type !== 'text') + return null + const anchorNode = anchor.getNode() + if (!anchorNode.isSimpleText()) + return null + const text = anchorNode.getTextContent().slice(0, anchor.offset) + return checkForTriggerMatch(text, editor) + }, [checkForTriggerMatch, editor]) + const buildNextMetadata = useCallback((metadata: Record, toolEntries: { configId: string tool: ToolDefaultValue @@ -73,7 +89,7 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => { tool, })) editor.update(() => { - const match = checkForTriggerMatch('@', editor) + const match = getMatchFromSelection() const nodeToRemove = match ? $splitNodeContainingQuery(match) : null if (nodeToRemove) nodeToRemove.remove() @@ -124,7 +140,7 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => { ...nextMetadata, }) pinTab(activeTabId) - }, [buildNextMetadata, checkForTriggerMatch, editor, isUsingExternalMetadata, storeApi, toolBlockContext]) + }, [buildNextMetadata, editor, getMatchFromSelection, isUsingExternalMetadata, storeApi, toolBlockContext]) const renderMenu = useCallback(( anchorElementRef: React.RefObject, @@ -163,6 +179,7 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => { searchText={queryString} onSearchTextChange={setQueryString} hideSearchBox + enableKeyboardNavigation scope={scope} hideFeaturedTool preventFocusLoss