variable-picker.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import type { FC } from 'react'
  2. import { useCallback, useMemo, useState } from 'react'
  3. import ReactDOM from 'react-dom'
  4. import { useTranslation } from 'react-i18next'
  5. import { $insertNodes, type TextNode } from 'lexical'
  6. import {
  7. LexicalTypeaheadMenuPlugin,
  8. MenuOption,
  9. } from '@lexical/react/LexicalTypeaheadMenuPlugin'
  10. import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
  11. import { useBasicTypeaheadTriggerMatch } from '../hooks'
  12. import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from './variable-block'
  13. import { $createCustomTextNode } from './custom-text/node'
  14. import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
  15. import { Tool03 } from '@/app/components/base/icons/src/vender/solid/general'
  16. import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
  17. import AppIcon from '@/app/components/base/app-icon'
  18. class VariablePickerOption extends MenuOption {
  19. title: string
  20. icon?: JSX.Element
  21. extraElement?: JSX.Element
  22. keywords: Array<string>
  23. keyboardShortcut?: string
  24. onSelect: (queryString: string) => void
  25. constructor(
  26. title: string,
  27. options: {
  28. icon?: JSX.Element
  29. extraElement?: JSX.Element
  30. keywords?: Array<string>
  31. keyboardShortcut?: string
  32. onSelect: (queryString: string) => void
  33. },
  34. ) {
  35. super(title)
  36. this.title = title
  37. this.keywords = options.keywords || []
  38. this.icon = options.icon
  39. this.extraElement = options.extraElement
  40. this.keyboardShortcut = options.keyboardShortcut
  41. this.onSelect = options.onSelect.bind(this)
  42. }
  43. }
  44. type VariablePickerMenuItemProps = {
  45. isSelected: boolean
  46. onClick: () => void
  47. onMouseEnter: () => void
  48. option: VariablePickerOption
  49. queryString: string | null
  50. }
  51. const VariablePickerMenuItem: FC<VariablePickerMenuItemProps> = ({
  52. isSelected,
  53. onClick,
  54. onMouseEnter,
  55. option,
  56. queryString,
  57. }) => {
  58. const title = option.title
  59. let before = title
  60. let middle = ''
  61. let after = ''
  62. if (queryString) {
  63. const regex = new RegExp(queryString, 'i')
  64. const match = regex.exec(option.title)
  65. if (match) {
  66. before = title.substring(0, match.index)
  67. middle = match[0]
  68. after = title.substring(match.index + match[0].length)
  69. }
  70. }
  71. return (
  72. <div
  73. key={option.key}
  74. className={`
  75. flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
  76. ${isSelected && 'bg-primary-50'}
  77. `}
  78. tabIndex={-1}
  79. ref={option.setRefElement}
  80. onMouseEnter={onMouseEnter}
  81. onClick={onClick}>
  82. <div className='mr-2'>
  83. {option.icon}
  84. </div>
  85. <div className='grow text-[13px] text-gray-900 truncate' title={option.title}>
  86. {before}
  87. <span className='text-[#2970FF]'>{middle}</span>
  88. {after}
  89. </div>
  90. {option.extraElement}
  91. </div>
  92. )
  93. }
  94. export type Option = {
  95. value: string
  96. name: string
  97. }
  98. export type ExternalToolOption = {
  99. name: string
  100. variableName: string
  101. icon?: string
  102. icon_background?: string
  103. }
  104. type VariablePickerProps = {
  105. items?: Option[]
  106. externalTools?: ExternalToolOption[]
  107. onAddExternalTool?: () => void
  108. }
  109. const VariablePicker: FC<VariablePickerProps> = ({
  110. items = [],
  111. externalTools = [],
  112. onAddExternalTool,
  113. }) => {
  114. const { t } = useTranslation()
  115. const [editor] = useLexicalComposerContext()
  116. const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('{', {
  117. minLength: 0,
  118. maxLength: 6,
  119. })
  120. const [queryString, setQueryString] = useState<string | null>(null)
  121. const options = useMemo(() => {
  122. const baseOptions = items.map((item) => {
  123. return new VariablePickerOption(item.value, {
  124. icon: <BracketsX className='w-[14px] h-[14px] text-[#2970FF]' />,
  125. onSelect: () => {
  126. editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.value}}}`)
  127. },
  128. })
  129. })
  130. if (!queryString)
  131. return baseOptions
  132. const regex = new RegExp(queryString, 'i')
  133. return baseOptions.filter(option => regex.test(option.title) || option.keywords.some(keyword => regex.test(keyword)))
  134. }, [editor, queryString, items])
  135. const toolOptions = useMemo(() => {
  136. const baseToolOptions = externalTools.map((item) => {
  137. return new VariablePickerOption(item.name, {
  138. icon: (
  139. <AppIcon
  140. className='!w-[14px] !h-[14px]'
  141. icon={item.icon}
  142. background={item.icon_background}
  143. />
  144. ),
  145. extraElement: <div className='text-xs text-gray-400'>{item.variableName}</div>,
  146. onSelect: () => {
  147. editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.variableName}}}`)
  148. },
  149. })
  150. })
  151. if (!queryString)
  152. return baseToolOptions
  153. const regex = new RegExp(queryString, 'i')
  154. return baseToolOptions.filter(option => regex.test(option.title) || option.keywords.some(keyword => regex.test(keyword)))
  155. }, [editor, queryString, externalTools])
  156. const newOption = new VariablePickerOption(t('common.promptEditor.variable.modal.add'), {
  157. icon: <BracketsX className='mr-2 w-[14px] h-[14px] text-[#2970FF]' />,
  158. onSelect: () => {
  159. editor.update(() => {
  160. const prefixNode = $createCustomTextNode('{{')
  161. const suffixNode = $createCustomTextNode('}}')
  162. $insertNodes([prefixNode, suffixNode])
  163. prefixNode.select()
  164. })
  165. },
  166. })
  167. const newToolOption = new VariablePickerOption(t('common.promptEditor.variable.modal.addTool'), {
  168. icon: <Tool03 className='mr-2 w-[14px] h-[14px] text-[#444CE7]' />,
  169. extraElement: <ArrowUpRight className='w-3 h-3 text-gray-400' />,
  170. onSelect: () => {
  171. if (onAddExternalTool)
  172. onAddExternalTool()
  173. },
  174. })
  175. const onSelectOption = useCallback(
  176. (
  177. selectedOption: VariablePickerOption,
  178. nodeToRemove: TextNode | null,
  179. closeMenu: () => void,
  180. matchingString: string,
  181. ) => {
  182. editor.update(() => {
  183. if (nodeToRemove)
  184. nodeToRemove.remove()
  185. selectedOption.onSelect(matchingString)
  186. closeMenu()
  187. })
  188. },
  189. [editor],
  190. )
  191. const mergedOptions = [...options, ...toolOptions, newOption, newToolOption]
  192. return (
  193. <LexicalTypeaheadMenuPlugin
  194. options={mergedOptions}
  195. onQueryChange={setQueryString}
  196. onSelectOption={onSelectOption}
  197. menuRenderFn={(
  198. anchorElementRef,
  199. { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
  200. ) =>
  201. (anchorElementRef.current && mergedOptions.length)
  202. ? ReactDOM.createPortal(
  203. <div className='mt-[25px] w-[240px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
  204. {
  205. !!options.length && (
  206. <>
  207. <div className='p-1'>
  208. {options.map((option, i: number) => (
  209. <VariablePickerMenuItem
  210. isSelected={selectedIndex === i}
  211. onClick={() => {
  212. setHighlightedIndex(i)
  213. selectOptionAndCleanUp(option)
  214. }}
  215. onMouseEnter={() => {
  216. setHighlightedIndex(i)
  217. }}
  218. key={option.key}
  219. option={option}
  220. queryString={queryString}
  221. />
  222. ))}
  223. </div>
  224. <div className='h-[1px] bg-gray-100' />
  225. </>
  226. )
  227. }
  228. {
  229. !!toolOptions.length && (
  230. <>
  231. <div className='p-1'>
  232. {toolOptions.map((option, i: number) => (
  233. <VariablePickerMenuItem
  234. isSelected={selectedIndex === i + options.length}
  235. onClick={() => {
  236. setHighlightedIndex(i + options.length)
  237. selectOptionAndCleanUp(option)
  238. }}
  239. onMouseEnter={() => {
  240. setHighlightedIndex(i + options.length)
  241. }}
  242. key={option.key}
  243. option={option}
  244. queryString={queryString}
  245. />
  246. ))}
  247. </div>
  248. <div className='h-[1px] bg-gray-100' />
  249. </>
  250. )
  251. }
  252. <div className='p-1'>
  253. <div
  254. className={`
  255. flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
  256. ${selectedIndex === options.length + toolOptions.length && 'bg-primary-50'}
  257. `}
  258. ref={newOption.setRefElement}
  259. tabIndex={-1}
  260. onClick={() => {
  261. setHighlightedIndex(options.length + toolOptions.length)
  262. selectOptionAndCleanUp(newOption)
  263. }}
  264. onMouseEnter={() => {
  265. setHighlightedIndex(options.length + toolOptions.length)
  266. }}
  267. key={newOption.key}
  268. >
  269. {newOption.icon}
  270. <div className='text-[13px] text-gray-900'>{newOption.title}</div>
  271. </div>
  272. <div
  273. className={`
  274. flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
  275. ${selectedIndex === options.length + toolOptions.length + 1 && 'bg-primary-50'}
  276. `}
  277. ref={newToolOption.setRefElement}
  278. tabIndex={-1}
  279. onClick={() => {
  280. setHighlightedIndex(options.length + toolOptions.length + 1)
  281. selectOptionAndCleanUp(newToolOption)
  282. }}
  283. onMouseEnter={() => {
  284. setHighlightedIndex(options.length + toolOptions.length + 1)
  285. }}
  286. key={newToolOption.key}
  287. >
  288. {newToolOption.icon}
  289. <div className='grow text-[13px] text-gray-900'>{newToolOption.title}</div>
  290. {newToolOption.extraElement}
  291. </div>
  292. </div>
  293. </div>,
  294. anchorElementRef.current,
  295. )
  296. : null}
  297. triggerFn={checkForTriggerMatch}
  298. />
  299. )
  300. }
  301. export default VariablePicker