variable-picker.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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. class VariablePickerOption extends MenuOption {
  16. title: string
  17. icon?: JSX.Element
  18. keywords: Array<string>
  19. keyboardShortcut?: string
  20. onSelect: (queryString: string) => void
  21. constructor(
  22. title: string,
  23. options: {
  24. icon?: JSX.Element
  25. keywords?: Array<string>
  26. keyboardShortcut?: string
  27. onSelect: (queryString: string) => void
  28. },
  29. ) {
  30. super(title)
  31. this.title = title
  32. this.keywords = options.keywords || []
  33. this.icon = options.icon
  34. this.keyboardShortcut = options.keyboardShortcut
  35. this.onSelect = options.onSelect.bind(this)
  36. }
  37. }
  38. type VariablePickerMenuItemProps = {
  39. isSelected: boolean
  40. onClick: () => void
  41. onMouseEnter: () => void
  42. option: VariablePickerOption
  43. queryString: string | null
  44. }
  45. const VariablePickerMenuItem: FC<VariablePickerMenuItemProps> = ({
  46. isSelected,
  47. onClick,
  48. onMouseEnter,
  49. option,
  50. queryString,
  51. }) => {
  52. const title = option.title
  53. let before = title
  54. let middle = ''
  55. let after = ''
  56. if (queryString) {
  57. const regex = new RegExp(queryString, 'i')
  58. const match = regex.exec(option.title)
  59. if (match) {
  60. before = title.substring(0, match.index)
  61. middle = match[0]
  62. after = title.substring(match.index + match[0].length)
  63. }
  64. }
  65. return (
  66. <div
  67. key={option.key}
  68. className={`
  69. flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
  70. ${isSelected && 'bg-primary-50'}
  71. `}
  72. tabIndex={-1}
  73. ref={option.setRefElement}
  74. onMouseEnter={onMouseEnter}
  75. onClick={onClick}>
  76. <div className='mr-2'>
  77. {option.icon}
  78. </div>
  79. <div className='text-[13px] text-gray-900'>
  80. {before}
  81. <span className='text-[#2970FF]'>{middle}</span>
  82. {after}
  83. </div>
  84. </div>
  85. )
  86. }
  87. export type Option = {
  88. value: string
  89. name: string
  90. }
  91. type VariablePickerProps = {
  92. items?: Option[]
  93. }
  94. const VariablePicker: FC<VariablePickerProps> = ({
  95. items = [],
  96. }) => {
  97. const { t } = useTranslation()
  98. const [editor] = useLexicalComposerContext()
  99. const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('{', {
  100. minLength: 0,
  101. maxLength: 6,
  102. })
  103. const [queryString, setQueryString] = useState<string | null>(null)
  104. const options = useMemo(() => {
  105. const baseOptions = items.map((item) => {
  106. return new VariablePickerOption(item.value, {
  107. icon: <BracketsX className='w-[14px] h-[14px] text-[#2970FF]' />,
  108. onSelect: () => {
  109. editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.value}}}`)
  110. },
  111. })
  112. })
  113. if (!queryString)
  114. return baseOptions
  115. const regex = new RegExp(queryString, 'i')
  116. return baseOptions.filter(option => regex.test(option.title) || option.keywords.some(keyword => regex.test(keyword)))
  117. }, [editor, queryString, items])
  118. const newOption = new VariablePickerOption(t('common.promptEditor.variable.modal.add'), {
  119. icon: <BracketsX className='mr-2 w-[14px] h-[14px] text-[#2970FF]' />,
  120. onSelect: () => {
  121. editor.update(() => {
  122. const prefixNode = $createCustomTextNode('{{')
  123. const suffixNode = $createCustomTextNode('}}')
  124. $insertNodes([prefixNode, suffixNode])
  125. prefixNode.select()
  126. })
  127. },
  128. })
  129. const onSelectOption = useCallback(
  130. (
  131. selectedOption: VariablePickerOption,
  132. nodeToRemove: TextNode | null,
  133. closeMenu: () => void,
  134. matchingString: string,
  135. ) => {
  136. editor.update(() => {
  137. if (nodeToRemove)
  138. nodeToRemove.remove()
  139. selectedOption.onSelect(matchingString)
  140. closeMenu()
  141. })
  142. },
  143. [editor],
  144. )
  145. const mergedOptions = [...options, newOption]
  146. return (
  147. <LexicalTypeaheadMenuPlugin
  148. options={mergedOptions}
  149. onQueryChange={setQueryString}
  150. onSelectOption={onSelectOption}
  151. menuRenderFn={(
  152. anchorElementRef,
  153. { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
  154. ) =>
  155. (anchorElementRef.current && mergedOptions.length)
  156. ? ReactDOM.createPortal(
  157. <div className='mt-[25px] w-[240px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
  158. {
  159. !!options.length && (
  160. <>
  161. <div className='p-1'>
  162. {options.map((option, i: number) => (
  163. <VariablePickerMenuItem
  164. isSelected={selectedIndex === i}
  165. onClick={() => {
  166. setHighlightedIndex(i)
  167. selectOptionAndCleanUp(option)
  168. }}
  169. onMouseEnter={() => {
  170. setHighlightedIndex(i)
  171. }}
  172. key={option.key}
  173. option={option}
  174. queryString={queryString}
  175. />
  176. ))}
  177. </div>
  178. <div className='h-[1px] bg-gray-100' />
  179. </>
  180. )
  181. }
  182. <div className='p-1'>
  183. <div
  184. className={`
  185. flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer
  186. ${selectedIndex === options.length && 'bg-primary-50'}
  187. `}
  188. ref={newOption.setRefElement}
  189. tabIndex={-1}
  190. onClick={() => {
  191. setHighlightedIndex(options.length)
  192. selectOptionAndCleanUp(newOption)
  193. }}
  194. onMouseEnter={() => {
  195. setHighlightedIndex(options.length)
  196. }}
  197. key={newOption.key}
  198. >
  199. {newOption.icon}
  200. <div className='text-[13px] text-gray-900'>{newOption.title}</div>
  201. </div>
  202. </div>
  203. </div>,
  204. anchorElementRef.current,
  205. )
  206. : null}
  207. triggerFn={checkForTriggerMatch}
  208. />
  209. )
  210. }
  211. export default VariablePicker