Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

selection-contextmenu.tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import {
  2. memo,
  3. useCallback,
  4. useEffect,
  5. useMemo,
  6. useRef,
  7. } from 'react'
  8. import { useTranslation } from 'react-i18next'
  9. import { useClickAway } from 'ahooks'
  10. import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
  11. import {
  12. RiAlignBottom,
  13. RiAlignCenter,
  14. RiAlignJustify,
  15. RiAlignLeft,
  16. RiAlignRight,
  17. RiAlignTop,
  18. } from '@remixicon/react'
  19. import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
  20. import produce from 'immer'
  21. import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history'
  22. import { useStore } from './store'
  23. import { useSelectionInteractions } from './hooks/use-selection-interactions'
  24. import { useWorkflowStore } from './store'
  25. enum AlignType {
  26. Left = 'left',
  27. Center = 'center',
  28. Right = 'right',
  29. Top = 'top',
  30. Middle = 'middle',
  31. Bottom = 'bottom',
  32. DistributeHorizontal = 'distributeHorizontal',
  33. DistributeVertical = 'distributeVertical',
  34. }
  35. const SelectionContextmenu = () => {
  36. const { t } = useTranslation()
  37. const ref = useRef(null)
  38. const { getNodesReadOnly } = useNodesReadOnly()
  39. const { handleSelectionContextmenuCancel } = useSelectionInteractions()
  40. const selectionMenu = useStore(s => s.selectionMenu)
  41. // Access React Flow methods
  42. const store = useStoreApi()
  43. const workflowStore = useWorkflowStore()
  44. // Get selected nodes for alignment logic
  45. const selectedNodes = useReactFlowStore(state =>
  46. state.getNodes().filter(node => node.selected),
  47. )
  48. const { handleSyncWorkflowDraft } = useNodesSyncDraft()
  49. const { saveStateToHistory } = useWorkflowHistory()
  50. const menuRef = useRef<HTMLDivElement>(null)
  51. const menuPosition = useMemo(() => {
  52. if (!selectionMenu) return { left: 0, top: 0 }
  53. let left = selectionMenu.left
  54. let top = selectionMenu.top
  55. const container = document.querySelector('#workflow-container')
  56. if (container) {
  57. const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect()
  58. const menuWidth = 240
  59. const estimatedMenuHeight = 380
  60. if (left + menuWidth > containerWidth)
  61. left = left - menuWidth
  62. if (top + estimatedMenuHeight > containerHeight)
  63. top = top - estimatedMenuHeight
  64. left = Math.max(0, left)
  65. top = Math.max(0, top)
  66. }
  67. return { left, top }
  68. }, [selectionMenu])
  69. useClickAway(() => {
  70. handleSelectionContextmenuCancel()
  71. }, ref)
  72. useEffect(() => {
  73. if (selectionMenu && selectedNodes.length <= 1)
  74. handleSelectionContextmenuCancel()
  75. }, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
  76. // Handle align nodes logic
  77. const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
  78. const width = nodeToAlign.width
  79. const height = nodeToAlign.height
  80. // Calculate new positions based on alignment type
  81. switch (alignType) {
  82. case AlignType.Left:
  83. // For left alignment, align left edge of each node to minX
  84. currentNode.position.x = minX
  85. if (currentNode.positionAbsolute)
  86. currentNode.positionAbsolute.x = minX
  87. break
  88. case AlignType.Center: {
  89. // For center alignment, center each node horizontally in the selection bounds
  90. const centerX = minX + (maxX - minX) / 2 - width / 2
  91. currentNode.position.x = centerX
  92. if (currentNode.positionAbsolute)
  93. currentNode.positionAbsolute.x = centerX
  94. break
  95. }
  96. case AlignType.Right: {
  97. // For right alignment, align right edge of each node to maxX
  98. const rightX = maxX - width
  99. currentNode.position.x = rightX
  100. if (currentNode.positionAbsolute)
  101. currentNode.positionAbsolute.x = rightX
  102. break
  103. }
  104. case AlignType.Top: {
  105. // For top alignment, align top edge of each node to minY
  106. currentNode.position.y = minY
  107. if (currentNode.positionAbsolute)
  108. currentNode.positionAbsolute.y = minY
  109. break
  110. }
  111. case AlignType.Middle: {
  112. // For middle alignment, center each node vertically in the selection bounds
  113. const middleY = minY + (maxY - minY) / 2 - height / 2
  114. currentNode.position.y = middleY
  115. if (currentNode.positionAbsolute)
  116. currentNode.positionAbsolute.y = middleY
  117. break
  118. }
  119. case AlignType.Bottom: {
  120. // For bottom alignment, align bottom edge of each node to maxY
  121. const newY = Math.round(maxY - height)
  122. currentNode.position.y = newY
  123. if (currentNode.positionAbsolute)
  124. currentNode.positionAbsolute.y = newY
  125. break
  126. }
  127. }
  128. }, [])
  129. // Handle distribute nodes logic
  130. const handleDistributeNodes = useCallback((nodesToAlign: any[], nodes: any[], alignType: AlignType) => {
  131. // Sort nodes appropriately
  132. const sortedNodes = [...nodesToAlign].sort((a, b) => {
  133. if (alignType === AlignType.DistributeHorizontal) {
  134. // Sort by left position for horizontal distribution
  135. return a.position.x - b.position.x
  136. }
  137. else {
  138. // Sort by top position for vertical distribution
  139. return a.position.y - b.position.y
  140. }
  141. })
  142. if (sortedNodes.length < 3)
  143. return null // Need at least 3 nodes for distribution
  144. let totalGap = 0
  145. let fixedSpace = 0
  146. if (alignType === AlignType.DistributeHorizontal) {
  147. // Fixed positions - first node's left edge and last node's right edge
  148. const firstNodeLeft = sortedNodes[0].position.x
  149. const lastNodeRight = sortedNodes[sortedNodes.length - 1].position.x + (sortedNodes[sortedNodes.length - 1].width || 0)
  150. // Total available space
  151. totalGap = lastNodeRight - firstNodeLeft
  152. // Space occupied by nodes themselves
  153. fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.width || 0), 0)
  154. }
  155. else {
  156. // Fixed positions - first node's top edge and last node's bottom edge
  157. const firstNodeTop = sortedNodes[0].position.y
  158. const lastNodeBottom = sortedNodes[sortedNodes.length - 1].position.y + (sortedNodes[sortedNodes.length - 1].height || 0)
  159. // Total available space
  160. totalGap = lastNodeBottom - firstNodeTop
  161. // Space occupied by nodes themselves
  162. fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.height || 0), 0)
  163. }
  164. // Available space for gaps
  165. const availableSpace = totalGap - fixedSpace
  166. // Calculate even spacing between node edges
  167. const spacing = availableSpace / (sortedNodes.length - 1)
  168. if (spacing <= 0)
  169. return null // Nodes are overlapping, can't distribute evenly
  170. return produce(nodes, (draft) => {
  171. // Keep first node fixed, position others with even gaps
  172. let currentPosition
  173. if (alignType === AlignType.DistributeHorizontal) {
  174. // Start from first node's right edge
  175. currentPosition = sortedNodes[0].position.x + (sortedNodes[0].width || 0)
  176. }
  177. else {
  178. // Start from first node's bottom edge
  179. currentPosition = sortedNodes[0].position.y + (sortedNodes[0].height || 0)
  180. }
  181. // Skip first node (index 0), it stays in place
  182. for (let i = 1; i < sortedNodes.length - 1; i++) {
  183. const nodeToAlign = sortedNodes[i]
  184. const currentNode = draft.find(n => n.id === nodeToAlign.id)
  185. if (!currentNode) continue
  186. if (alignType === AlignType.DistributeHorizontal) {
  187. // Position = previous right edge + spacing
  188. const newX: number = currentPosition + spacing
  189. currentNode.position.x = newX
  190. if (currentNode.positionAbsolute)
  191. currentNode.positionAbsolute.x = newX
  192. // Update for next iteration - current node's right edge
  193. currentPosition = newX + (nodeToAlign.width || 0)
  194. }
  195. else {
  196. // Position = previous bottom edge + spacing
  197. const newY: number = currentPosition + spacing
  198. currentNode.position.y = newY
  199. if (currentNode.positionAbsolute)
  200. currentNode.positionAbsolute.y = newY
  201. // Update for next iteration - current node's bottom edge
  202. currentPosition = newY + (nodeToAlign.height || 0)
  203. }
  204. }
  205. })
  206. }, [])
  207. const handleAlignNodes = useCallback((alignType: AlignType) => {
  208. if (getNodesReadOnly() || selectedNodes.length <= 1) {
  209. handleSelectionContextmenuCancel()
  210. return
  211. }
  212. // Disable node animation state - same as handleNodeDragStart
  213. workflowStore.setState({ nodeAnimation: false })
  214. // Get all current nodes
  215. const nodes = store.getState().getNodes()
  216. // Get all selected nodes
  217. const selectedNodeIds = selectedNodes.map(node => node.id)
  218. const nodesToAlign = nodes.filter(node => selectedNodeIds.includes(node.id))
  219. if (nodesToAlign.length <= 1) {
  220. handleSelectionContextmenuCancel()
  221. return
  222. }
  223. // Calculate node boundaries for alignment
  224. let minX = Number.MAX_SAFE_INTEGER
  225. let maxX = Number.MIN_SAFE_INTEGER
  226. let minY = Number.MAX_SAFE_INTEGER
  227. let maxY = Number.MIN_SAFE_INTEGER
  228. // Calculate boundaries of selected nodes
  229. const validNodes = nodesToAlign.filter(node => node.width && node.height)
  230. validNodes.forEach((node) => {
  231. const width = node.width!
  232. const height = node.height!
  233. minX = Math.min(minX, node.position.x)
  234. maxX = Math.max(maxX, node.position.x + width)
  235. minY = Math.min(minY, node.position.y)
  236. maxY = Math.max(maxY, node.position.y + height)
  237. })
  238. // Handle distribute nodes logic
  239. if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) {
  240. const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType)
  241. if (distributeNodes) {
  242. // Apply node distribution updates
  243. store.getState().setNodes(distributeNodes)
  244. handleSelectionContextmenuCancel()
  245. // Clear guide lines
  246. const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
  247. setHelpLineHorizontal()
  248. setHelpLineVertical()
  249. // Sync workflow draft
  250. handleSyncWorkflowDraft()
  251. // Save to history
  252. saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
  253. return // End function execution
  254. }
  255. }
  256. const newNodes = produce(nodes, (draft) => {
  257. // Iterate through all selected nodes
  258. const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height)
  259. validNodesToAlign.forEach((nodeToAlign) => {
  260. // Find the corresponding node in draft - consistent with handleNodeDrag
  261. const currentNode = draft.find(n => n.id === nodeToAlign.id)
  262. if (!currentNode)
  263. return
  264. // Use the extracted alignment function
  265. handleAlignNode(currentNode, nodeToAlign, alignType, minX, maxX, minY, maxY)
  266. })
  267. })
  268. // Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop
  269. try {
  270. // Directly use setNodes to update nodes - consistent with handleNodeDrag
  271. store.getState().setNodes(newNodes)
  272. // Close popup
  273. handleSelectionContextmenuCancel()
  274. // Clear guide lines - consistent with handleNodeDragStop
  275. const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
  276. setHelpLineHorizontal()
  277. setHelpLineVertical()
  278. // Sync workflow draft - consistent with handleNodeDragStop
  279. handleSyncWorkflowDraft()
  280. // Save to history - consistent with handleNodeDragStop
  281. saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
  282. }
  283. catch (err) {
  284. console.error('Failed to update nodes:', err)
  285. }
  286. }, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
  287. if (!selectionMenu)
  288. return null
  289. return (
  290. <div
  291. className='absolute z-[9]'
  292. style={{
  293. left: menuPosition.left,
  294. top: menuPosition.top,
  295. }}
  296. ref={ref}
  297. >
  298. <div ref={menuRef} className='w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'>
  299. <div className='p-1'>
  300. <div className='system-xs-medium px-2 py-2 text-text-tertiary'>
  301. {t('workflow.operator.vertical')}
  302. </div>
  303. <div
  304. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  305. onClick={() => handleAlignNodes(AlignType.Top)}
  306. >
  307. <RiAlignTop className='h-4 w-4' />
  308. {t('workflow.operator.alignTop')}
  309. </div>
  310. <div
  311. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  312. onClick={() => handleAlignNodes(AlignType.Middle)}
  313. >
  314. <RiAlignCenter className='h-4 w-4 rotate-90' />
  315. {t('workflow.operator.alignMiddle')}
  316. </div>
  317. <div
  318. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  319. onClick={() => handleAlignNodes(AlignType.Bottom)}
  320. >
  321. <RiAlignBottom className='h-4 w-4' />
  322. {t('workflow.operator.alignBottom')}
  323. </div>
  324. <div
  325. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  326. onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
  327. >
  328. <RiAlignJustify className='h-4 w-4 rotate-90' />
  329. {t('workflow.operator.distributeVertical')}
  330. </div>
  331. </div>
  332. <div className='h-[1px] bg-divider-regular'></div>
  333. <div className='p-1'>
  334. <div className='system-xs-medium px-2 py-2 text-text-tertiary'>
  335. {t('workflow.operator.horizontal')}
  336. </div>
  337. <div
  338. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  339. onClick={() => handleAlignNodes(AlignType.Left)}
  340. >
  341. <RiAlignLeft className='h-4 w-4' />
  342. {t('workflow.operator.alignLeft')}
  343. </div>
  344. <div
  345. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  346. onClick={() => handleAlignNodes(AlignType.Center)}
  347. >
  348. <RiAlignCenter className='h-4 w-4' />
  349. {t('workflow.operator.alignCenter')}
  350. </div>
  351. <div
  352. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  353. onClick={() => handleAlignNodes(AlignType.Right)}
  354. >
  355. <RiAlignRight className='h-4 w-4' />
  356. {t('workflow.operator.alignRight')}
  357. </div>
  358. <div
  359. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  360. onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
  361. >
  362. <RiAlignJustify className='h-4 w-4' />
  363. {t('workflow.operator.distributeHorizontal')}
  364. </div>
  365. </div>
  366. </div>
  367. </div>
  368. )
  369. }
  370. export default memo(SelectionContextmenu)