Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

selection-contextmenu.tsx 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  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. // Find container nodes and their children
  219. // Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
  220. // when the container is selected. This prevents child nodes from being moved outside their containers.
  221. const childNodeIds = new Set<string>()
  222. nodes.forEach((node) => {
  223. // Check if this is a container node (Iteration or Loop)
  224. if (node.data._children && node.data._children.length > 0) {
  225. // If container node is selected, add its children to the exclusion set
  226. if (selectedNodeIds.includes(node.id)) {
  227. // Add all its children to the childNodeIds set
  228. node.data._children.forEach((child: { nodeId: string; nodeType: string }) => {
  229. childNodeIds.add(child.nodeId)
  230. })
  231. }
  232. }
  233. })
  234. // Filter out child nodes from the alignment operation
  235. // Only align nodes that are selected AND are not children of container nodes
  236. // This ensures container nodes can be aligned while their children stay in the same relative position
  237. const nodesToAlign = nodes.filter(node =>
  238. selectedNodeIds.includes(node.id) && !childNodeIds.has(node.id))
  239. if (nodesToAlign.length <= 1) {
  240. handleSelectionContextmenuCancel()
  241. return
  242. }
  243. // Calculate node boundaries for alignment
  244. let minX = Number.MAX_SAFE_INTEGER
  245. let maxX = Number.MIN_SAFE_INTEGER
  246. let minY = Number.MAX_SAFE_INTEGER
  247. let maxY = Number.MIN_SAFE_INTEGER
  248. // Calculate boundaries of selected nodes
  249. const validNodes = nodesToAlign.filter(node => node.width && node.height)
  250. validNodes.forEach((node) => {
  251. const width = node.width!
  252. const height = node.height!
  253. minX = Math.min(minX, node.position.x)
  254. maxX = Math.max(maxX, node.position.x + width)
  255. minY = Math.min(minY, node.position.y)
  256. maxY = Math.max(maxY, node.position.y + height)
  257. })
  258. // Handle distribute nodes logic
  259. if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) {
  260. const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType)
  261. if (distributeNodes) {
  262. // Apply node distribution updates
  263. store.getState().setNodes(distributeNodes)
  264. handleSelectionContextmenuCancel()
  265. // Clear guide lines
  266. const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
  267. setHelpLineHorizontal()
  268. setHelpLineVertical()
  269. // Sync workflow draft
  270. handleSyncWorkflowDraft()
  271. // Save to history
  272. saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
  273. return // End function execution
  274. }
  275. }
  276. const newNodes = produce(nodes, (draft) => {
  277. // Iterate through all selected nodes
  278. const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height)
  279. validNodesToAlign.forEach((nodeToAlign) => {
  280. // Find the corresponding node in draft - consistent with handleNodeDrag
  281. const currentNode = draft.find(n => n.id === nodeToAlign.id)
  282. if (!currentNode)
  283. return
  284. // Use the extracted alignment function
  285. handleAlignNode(currentNode, nodeToAlign, alignType, minX, maxX, minY, maxY)
  286. })
  287. })
  288. // Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop
  289. try {
  290. // Directly use setNodes to update nodes - consistent with handleNodeDrag
  291. store.getState().setNodes(newNodes)
  292. // Close popup
  293. handleSelectionContextmenuCancel()
  294. // Clear guide lines - consistent with handleNodeDragStop
  295. const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
  296. setHelpLineHorizontal()
  297. setHelpLineVertical()
  298. // Sync workflow draft - consistent with handleNodeDragStop
  299. handleSyncWorkflowDraft()
  300. // Save to history - consistent with handleNodeDragStop
  301. saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
  302. }
  303. catch (err) {
  304. console.error('Failed to update nodes:', err)
  305. }
  306. }, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
  307. if (!selectionMenu)
  308. return null
  309. return (
  310. <div
  311. className='absolute z-[9]'
  312. style={{
  313. left: menuPosition.left,
  314. top: menuPosition.top,
  315. }}
  316. ref={ref}
  317. >
  318. <div ref={menuRef} className='w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'>
  319. <div className='p-1'>
  320. <div className='system-xs-medium px-2 py-2 text-text-tertiary'>
  321. {t('workflow.operator.vertical')}
  322. </div>
  323. <div
  324. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  325. onClick={() => handleAlignNodes(AlignType.Top)}
  326. >
  327. <RiAlignTop className='h-4 w-4' />
  328. {t('workflow.operator.alignTop')}
  329. </div>
  330. <div
  331. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  332. onClick={() => handleAlignNodes(AlignType.Middle)}
  333. >
  334. <RiAlignCenter className='h-4 w-4 rotate-90' />
  335. {t('workflow.operator.alignMiddle')}
  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.Bottom)}
  340. >
  341. <RiAlignBottom className='h-4 w-4' />
  342. {t('workflow.operator.alignBottom')}
  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.DistributeVertical)}
  347. >
  348. <RiAlignJustify className='h-4 w-4 rotate-90' />
  349. {t('workflow.operator.distributeVertical')}
  350. </div>
  351. </div>
  352. <div className='h-[1px] bg-divider-regular'></div>
  353. <div className='p-1'>
  354. <div className='system-xs-medium px-2 py-2 text-text-tertiary'>
  355. {t('workflow.operator.horizontal')}
  356. </div>
  357. <div
  358. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  359. onClick={() => handleAlignNodes(AlignType.Left)}
  360. >
  361. <RiAlignLeft className='h-4 w-4' />
  362. {t('workflow.operator.alignLeft')}
  363. </div>
  364. <div
  365. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  366. onClick={() => handleAlignNodes(AlignType.Center)}
  367. >
  368. <RiAlignCenter className='h-4 w-4' />
  369. {t('workflow.operator.alignCenter')}
  370. </div>
  371. <div
  372. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  373. onClick={() => handleAlignNodes(AlignType.Right)}
  374. >
  375. <RiAlignRight className='h-4 w-4' />
  376. {t('workflow.operator.alignRight')}
  377. </div>
  378. <div
  379. className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
  380. onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
  381. >
  382. <RiAlignJustify className='h-4 w-4' />
  383. {t('workflow.operator.distributeHorizontal')}
  384. </div>
  385. </div>
  386. </div>
  387. </div>
  388. )
  389. }
  390. export default memo(SelectionContextmenu)