index.tsx 7.4 KB

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