variable-picker.tsx 11 KB

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