component-picker.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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. contextShow?: boolean
  92. historyShow?: boolean
  93. queryShow?: boolean
  94. }
  95. const ComponentPicker: FC<ComponentPickerProps> = ({
  96. contextDisabled,
  97. historyDisabled,
  98. queryDisabled,
  99. contextShow,
  100. historyShow,
  101. queryShow,
  102. }) => {
  103. const { t } = useTranslation()
  104. const [editor] = useLexicalComposerContext()
  105. const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
  106. minLength: 0,
  107. maxLength: 0,
  108. })
  109. const options = [
  110. ...contextShow
  111. ? [
  112. new ComponentPickerOption(t('common.promptEditor.context.item.title'), {
  113. desc: t('common.promptEditor.context.item.desc'),
  114. icon: <File05 className='w-4 h-4 text-[#6938EF]' />,
  115. onSelect: () => {
  116. if (contextDisabled)
  117. return
  118. editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined)
  119. },
  120. disabled: contextDisabled,
  121. }),
  122. ]
  123. : [],
  124. new ComponentPickerOption(t('common.promptEditor.variable.item.title'), {
  125. desc: t('common.promptEditor.variable.item.desc'),
  126. icon: <Variable className='w-4 h-4 text-[#2970FF]' />,
  127. onSelect: () => {
  128. editor.dispatchCommand(INSERT_VARIABLE_BLOCK_COMMAND, undefined)
  129. },
  130. }),
  131. ...historyShow
  132. ? [
  133. new ComponentPickerOption(t('common.promptEditor.history.item.title'), {
  134. desc: t('common.promptEditor.history.item.desc'),
  135. icon: <MessageClockCircle className='w-4 h-4 text-[#DD2590]' />,
  136. onSelect: () => {
  137. if (historyDisabled)
  138. return
  139. editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
  140. },
  141. disabled: historyDisabled,
  142. }),
  143. ]
  144. : [],
  145. ...queryShow
  146. ? [
  147. new ComponentPickerOption(t('common.promptEditor.query.item.title'), {
  148. desc: t('common.promptEditor.query.item.desc'),
  149. icon: <UserEdit02 className='w-4 h-4 text-[#FD853A]' />,
  150. onSelect: () => {
  151. if (queryDisabled)
  152. return
  153. editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
  154. },
  155. disabled: queryDisabled,
  156. }),
  157. ]
  158. : [],
  159. ]
  160. const onSelectOption = useCallback(
  161. (
  162. selectedOption: ComponentPickerOption,
  163. nodeToRemove: TextNode | null,
  164. closeMenu: () => void,
  165. matchingString: string,
  166. ) => {
  167. editor.update(() => {
  168. if (nodeToRemove)
  169. nodeToRemove.remove()
  170. selectedOption.onSelect(matchingString)
  171. closeMenu()
  172. })
  173. },
  174. [editor],
  175. )
  176. return (
  177. <LexicalTypeaheadMenuPlugin
  178. options={options}
  179. onQueryChange={() => {}}
  180. onSelectOption={onSelectOption}
  181. menuRenderFn={(
  182. anchorElementRef,
  183. { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
  184. ) =>
  185. (anchorElementRef.current && options.length)
  186. ? ReactDOM.createPortal(
  187. <div className='mt-[25px] p-1 w-[400px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
  188. {options.map((option, i: number) => (
  189. <ComponentPickerMenuItem
  190. isSelected={selectedIndex === i}
  191. onClick={() => {
  192. if (option.disabled)
  193. return
  194. setHighlightedIndex(i)
  195. selectOptionAndCleanUp(option)
  196. }}
  197. onMouseEnter={() => {
  198. if (option.disabled)
  199. return
  200. setHighlightedIndex(i)
  201. }}
  202. key={option.key}
  203. option={option}
  204. />
  205. ))}
  206. </div>,
  207. anchorElementRef.current,
  208. )
  209. : null}
  210. triggerFn={checkForTriggerMatch}
  211. />
  212. )
  213. }
  214. export default ComponentPicker