From 724ec12bf37e6cfb5e05081da62bee028828c1de Mon Sep 17 00:00:00 2001 From: GuanMu Date: Wed, 6 Aug 2025 11:01:10 +0800 Subject: [PATCH] Feat workflow node align (#23451) --- .../hooks/use-selection-interactions.ts | 24 + web/app/components/workflow/index.tsx | 4 + .../workflow/selection-contextmenu.tsx | 433 ++++++++++++++++++ .../workflow/store/workflow/panel-slice.ts | 7 + web/i18n/en-US/workflow.ts | 12 + web/i18n/zh-Hans/workflow.ts | 12 + 6 files changed, 492 insertions(+) create mode 100644 web/app/components/workflow/selection-contextmenu.tsx diff --git a/web/app/components/workflow/hooks/use-selection-interactions.ts b/web/app/components/workflow/hooks/use-selection-interactions.ts index 36aa0485a..0055549b7 100644 --- a/web/app/components/workflow/hooks/use-selection-interactions.ts +++ b/web/app/components/workflow/hooks/use-selection-interactions.ts @@ -131,10 +131,34 @@ export const useSelectionInteractions = () => { setEdges(newEdges) }, [store]) + const handleSelectionContextMenu = useCallback((e: MouseEvent) => { + const target = e.target as HTMLElement + if (!target.classList.contains('react-flow__nodesselection-rect')) + return + + e.preventDefault() + const container = document.querySelector('#workflow-container') + const { x, y } = container!.getBoundingClientRect() + workflowStore.setState({ + selectionMenu: { + top: e.clientY - y, + left: e.clientX - x, + }, + }) + }, [workflowStore]) + + const handleSelectionContextmenuCancel = useCallback(() => { + workflowStore.setState({ + selectionMenu: undefined, + }) + }, [workflowStore]) + return { handleSelectionStart, handleSelectionChange, handleSelectionDrag, handleSelectionCancel, + handleSelectionContextMenu, + handleSelectionContextmenuCancel, } } diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index a5894451c..2ebb040f0 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -65,6 +65,7 @@ import HelpLine from './help-line' import CandidateNode from './candidate-node' import PanelContextmenu from './panel-contextmenu' import NodeContextmenu from './node-contextmenu' +import SelectionContextmenu from './selection-contextmenu' import SyncingDataModal from './syncing-data-modal' import LimitTips from './limit-tips' import { @@ -263,6 +264,7 @@ export const Workflow: FC = memo(({ handleSelectionStart, handleSelectionChange, handleSelectionDrag, + handleSelectionContextMenu, } = useSelectionInteractions() const { handlePaneContextMenu, @@ -313,6 +315,7 @@ export const Workflow: FC = memo(({ + { !!showConfirm && ( @@ -349,6 +352,7 @@ export const Workflow: FC = memo(({ onSelectionChange={handleSelectionChange} onSelectionDrag={handleSelectionDrag} onPaneContextMenu={handlePaneContextMenu} + onSelectionContextMenu={handleSelectionContextMenu} connectionLineComponent={CustomConnectionLine} // TODO: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same? connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }} diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx new file mode 100644 index 000000000..71c8e97ab --- /dev/null +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -0,0 +1,433 @@ +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useClickAway } from 'ahooks' +import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' +import { + RiAlignBottom, + RiAlignCenter, + RiAlignJustify, + RiAlignLeft, + RiAlignRight, + RiAlignTop, +} from '@remixicon/react' +import { useNodesReadOnly, useNodesSyncDraft } from './hooks' +import produce from 'immer' +import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history' +import { useStore } from './store' +import { useSelectionInteractions } from './hooks/use-selection-interactions' +import { useWorkflowStore } from './store' + +enum AlignType { + Left = 'left', + Center = 'center', + Right = 'right', + Top = 'top', + Middle = 'middle', + Bottom = 'bottom', + DistributeHorizontal = 'distributeHorizontal', + DistributeVertical = 'distributeVertical', +} + +const SelectionContextmenu = () => { + const { t } = useTranslation() + const ref = useRef(null) + const { getNodesReadOnly } = useNodesReadOnly() + const { handleSelectionContextmenuCancel } = useSelectionInteractions() + const selectionMenu = useStore(s => s.selectionMenu) + + // Access React Flow methods + const store = useStoreApi() + const workflowStore = useWorkflowStore() + + // Get selected nodes for alignment logic + const selectedNodes = useReactFlowStore(state => + state.getNodes().filter(node => node.selected), + ) + + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { saveStateToHistory } = useWorkflowHistory() + + const menuRef = useRef(null) + + const menuPosition = useMemo(() => { + if (!selectionMenu) return { left: 0, top: 0 } + + let left = selectionMenu.left + let top = selectionMenu.top + + const container = document.querySelector('#workflow-container') + if (container) { + const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect() + + const menuWidth = 240 + + const estimatedMenuHeight = 380 + + if (left + menuWidth > containerWidth) + left = left - menuWidth + + if (top + estimatedMenuHeight > containerHeight) + top = top - estimatedMenuHeight + + left = Math.max(0, left) + top = Math.max(0, top) + } + + return { left, top } + }, [selectionMenu]) + + useClickAway(() => { + handleSelectionContextmenuCancel() + }, ref) + + useEffect(() => { + if (selectionMenu && selectedNodes.length <= 1) + handleSelectionContextmenuCancel() + }, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel]) + + // Handle align nodes logic + const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => { + const width = nodeToAlign.width + const height = nodeToAlign.height + + // Calculate new positions based on alignment type + switch (alignType) { + case AlignType.Left: + // For left alignment, align left edge of each node to minX + currentNode.position.x = minX + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.x = minX + break + + case AlignType.Center: { + // For center alignment, center each node horizontally in the selection bounds + const centerX = minX + (maxX - minX) / 2 - width / 2 + currentNode.position.x = centerX + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.x = centerX + break + } + + case AlignType.Right: { + // For right alignment, align right edge of each node to maxX + const rightX = maxX - width + currentNode.position.x = rightX + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.x = rightX + break + } + + case AlignType.Top: { + // For top alignment, align top edge of each node to minY + currentNode.position.y = minY + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.y = minY + break + } + + case AlignType.Middle: { + // For middle alignment, center each node vertically in the selection bounds + const middleY = minY + (maxY - minY) / 2 - height / 2 + currentNode.position.y = middleY + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.y = middleY + break + } + + case AlignType.Bottom: { + // For bottom alignment, align bottom edge of each node to maxY + const newY = Math.round(maxY - height) + currentNode.position.y = newY + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.y = newY + break + } + } + }, []) + + // Handle distribute nodes logic + const handleDistributeNodes = useCallback((nodesToAlign: any[], nodes: any[], alignType: AlignType) => { + // Sort nodes appropriately + const sortedNodes = [...nodesToAlign].sort((a, b) => { + if (alignType === AlignType.DistributeHorizontal) { + // Sort by left position for horizontal distribution + return a.position.x - b.position.x + } + else { + // Sort by top position for vertical distribution + return a.position.y - b.position.y + } + }) + + if (sortedNodes.length < 3) + return null // Need at least 3 nodes for distribution + + let totalGap = 0 + let fixedSpace = 0 + + if (alignType === AlignType.DistributeHorizontal) { + // Fixed positions - first node's left edge and last node's right edge + const firstNodeLeft = sortedNodes[0].position.x + const lastNodeRight = sortedNodes[sortedNodes.length - 1].position.x + (sortedNodes[sortedNodes.length - 1].width || 0) + + // Total available space + totalGap = lastNodeRight - firstNodeLeft + + // Space occupied by nodes themselves + fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.width || 0), 0) + } + else { + // Fixed positions - first node's top edge and last node's bottom edge + const firstNodeTop = sortedNodes[0].position.y + const lastNodeBottom = sortedNodes[sortedNodes.length - 1].position.y + (sortedNodes[sortedNodes.length - 1].height || 0) + + // Total available space + totalGap = lastNodeBottom - firstNodeTop + + // Space occupied by nodes themselves + fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.height || 0), 0) + } + + // Available space for gaps + const availableSpace = totalGap - fixedSpace + + // Calculate even spacing between node edges + const spacing = availableSpace / (sortedNodes.length - 1) + + if (spacing <= 0) + return null // Nodes are overlapping, can't distribute evenly + + return produce(nodes, (draft) => { + // Keep first node fixed, position others with even gaps + let currentPosition + + if (alignType === AlignType.DistributeHorizontal) { + // Start from first node's right edge + currentPosition = sortedNodes[0].position.x + (sortedNodes[0].width || 0) + } + else { + // Start from first node's bottom edge + currentPosition = sortedNodes[0].position.y + (sortedNodes[0].height || 0) + } + + // Skip first node (index 0), it stays in place + for (let i = 1; i < sortedNodes.length - 1; i++) { + const nodeToAlign = sortedNodes[i] + const currentNode = draft.find(n => n.id === nodeToAlign.id) + if (!currentNode) continue + + if (alignType === AlignType.DistributeHorizontal) { + // Position = previous right edge + spacing + const newX: number = currentPosition + spacing + currentNode.position.x = newX + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.x = newX + + // Update for next iteration - current node's right edge + currentPosition = newX + (nodeToAlign.width || 0) + } + else { + // Position = previous bottom edge + spacing + const newY: number = currentPosition + spacing + currentNode.position.y = newY + if (currentNode.positionAbsolute) + currentNode.positionAbsolute.y = newY + + // Update for next iteration - current node's bottom edge + currentPosition = newY + (nodeToAlign.height || 0) + } + } + }) + }, []) + + const handleAlignNodes = useCallback((alignType: AlignType) => { + if (getNodesReadOnly() || selectedNodes.length <= 1) { + handleSelectionContextmenuCancel() + return + } + + // Disable node animation state - same as handleNodeDragStart + workflowStore.setState({ nodeAnimation: false }) + + // Get all current nodes + const nodes = store.getState().getNodes() + + // Get all selected nodes + const selectedNodeIds = selectedNodes.map(node => node.id) + const nodesToAlign = nodes.filter(node => selectedNodeIds.includes(node.id)) + + if (nodesToAlign.length <= 1) { + handleSelectionContextmenuCancel() + return + } + + // Calculate node boundaries for alignment + let minX = Number.MAX_SAFE_INTEGER + let maxX = Number.MIN_SAFE_INTEGER + let minY = Number.MAX_SAFE_INTEGER + let maxY = Number.MIN_SAFE_INTEGER + + // Calculate boundaries of selected nodes + const validNodes = nodesToAlign.filter(node => node.width && node.height) + validNodes.forEach((node) => { + const width = node.width! + const height = node.height! + minX = Math.min(minX, node.position.x) + maxX = Math.max(maxX, node.position.x + width) + minY = Math.min(minY, node.position.y) + maxY = Math.max(maxY, node.position.y + height) + }) + + // Handle distribute nodes logic + if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) { + const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType) + if (distributeNodes) { + // Apply node distribution updates + store.getState().setNodes(distributeNodes) + handleSelectionContextmenuCancel() + + // Clear guide lines + const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() + setHelpLineHorizontal() + setHelpLineVertical() + + // Sync workflow draft + handleSyncWorkflowDraft() + + // Save to history + saveStateToHistory(WorkflowHistoryEvent.NodeDragStop) + + return // End function execution + } + } + + const newNodes = produce(nodes, (draft) => { + // Iterate through all selected nodes + const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height) + validNodesToAlign.forEach((nodeToAlign) => { + // Find the corresponding node in draft - consistent with handleNodeDrag + const currentNode = draft.find(n => n.id === nodeToAlign.id) + if (!currentNode) + return + + // Use the extracted alignment function + handleAlignNode(currentNode, nodeToAlign, alignType, minX, maxX, minY, maxY) + }) + }) + + // Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop + try { + // Directly use setNodes to update nodes - consistent with handleNodeDrag + store.getState().setNodes(newNodes) + + // Close popup + handleSelectionContextmenuCancel() + + // Clear guide lines - consistent with handleNodeDragStop + const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() + setHelpLineHorizontal() + setHelpLineVertical() + + // Sync workflow draft - consistent with handleNodeDragStop + handleSyncWorkflowDraft() + + // Save to history - consistent with handleNodeDragStop + saveStateToHistory(WorkflowHistoryEvent.NodeDragStop) + } + catch (err) { + console.error('Failed to update nodes:', err) + } + }, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes]) + + if (!selectionMenu) + return null + + return ( +
+
+
+
+ {t('workflow.operator.vertical')} +
+
handleAlignNodes(AlignType.Top)} + > + + {t('workflow.operator.alignTop')} +
+
handleAlignNodes(AlignType.Middle)} + > + + {t('workflow.operator.alignMiddle')} +
+
handleAlignNodes(AlignType.Bottom)} + > + + {t('workflow.operator.alignBottom')} +
+
handleAlignNodes(AlignType.DistributeVertical)} + > + + {t('workflow.operator.distributeVertical')} +
+
+
+
+
+ {t('workflow.operator.horizontal')} +
+
handleAlignNodes(AlignType.Left)} + > + + {t('workflow.operator.alignLeft')} +
+
handleAlignNodes(AlignType.Center)} + > + + {t('workflow.operator.alignCenter')} +
+
handleAlignNodes(AlignType.Right)} + > + + {t('workflow.operator.alignRight')} +
+
handleAlignNodes(AlignType.DistributeHorizontal)} + > + + {t('workflow.operator.distributeHorizontal')} +
+
+
+
+ ) +} + +export default memo(SelectionContextmenu) diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 855f45f26..4848beeac 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -15,6 +15,11 @@ export type PanelSliceShape = { left: number } setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void + selectionMenu?: { + top: number + left: number + } + setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void showVariableInspectPanel: boolean setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void initShowLastRunTab: boolean @@ -33,6 +38,8 @@ export const createPanelSlice: StateCreator = set => ({ setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })), panelMenu: undefined, setPanelMenu: panelMenu => set(() => ({ panelMenu })), + selectionMenu: undefined, + setSelectionMenu: selectionMenu => set(() => ({ selectionMenu })), showVariableInspectPanel: false, setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })), initShowLastRunTab: false, diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 10b74dadb..2653303e6 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -287,6 +287,18 @@ const translation = { zoomTo50: 'Zoom to 50%', zoomTo100: 'Zoom to 100%', zoomToFit: 'Zoom to Fit', + alignNodes: 'Align Nodes', + alignLeft: 'Left', + alignCenter: 'Center', + alignRight: 'Right', + alignTop: 'Top', + alignMiddle: 'Middle', + alignBottom: 'Bottom', + vertical: 'Vertical', + horizontal: 'Horizontal', + distributeHorizontal: 'Space Horizontally', + distributeVertical: 'Space Vertically', + selectionAlignment: 'Selection Alignment', }, variableReference: { noAvailableVars: 'No available variables', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index dbc37a7b3..e18c59730 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -287,6 +287,18 @@ const translation = { zoomTo50: '缩放到 50%', zoomTo100: '放大到 100%', zoomToFit: '自适应视图', + alignNodes: '对齐节点', + alignLeft: '左对齐', + alignCenter: '居中对齐', + alignRight: '右对齐', + alignTop: '顶部对齐', + alignMiddle: '中部对齐', + alignBottom: '底部对齐', + vertical: '垂直方向', + horizontal: '水平方向', + distributeHorizontal: '水平等间距', + distributeVertical: '垂直等间距', + selectionAlignment: '选择对齐', }, variableReference: { noAvailableVars: '没有可用变量',