123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- import type { FC } from 'react'
- import { useCallback, useMemo, useState } from 'react'
- import ReactDOM from 'react-dom'
- import { useTranslation } from 'react-i18next'
- import { $insertNodes, type TextNode } from 'lexical'
- import {
- LexicalTypeaheadMenuPlugin,
- MenuOption,
- } from '@lexical/react/LexicalTypeaheadMenuPlugin'
- import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
- import { useBasicTypeaheadTriggerMatch } from '../hooks'
- import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from './variable-block'
- import { $createCustomTextNode } from './custom-text/node'
- import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
- import { Tool03 } from '@/app/components/base/icons/src/vender/solid/general'
- import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
- import AppIcon from '@/app/components/base/app-icon'
- import { useEventEmitterContextContext } from '@/context/event-emitter'
- class VariablePickerOption extends MenuOption {
- title: string
- icon?: JSX.Element
- extraElement?: JSX.Element
- keywords: Array<string>
- keyboardShortcut?: string
- onSelect: (queryString: string) => void
- constructor(
- title: string,
- options: {
- icon?: JSX.Element
- extraElement?: JSX.Element
- keywords?: Array<string>
- keyboardShortcut?: string
- onSelect: (queryString: string) => void
- },
- ) {
- super(title)
- this.title = title
- this.keywords = options.keywords || []
- this.icon = options.icon
- this.extraElement = options.extraElement
- this.keyboardShortcut = options.keyboardShortcut
- this.onSelect = options.onSelect.bind(this)
- }
- }
- type VariablePickerMenuItemProps = {
- isSelected: boolean
- onClick: () => void
- onMouseEnter: () => void
- option: VariablePickerOption
- queryString: string | null
- }
- const VariablePickerMenuItem: FC<VariablePickerMenuItemProps> = ({
- isSelected,
- onClick,
- onMouseEnter,
- option,
- queryString,
- }) => {
- const title = option.title
- let before = title
- let middle = ''
- let after = ''
- if (queryString) {
- const regex = new RegExp(queryString, 'i')
- const match = regex.exec(option.title)
- if (match) {
- before = title.substring(0, match.index)
- middle = match[0]
- after = title.substring(match.index + match[0].length)
- }
- }
- return (
- <div
- key={option.key}
- className={`
- flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
- ${isSelected && 'bg-primary-50'}
- `}
- tabIndex={-1}
- ref={option.setRefElement}
- onMouseEnter={onMouseEnter}
- onClick={onClick}>
- <div className='mr-2'>
- {option.icon}
- </div>
- <div className='grow text-[13px] text-gray-900 truncate' title={option.title}>
- {before}
- <span className='text-[#2970FF]'>{middle}</span>
- {after}
- </div>
- {option.extraElement}
- </div>
- )
- }
- export type Option = {
- value: string
- name: string
- }
- export type ExternalToolOption = {
- name: string
- variableName: string
- icon?: string
- icon_background?: string
- }
- type VariablePickerProps = {
- items?: Option[]
- externalTools?: ExternalToolOption[]
- onAddExternalTool?: () => void
- }
- const VariablePicker: FC<VariablePickerProps> = ({
- items = [],
- externalTools = [],
- onAddExternalTool,
- }) => {
- const { t } = useTranslation()
- const { eventEmitter } = useEventEmitterContextContext()
- const [editor] = useLexicalComposerContext()
- const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('{', {
- minLength: 0,
- maxLength: 6,
- })
- const [queryString, setQueryString] = useState<string | null>(null)
- eventEmitter?.useSubscription((v: any) => {
- if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
- editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
- })
- const options = useMemo(() => {
- const baseOptions = items.map((item) => {
- return new VariablePickerOption(item.value, {
- icon: <BracketsX className='w-[14px] h-[14px] text-[#2970FF]' />,
- onSelect: () => {
- editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.value}}}`)
- },
- })
- })
- if (!queryString)
- return baseOptions
- const regex = new RegExp(queryString, 'i')
- return baseOptions.filter(option => regex.test(option.title) || option.keywords.some(keyword => regex.test(keyword)))
- }, [editor, queryString, items])
- const toolOptions = useMemo(() => {
- const baseToolOptions = externalTools.map((item) => {
- return new VariablePickerOption(item.name, {
- icon: (
- <AppIcon
- className='!w-[14px] !h-[14px]'
- icon={item.icon}
- background={item.icon_background}
- />
- ),
- extraElement: <div className='text-xs text-gray-400'>{item.variableName}</div>,
- onSelect: () => {
- editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.variableName}}}`)
- },
- })
- })
- if (!queryString)
- return baseToolOptions
- const regex = new RegExp(queryString, 'i')
- return baseToolOptions.filter(option => regex.test(option.title) || option.keywords.some(keyword => regex.test(keyword)))
- }, [editor, queryString, externalTools])
- const newOption = new VariablePickerOption(t('common.promptEditor.variable.modal.add'), {
- icon: <BracketsX className='mr-2 w-[14px] h-[14px] text-[#2970FF]' />,
- onSelect: () => {
- editor.update(() => {
- const prefixNode = $createCustomTextNode('{{')
- const suffixNode = $createCustomTextNode('}}')
- $insertNodes([prefixNode, suffixNode])
- prefixNode.select()
- })
- },
- })
- const newToolOption = new VariablePickerOption(t('common.promptEditor.variable.modal.addTool'), {
- icon: <Tool03 className='mr-2 w-[14px] h-[14px] text-[#444CE7]' />,
- extraElement: <ArrowUpRight className='w-3 h-3 text-gray-400' />,
- onSelect: () => {
- if (onAddExternalTool)
- onAddExternalTool()
- },
- })
- const onSelectOption = useCallback(
- (
- selectedOption: VariablePickerOption,
- nodeToRemove: TextNode | null,
- closeMenu: () => void,
- matchingString: string,
- ) => {
- editor.update(() => {
- if (nodeToRemove)
- nodeToRemove.remove()
- selectedOption.onSelect(matchingString)
- closeMenu()
- })
- },
- [editor],
- )
- const mergedOptions = [...options, ...toolOptions, newOption, newToolOption]
- return (
- <LexicalTypeaheadMenuPlugin
- options={mergedOptions}
- onQueryChange={setQueryString}
- onSelectOption={onSelectOption}
- anchorClassName='z-[999999]'
- menuRenderFn={(
- anchorElementRef,
- { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
- ) =>
- (anchorElementRef.current && mergedOptions.length)
- ? ReactDOM.createPortal(
- <div className='mt-[25px] w-[240px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
- {
- !!options.length && (
- <>
- <div className='p-1'>
- {options.map((option, i: number) => (
- <VariablePickerMenuItem
- isSelected={selectedIndex === i}
- onClick={() => {
- setHighlightedIndex(i)
- selectOptionAndCleanUp(option)
- }}
- onMouseEnter={() => {
- setHighlightedIndex(i)
- }}
- key={option.key}
- option={option}
- queryString={queryString}
- />
- ))}
- </div>
- <div className='h-[1px] bg-gray-100' />
- </>
- )
- }
- {
- !!toolOptions.length && (
- <>
- <div className='p-1'>
- {toolOptions.map((option, i: number) => (
- <VariablePickerMenuItem
- isSelected={selectedIndex === i + options.length}
- onClick={() => {
- setHighlightedIndex(i + options.length)
- selectOptionAndCleanUp(option)
- }}
- onMouseEnter={() => {
- setHighlightedIndex(i + options.length)
- }}
- key={option.key}
- option={option}
- queryString={queryString}
- />
- ))}
- </div>
- <div className='h-[1px] bg-gray-100' />
- </>
- )
- }
- <div className='p-1'>
- <div
- className={`
- flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
- ${selectedIndex === options.length + toolOptions.length && 'bg-primary-50'}
- `}
- ref={newOption.setRefElement}
- tabIndex={-1}
- onClick={() => {
- setHighlightedIndex(options.length + toolOptions.length)
- selectOptionAndCleanUp(newOption)
- }}
- onMouseEnter={() => {
- setHighlightedIndex(options.length + toolOptions.length)
- }}
- key={newOption.key}
- >
- {newOption.icon}
- <div className='text-[13px] text-gray-900'>{newOption.title}</div>
- </div>
- <div
- className={`
- flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
- ${selectedIndex === options.length + toolOptions.length + 1 && 'bg-primary-50'}
- `}
- ref={newToolOption.setRefElement}
- tabIndex={-1}
- onClick={() => {
- setHighlightedIndex(options.length + toolOptions.length + 1)
- selectOptionAndCleanUp(newToolOption)
- }}
- onMouseEnter={() => {
- setHighlightedIndex(options.length + toolOptions.length + 1)
- }}
- key={newToolOption.key}
- >
- {newToolOption.icon}
- <div className='grow text-[13px] text-gray-900'>{newToolOption.title}</div>
- {newToolOption.extraElement}
- </div>
- </div>
- </div>,
- anchorElementRef.current,
- )
- : null}
- triggerFn={checkForTriggerMatch}
- />
- )
- }
- export default VariablePicker
|