You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.tsx 9.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import {
  2. Fragment,
  3. memo,
  4. useCallback,
  5. useState,
  6. } from 'react'
  7. import ReactDOM from 'react-dom'
  8. import {
  9. flip,
  10. offset,
  11. shift,
  12. useFloating,
  13. } from '@floating-ui/react'
  14. import type { TextNode } from 'lexical'
  15. import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin'
  16. import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
  17. import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
  18. import type {
  19. ContextBlockType,
  20. CurrentBlockType,
  21. ErrorMessageBlockType,
  22. ExternalToolBlockType,
  23. HistoryBlockType,
  24. LastRunBlockType,
  25. QueryBlockType,
  26. VariableBlockType,
  27. WorkflowVariableBlockType,
  28. } from '../../types'
  29. import { useBasicTypeaheadTriggerMatch } from '../../hooks'
  30. import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block'
  31. import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
  32. import { $splitNodeContainingQuery } from '../../utils'
  33. import { useOptions } from './hooks'
  34. import type { PickerBlockMenuOption } from './menu'
  35. import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
  36. import { useEventEmitterContextContext } from '@/context/event-emitter'
  37. import { KEY_ESCAPE_COMMAND } from 'lexical'
  38. import { INSERT_CURRENT_BLOCK_COMMAND } from '../current-block'
  39. import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
  40. import { INSERT_ERROR_MESSAGE_BLOCK_COMMAND } from '../error-message-block'
  41. import { INSERT_LAST_RUN_BLOCK_COMMAND } from '../last-run-block'
  42. type ComponentPickerProps = {
  43. triggerString: string
  44. contextBlock?: ContextBlockType
  45. queryBlock?: QueryBlockType
  46. historyBlock?: HistoryBlockType
  47. variableBlock?: VariableBlockType
  48. externalToolBlock?: ExternalToolBlockType
  49. workflowVariableBlock?: WorkflowVariableBlockType
  50. currentBlock?: CurrentBlockType
  51. errorMessageBlock?: ErrorMessageBlockType
  52. lastRunBlock?: LastRunBlockType
  53. isSupportFileVar?: boolean
  54. }
  55. const ComponentPicker = ({
  56. triggerString,
  57. contextBlock,
  58. queryBlock,
  59. historyBlock,
  60. variableBlock,
  61. externalToolBlock,
  62. workflowVariableBlock,
  63. currentBlock,
  64. errorMessageBlock,
  65. lastRunBlock,
  66. isSupportFileVar,
  67. }: ComponentPickerProps) => {
  68. const { eventEmitter } = useEventEmitterContextContext()
  69. const { refs, floatingStyles, isPositioned } = useFloating({
  70. placement: 'bottom-start',
  71. middleware: [
  72. offset(0), // fix hide cursor
  73. shift({
  74. padding: 8,
  75. }),
  76. flip(),
  77. ],
  78. })
  79. const [editor] = useLexicalComposerContext()
  80. const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
  81. minLength: 0,
  82. maxLength: 0,
  83. })
  84. const [queryString, setQueryString] = useState<string | null>(null)
  85. eventEmitter?.useSubscription((v: any) => {
  86. if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
  87. editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
  88. })
  89. const {
  90. allFlattenOptions,
  91. workflowVariableOptions,
  92. } = useOptions(
  93. contextBlock,
  94. queryBlock,
  95. historyBlock,
  96. variableBlock,
  97. externalToolBlock,
  98. workflowVariableBlock,
  99. currentBlock,
  100. errorMessageBlock,
  101. lastRunBlock,
  102. )
  103. const onSelectOption = useCallback(
  104. (
  105. selectedOption: PickerBlockMenuOption,
  106. nodeToRemove: TextNode | null,
  107. closeMenu: () => void,
  108. ) => {
  109. editor.update(() => {
  110. if (nodeToRemove && selectedOption?.key)
  111. nodeToRemove.remove()
  112. selectedOption.onSelectMenuOption()
  113. closeMenu()
  114. })
  115. },
  116. [editor],
  117. )
  118. const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
  119. editor.update(() => {
  120. const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
  121. if (needRemove)
  122. needRemove.remove()
  123. })
  124. const isFlat = variables.length === 1
  125. if(isFlat) {
  126. const varName = variables[0]
  127. if(varName === 'current')
  128. editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, currentBlock?.generatorType)
  129. else if (varName === 'error_message')
  130. editor.dispatchCommand(INSERT_ERROR_MESSAGE_BLOCK_COMMAND, null)
  131. else if (varName === 'last_run')
  132. editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, null)
  133. }
  134. else if (variables[1] === 'sys.query' || variables[1] === 'sys.files') {
  135. editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [variables[1]])
  136. }
  137. else {
  138. editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
  139. }
  140. }, [editor, currentBlock?.generatorType, checkForTriggerMatch, triggerString])
  141. const handleClose = useCallback(() => {
  142. const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' })
  143. editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent)
  144. }, [editor])
  145. const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
  146. anchorElementRef,
  147. { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
  148. ) => {
  149. if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
  150. return null
  151. setTimeout(() => {
  152. if (anchorElementRef.current)
  153. refs.setReference(anchorElementRef.current)
  154. }, 0)
  155. return (
  156. <>
  157. {
  158. ReactDOM.createPortal(
  159. // The `LexicalMenu` will try to calculate the position of the floating menu based on the first child.
  160. // Since we use floating ui, we need to wrap it with a div to prevent the position calculation being affected.
  161. // See https://github.com/facebook/lexical/blob/ac97dfa9e14a73ea2d6934ff566282d7f758e8bb/packages/lexical-react/src/shared/LexicalMenu.ts#L493
  162. <div className='h-0 w-0'>
  163. <div
  164. className='w-[260px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'
  165. style={{
  166. ...floatingStyles,
  167. visibility: isPositioned ? 'visible' : 'hidden',
  168. }}
  169. ref={refs.setFloating}
  170. >
  171. {
  172. workflowVariableBlock?.show && (
  173. <div className='p-1'>
  174. <VarReferenceVars
  175. searchBoxClassName='mt-1'
  176. vars={workflowVariableOptions}
  177. onChange={(variables: string[]) => {
  178. handleSelectWorkflowVariable(variables)
  179. }}
  180. maxHeightClass='max-h-[34vh]'
  181. isSupportFileVar={isSupportFileVar}
  182. onClose={handleClose}
  183. onBlur={handleClose}
  184. autoFocus={false}
  185. isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code}
  186. />
  187. </div>
  188. )
  189. }
  190. {
  191. workflowVariableBlock?.show && !!options.length && (
  192. <div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
  193. )
  194. }
  195. <div>
  196. {
  197. options.map((option, index) => (
  198. <Fragment key={option.key}>
  199. {
  200. // Divider
  201. index !== 0 && options.at(index - 1)?.group !== option.group && (
  202. <div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
  203. )
  204. }
  205. {option.renderMenuOption({
  206. queryString,
  207. isSelected: selectedIndex === index,
  208. onSelect: () => {
  209. selectOptionAndCleanUp(option)
  210. },
  211. onSetHighlight: () => {
  212. setHighlightedIndex(index)
  213. },
  214. })}
  215. </Fragment>
  216. ))
  217. }
  218. </div>
  219. </div>
  220. </div>,
  221. anchorElementRef.current,
  222. )
  223. }
  224. </>
  225. )
  226. }, [allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString])
  227. return (
  228. <LexicalTypeaheadMenuPlugin
  229. options={allFlattenOptions}
  230. onQueryChange={setQueryString}
  231. onSelectOption={onSelectOption}
  232. // The `translate` class is used to workaround the issue that the `typeahead-menu` menu is not positioned as expected.
  233. // See also https://github.com/facebook/lexical/blob/772520509308e8ba7e4a82b6cd1996a78b3298d0/packages/lexical-react/src/shared/LexicalMenu.ts#L498
  234. //
  235. // We no need the position function of the `LexicalTypeaheadMenuPlugin`,
  236. // so the reference anchor should be positioned based on the range of the trigger string, and the menu will be positioned by the floating ui.
  237. anchorClassName='z-[999999] translate-y-[calc(-100%-3px)]'
  238. menuRenderFn={renderMenu}
  239. triggerFn={checkForTriggerMatch}
  240. />
  241. )
  242. }
  243. export default memo(ComponentPicker)