mirror of
https://github.com/langgenius/dify.git
synced 2026-04-29 06:58:05 +08:00
feat: Add @ and keyboard navigation to tool picker in prompt editor
This commit is contained in:
@ -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> | 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>(ViewType.flat)
|
||||
const activeIndexRef = useRef(-1)
|
||||
const itemElementsRef = useRef<HTMLElement[]>([])
|
||||
const highlightedElementRef = useRef<HTMLElement | null>(null)
|
||||
const trimmedSearchText = searchText.trim()
|
||||
const hasSearchText = trimmedSearchText.length > 0
|
||||
const hasTags = tags.length > 0
|
||||
@ -188,6 +196,34 @@ const AllTools = ({
|
||||
const pluginRef = useRef<ListRef>(null)
|
||||
const wrapElemRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
if (!pluginDefaultValue)
|
||||
return
|
||||
|
||||
@ -57,6 +57,7 @@ type Props = {
|
||||
searchText?: string
|
||||
onSearchTextChange?: (value: string) => void
|
||||
hideSearchBox?: boolean
|
||||
enableKeyboardNavigation?: boolean
|
||||
}
|
||||
|
||||
const ToolPicker: FC<Props> = ({
|
||||
@ -79,6 +80,7 @@ const ToolPicker: FC<Props> = ({
|
||||
searchText: controlledSearchText,
|
||||
onSearchTextChange,
|
||||
hideSearchBox = false,
|
||||
enableKeyboardNavigation = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
@ -239,6 +241,8 @@ const ToolPicker: FC<Props> = ({
|
||||
showFeatured={scope === 'all' && enable_marketplace}
|
||||
hideFeaturedTool={hideFeaturedTool}
|
||||
hideSelectedInfo={hideSelectedInfo}
|
||||
enableKeyboardNavigation={enableKeyboardNavigation}
|
||||
onClose={() => onShowChange(false)}
|
||||
onFeaturedInstallSuccess={async () => {
|
||||
invalidateBuiltInTools()
|
||||
invalidateCustomTools()
|
||||
|
||||
@ -78,6 +78,7 @@ const ToolItem: FC<Props> = ({
|
||||
>
|
||||
<div
|
||||
key={payload.name}
|
||||
data-tool-picker-item="true"
|
||||
className="flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
if (disabled)
|
||||
|
||||
@ -181,6 +181,7 @@ const Tool: FC<Props> = ({
|
||||
<div className={cn(className)}>
|
||||
<div
|
||||
className="group/item flex w-full cursor-pointer select-none items-center justify-between rounded-lg pl-3 pr-1 hover:bg-state-base-hover"
|
||||
data-tool-picker-item="true"
|
||||
onClick={() => {
|
||||
if (hasAction) {
|
||||
setFold(!isFold)
|
||||
|
||||
@ -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<string, unknown>, 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<HTMLElement | null>,
|
||||
@ -163,6 +179,7 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => {
|
||||
searchText={queryString}
|
||||
onSearchTextChange={setQueryString}
|
||||
hideSearchBox
|
||||
enableKeyboardNavigation
|
||||
scope={scope}
|
||||
hideFeaturedTool
|
||||
preventFocusLoss
|
||||
|
||||
Reference in New Issue
Block a user