index.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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. ExternalToolBlockType,
  21. HistoryBlockType,
  22. QueryBlockType,
  23. VariableBlockType,
  24. WorkflowVariableBlockType,
  25. } from '../../types'
  26. import { useBasicTypeaheadTriggerMatch } from '../../hooks'
  27. import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block'
  28. import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
  29. import { $splitNodeContainingQuery } from '../../utils'
  30. import { useOptions } from './hooks'
  31. import type { PickerBlockMenuOption } from './menu'
  32. import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
  33. import { useEventEmitterContextContext } from '@/context/event-emitter'
  34. type ComponentPickerProps = {
  35. triggerString: string
  36. contextBlock?: ContextBlockType
  37. queryBlock?: QueryBlockType
  38. historyBlock?: HistoryBlockType
  39. variableBlock?: VariableBlockType
  40. externalToolBlock?: ExternalToolBlockType
  41. workflowVariableBlock?: WorkflowVariableBlockType
  42. isSupportFileVar?: boolean
  43. }
  44. const ComponentPicker = ({
  45. triggerString,
  46. contextBlock,
  47. queryBlock,
  48. historyBlock,
  49. variableBlock,
  50. externalToolBlock,
  51. workflowVariableBlock,
  52. isSupportFileVar,
  53. }: ComponentPickerProps) => {
  54. const { eventEmitter } = useEventEmitterContextContext()
  55. const { refs, floatingStyles, isPositioned } = useFloating({
  56. placement: 'bottom-start',
  57. middleware: [
  58. offset(0), // fix hide cursor
  59. shift({
  60. padding: 8,
  61. }),
  62. flip(),
  63. ],
  64. })
  65. const [editor] = useLexicalComposerContext()
  66. const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
  67. minLength: 0,
  68. maxLength: 0,
  69. })
  70. const [queryString, setQueryString] = useState<string | null>(null)
  71. eventEmitter?.useSubscription((v: any) => {
  72. if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
  73. editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
  74. })
  75. const {
  76. allFlattenOptions,
  77. workflowVariableOptions,
  78. } = useOptions(
  79. contextBlock,
  80. queryBlock,
  81. historyBlock,
  82. variableBlock,
  83. externalToolBlock,
  84. workflowVariableBlock,
  85. )
  86. const onSelectOption = useCallback(
  87. (
  88. selectedOption: PickerBlockMenuOption,
  89. nodeToRemove: TextNode | null,
  90. closeMenu: () => void,
  91. ) => {
  92. editor.update(() => {
  93. if (nodeToRemove && selectedOption?.key)
  94. nodeToRemove.remove()
  95. selectedOption.onSelectMenuOption()
  96. closeMenu()
  97. })
  98. },
  99. [editor],
  100. )
  101. const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
  102. editor.update(() => {
  103. const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
  104. if (needRemove)
  105. needRemove.remove()
  106. })
  107. if (variables[1] === 'sys.query' || variables[1] === 'sys.files')
  108. editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [variables[1]])
  109. else
  110. editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
  111. }, [editor, checkForTriggerMatch, triggerString])
  112. const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
  113. anchorElementRef,
  114. { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
  115. ) => {
  116. if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
  117. return null
  118. refs.setReference(anchorElementRef.current)
  119. return (
  120. <>
  121. {
  122. ReactDOM.createPortal(
  123. // The `LexicalMenu` will try to calculate the position of the floating menu based on the first child.
  124. // Since we use floating ui, we need to wrap it with a div to prevent the position calculation being affected.
  125. // See https://github.com/facebook/lexical/blob/ac97dfa9e14a73ea2d6934ff566282d7f758e8bb/packages/lexical-react/src/shared/LexicalMenu.ts#L493
  126. <div className='w-0 h-0'>
  127. <div
  128. className='p-1 w-[260px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'
  129. style={{
  130. ...floatingStyles,
  131. visibility: isPositioned ? 'visible' : 'hidden',
  132. }}
  133. ref={refs.setFloating}
  134. >
  135. {
  136. options.map((option, index) => (
  137. <Fragment key={option.key}>
  138. {
  139. // Divider
  140. index !== 0 && options.at(index - 1)?.group !== option.group && (
  141. <div className='h-px bg-gray-100 my-1 w-full -translate-x-1'></div>
  142. )
  143. }
  144. {option.renderMenuOption({
  145. queryString,
  146. isSelected: selectedIndex === index,
  147. onSelect: () => {
  148. selectOptionAndCleanUp(option)
  149. },
  150. onSetHighlight: () => {
  151. setHighlightedIndex(index)
  152. },
  153. })}
  154. </Fragment>
  155. ))
  156. }
  157. {
  158. workflowVariableBlock?.show && (
  159. <>
  160. {
  161. (!!options.length) && (
  162. <div className='h-px bg-gray-100 my-1 w-full -translate-x-1'></div>
  163. )
  164. }
  165. <div className='p-1'>
  166. <VarReferenceVars
  167. hideSearch
  168. vars={workflowVariableOptions}
  169. onChange={(variables: string[]) => {
  170. handleSelectWorkflowVariable(variables)
  171. }}
  172. maxHeightClass='max-h-[34vh]'
  173. isSupportFileVar={isSupportFileVar}
  174. />
  175. </div>
  176. </>
  177. )
  178. }
  179. </div>
  180. </div>,
  181. anchorElementRef.current,
  182. )
  183. }
  184. </>
  185. )
  186. }, [allFlattenOptions.length, workflowVariableBlock?.show, refs, isPositioned, floatingStyles, queryString, workflowVariableOptions, handleSelectWorkflowVariable])
  187. return (
  188. <LexicalTypeaheadMenuPlugin
  189. options={allFlattenOptions}
  190. onQueryChange={setQueryString}
  191. onSelectOption={onSelectOption}
  192. // The `translate` class is used to workaround the issue that the `typeahead-menu` menu is not positioned as expected.
  193. // See also https://github.com/facebook/lexical/blob/772520509308e8ba7e4a82b6cd1996a78b3298d0/packages/lexical-react/src/shared/LexicalMenu.ts#L498
  194. //
  195. // We no need the position function of the `LexicalTypeaheadMenuPlugin`,
  196. // 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.
  197. anchorClassName='z-[999999] translate-y-[calc(-100%-3px)]'
  198. menuRenderFn={renderMenu}
  199. triggerFn={checkForTriggerMatch}
  200. />
  201. )
  202. }
  203. export default memo(ComponentPicker)