component-picker.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import type { FC } from 'react'
  2. import { useCallback } from 'react'
  3. import ReactDOM from 'react-dom'
  4. import { useTranslation } from 'react-i18next'
  5. import type { TextNode } from 'lexical'
  6. import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
  7. import {
  8. LexicalTypeaheadMenuPlugin,
  9. MenuOption,
  10. } from '@lexical/react/LexicalTypeaheadMenuPlugin'
  11. import { useBasicTypeaheadTriggerMatch } from '../hooks'
  12. import { INSERT_CONTEXT_BLOCK_COMMAND } from './context-block'
  13. import { INSERT_VARIABLE_BLOCK_COMMAND } from './variable-block'
  14. import { INSERT_HISTORY_BLOCK_COMMAND } from './history-block'
  15. import { INSERT_QUERY_BLOCK_COMMAND } from './query-block'
  16. import { File05 } from '@/app/components/base/icons/src/vender/solid/files'
  17. import { Variable } from '@/app/components/base/icons/src/vender/line/development'
  18. import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
  19. import { UserEdit02 } from '@/app/components/base/icons/src/vender/solid/users'
  20. class ComponentPickerOption extends MenuOption {
  21. title: string
  22. icon?: JSX.Element
  23. keywords: Array<string>
  24. keyboardShortcut?: string
  25. desc: string
  26. onSelect: (queryString: string) => void
  27. disabled?: boolean
  28. constructor(
  29. title: string,
  30. options: {
  31. icon?: JSX.Element
  32. keywords?: Array<string>
  33. keyboardShortcut?: string
  34. desc: string
  35. onSelect: (queryString: string) => void
  36. disabled?: boolean
  37. },
  38. ) {
  39. super(title)
  40. this.title = title
  41. this.keywords = options.keywords || []
  42. this.icon = options.icon
  43. this.keyboardShortcut = options.keyboardShortcut
  44. this.desc = options.desc
  45. this.onSelect = options.onSelect.bind(this)
  46. this.disabled = options.disabled
  47. }
  48. }
  49. type ComponentPickerMenuItemProps = {
  50. isSelected: boolean
  51. onClick: () => void
  52. onMouseEnter: () => void
  53. option: ComponentPickerOption
  54. }
  55. const ComponentPickerMenuItem: FC<ComponentPickerMenuItemProps> = ({
  56. isSelected,
  57. onClick,
  58. onMouseEnter,
  59. option,
  60. }) => {
  61. const { t } = useTranslation()
  62. return (
  63. <div
  64. key={option.key}
  65. className={`
  66. flex items-center px-3 py-1.5 rounded-lg
  67. ${isSelected && !option.disabled && '!bg-gray-50'}
  68. ${option.disabled ? 'cursor-not-allowed opacity-30' : 'hover:bg-gray-50 cursor-pointer'}
  69. `}
  70. tabIndex={-1}
  71. ref={option.setRefElement}
  72. onMouseEnter={onMouseEnter}
  73. onClick={onClick}>
  74. <div className='flex items-center justify-center mr-2 w-8 h-8 rounded-lg border border-gray-100'>
  75. {option.icon}
  76. </div>
  77. <div className='grow'>
  78. <div className='flex items-center justify-between h-5 text-sm text-gray-900'>
  79. {option.title}
  80. <span className='text-xs text-gray-400'>{option.disabled && t('common.promptEditor.existed')}</span>
  81. </div>
  82. <div className='text-xs text-gray-500'>{option.desc}</div>
  83. </div>
  84. </div>
  85. )
  86. }
  87. type ComponentPickerProps = {
  88. contextDisabled?: boolean
  89. historyDisabled?: boolean
  90. queryDisabled?: boolean
  91. historyShow?: boolean
  92. queryShow?: boolean
  93. }
  94. const ComponentPicker: FC<ComponentPickerProps> = ({
  95. contextDisabled,
  96. historyDisabled,
  97. queryDisabled,
  98. historyShow,
  99. queryShow,
  100. }) => {
  101. const { t } = useTranslation()
  102. const [editor] = useLexicalComposerContext()
  103. const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
  104. minLength: 0,
  105. maxLength: 0,
  106. })
  107. const options = [
  108. new ComponentPickerOption(t('common.promptEditor.context.item.title'), {
  109. desc: t('common.promptEditor.context.item.desc'),
  110. icon: <File05 className='w-4 h-4 text-[#6938EF]' />,
  111. onSelect: () => {
  112. if (contextDisabled)
  113. return
  114. editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined)
  115. },
  116. disabled: contextDisabled,
  117. }),
  118. new ComponentPickerOption(t('common.promptEditor.variable.item.title'), {
  119. desc: t('common.promptEditor.variable.item.desc'),
  120. icon: <Variable className='w-4 h-4 text-[#2970FF]' />,
  121. onSelect: () => {
  122. editor.dispatchCommand(INSERT_VARIABLE_BLOCK_COMMAND, undefined)
  123. },
  124. }),
  125. ...historyShow
  126. ? [
  127. new ComponentPickerOption(t('common.promptEditor.history.item.title'), {
  128. desc: t('common.promptEditor.history.item.desc'),
  129. icon: <MessageClockCircle className='w-4 h-4 text-[#DD2590]' />,
  130. onSelect: () => {
  131. if (historyDisabled)
  132. return
  133. editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
  134. },
  135. disabled: historyDisabled,
  136. }),
  137. ]
  138. : [],
  139. ...queryShow
  140. ? [
  141. new ComponentPickerOption(t('common.promptEditor.query.item.title'), {
  142. desc: t('common.promptEditor.query.item.desc'),
  143. icon: <UserEdit02 className='w-4 h-4 text-[#FD853A]' />,
  144. onSelect: () => {
  145. if (queryDisabled)
  146. return
  147. editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
  148. },
  149. disabled: queryDisabled,
  150. }),
  151. ]
  152. : [],
  153. ]
  154. const onSelectOption = useCallback(
  155. (
  156. selectedOption: ComponentPickerOption,
  157. nodeToRemove: TextNode | null,
  158. closeMenu: () => void,
  159. matchingString: string,
  160. ) => {
  161. editor.update(() => {
  162. if (nodeToRemove)
  163. nodeToRemove.remove()
  164. selectedOption.onSelect(matchingString)
  165. closeMenu()
  166. })
  167. },
  168. [editor],
  169. )
  170. return (
  171. <LexicalTypeaheadMenuPlugin
  172. options={options}
  173. onQueryChange={() => {}}
  174. onSelectOption={onSelectOption}
  175. menuRenderFn={(
  176. anchorElementRef,
  177. { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
  178. ) =>
  179. (anchorElementRef.current && options.length)
  180. ? ReactDOM.createPortal(
  181. <div className='mt-[25px] p-1 w-[400px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
  182. {options.map((option, i: number) => (
  183. <ComponentPickerMenuItem
  184. isSelected={selectedIndex === i}
  185. onClick={() => {
  186. if (option.disabled)
  187. return
  188. setHighlightedIndex(i)
  189. selectOptionAndCleanUp(option)
  190. }}
  191. onMouseEnter={() => {
  192. if (option.disabled)
  193. return
  194. setHighlightedIndex(i)
  195. }}
  196. key={option.key}
  197. option={option}
  198. />
  199. ))}
  200. </div>,
  201. anchorElementRef.current,
  202. )
  203. : null}
  204. triggerFn={checkForTriggerMatch}
  205. />
  206. )
  207. }
  208. export default ComponentPicker