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 8.0KB

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