diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index d56b85893e..55640e3af4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1996,6 +1996,13 @@ export const useNodesInteractions = () => { setEdges(newEdges) }, [store]) + // Check if there are any nodes selected via box selection (框选) + const hasBundledNodes = useCallback(() => { + const { getNodes } = store.getState() + const nodes = getNodes() + return nodes.some(node => node.data._isBundled) + }, [store]) + return { handleNodeDragStart, handleNodeDrag, @@ -2022,5 +2029,6 @@ export const useNodesInteractions = () => { handleHistoryForward, dimOtherNodes, undimAllNodes, + hasBundledNodes, } } diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 16502c97c4..5e68989aa4 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -27,6 +27,7 @@ export const useShortcuts = (): void => { handleHistoryForward, dimOtherNodes, undimAllNodes, + hasBundledNodes, } = useNodesInteractions() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -73,7 +74,8 @@ export const useShortcuts = (): void => { useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => { const { showDebugAndPreviewPanel } = workflowStore.getState() - if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) { + // Only intercept when nodes are selected via box selection + if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel && hasBundledNodes()) { e.preventDefault() handleNodesCopy() } @@ -94,6 +96,16 @@ export const useShortcuts = (): void => { } }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => { + if (shouldHandleShortcut(e) && hasBundledNodes()) { + e.preventDefault() + // Close selection context menu if open + workflowStore.setState({ selectionMenu: undefined }) + // TODO: handleMakeGroup() - Make group functionality to be implemented + console.info('make group') + } + }, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => { if (shouldHandleShortcut(e)) { e.preventDefault() diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 53392f2cd3..ffebe89c26 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -8,6 +8,8 @@ import { import { useTranslation } from 'react-i18next' import { useClickAway } from 'ahooks' import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' +import { shallow } from 'zustand/shallow' +import type { FC, ReactElement } from 'react' import { RiAlignBottom, RiAlignCenter, @@ -16,7 +18,9 @@ import { RiAlignRight, RiAlignTop, } from '@remixicon/react' -import { useNodesReadOnly, useNodesSyncDraft } from './hooks' +import Tooltip from '@/app/components/base/tooltip' +import ShortcutsName from './shortcuts-name' +import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' import { produce } from 'immer' import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history' import { useStore } from './store' @@ -34,21 +38,63 @@ enum AlignType { DistributeVertical = 'distributeVertical', } +type AlignButtonConfig = { + type: AlignType + icon: ReactElement + labelKey: string +} + +type AlignButtonProps = { + config: AlignButtonConfig + onClick: (type: AlignType) => void + position?: 'top' | 'bottom' | 'left' | 'right' +} + +const AlignButton: FC = ({ config, onClick, position = 'bottom' }) => { + return ( + +
onClick(config.type)} + > + {config.icon} +
+
+ ) +} + +const ALIGN_BUTTONS: AlignButtonConfig[] = [ + { type: AlignType.Left, icon: , labelKey: 'workflow.operator.alignLeft' }, + { type: AlignType.Center, icon: , labelKey: 'workflow.operator.alignCenter' }, + { type: AlignType.Right, icon: , labelKey: 'workflow.operator.alignRight' }, + { type: AlignType.DistributeHorizontal, icon: , labelKey: 'workflow.operator.distributeHorizontal' }, + { type: AlignType.Top, icon: , labelKey: 'workflow.operator.alignTop' }, + { type: AlignType.Middle, icon: , labelKey: 'workflow.operator.alignMiddle' }, + { type: AlignType.Bottom, icon: , labelKey: 'workflow.operator.alignBottom' }, + { type: AlignType.DistributeVertical, icon: , labelKey: 'workflow.operator.distributeVertical' }, +] + const SelectionContextmenu = () => { const { t } = useTranslation() const ref = useRef(null) - const { getNodesReadOnly } = useNodesReadOnly() + const { getNodesReadOnly, nodesReadOnly } = useNodesReadOnly() const { handleSelectionContextmenuCancel } = useSelectionInteractions() + const { + handleNodesCopy, + handleNodesDuplicate, + handleNodesDelete, + } = useNodesInteractions() 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 selectedNodeIds = useReactFlowStore((state) => { + const ids = state.getNodes().filter(node => node.selected).map(node => node.id) + ids.sort() + return ids + }, shallow) const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() @@ -65,9 +111,9 @@ const SelectionContextmenu = () => { if (container) { const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect() - const menuWidth = 240 + const menuWidth = 244 - const estimatedMenuHeight = 380 + const estimatedMenuHeight = 203 if (left + menuWidth > containerWidth) left = left - menuWidth @@ -87,9 +133,9 @@ const SelectionContextmenu = () => { }, ref) useEffect(() => { - if (selectionMenu && selectedNodes.length <= 1) + if (selectionMenu && selectedNodeIds.length <= 1) handleSelectionContextmenuCancel() - }, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel]) + }, [selectionMenu, selectedNodeIds.length, handleSelectionContextmenuCancel]) // Handle align nodes logic const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => { @@ -247,7 +293,7 @@ const SelectionContextmenu = () => { }, []) const handleAlignNodes = useCallback((alignType: AlignType) => { - if (getNodesReadOnly() || selectedNodes.length <= 1) { + if (getNodesReadOnly() || selectedNodeIds.length <= 1) { handleSelectionContextmenuCancel() return } @@ -258,9 +304,6 @@ const SelectionContextmenu = () => { // Get all current nodes const nodes = store.getState().getNodes() - // Get all selected nodes - const selectedNodeIds = selectedNodes.map(node => node.id) - // Find container nodes and their children // Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently // when the container is selected. This prevents child nodes from being moved outside their containers. @@ -366,7 +409,7 @@ const SelectionContextmenu = () => { catch (err) { console.error('Failed to update nodes:', err) } - }, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes]) + }, [getNodesReadOnly, handleAlignNode, handleDistributeNodes, handleSelectionContextmenuCancel, handleSyncWorkflowDraft, saveStateToHistory, selectedNodeIds, store, workflowStore]) if (!selectionMenu) return null @@ -380,73 +423,69 @@ const SelectionContextmenu = () => { }} ref={ref} > -
-
-
- {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')} -
+
+ {!nodesReadOnly && ( + <> +
+
{ + console.log('make group') + // TODO: Make group functionality + handleSelectionContextmenuCancel() + }} + > + {t('workflow.operator.makeGroup')} + +
+
+
+
+
{ + handleNodesCopy() + handleSelectionContextmenuCancel() + }} + > + {t('workflow.common.copy')} + +
+
{ + handleNodesDuplicate() + handleSelectionContextmenuCancel() + }} + > + {t('workflow.common.duplicate')} + +
+
+
+
+
{ + handleNodesDelete() + handleSelectionContextmenuCancel() + }} + > + {t('common.operation.delete')} + +
+
+
+ + )} +
+ {ALIGN_BUTTONS.map(config => ( + + ))}
diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 14b1eb87d5..865391e0b8 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -157,6 +157,42 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { } } +export const getCommonPredecessorNodeIds = (selectedNodeIds: string[], edges: Edge[]) => { + const uniqSelectedNodeIds = Array.from(new Set(selectedNodeIds)) + if (uniqSelectedNodeIds.length <= 1) + return [] + + const selectedNodeIdSet = new Set(uniqSelectedNodeIds) + const predecessorNodeIdsMap = new Map>() + + edges.forEach((edge) => { + if (!selectedNodeIdSet.has(edge.target)) + return + + const predecessors = predecessorNodeIdsMap.get(edge.target) ?? new Set() + predecessors.add(edge.source) + predecessorNodeIdsMap.set(edge.target, predecessors) + }) + + let commonPredecessorNodeIds: Set | null = null + + uniqSelectedNodeIds.forEach((nodeId) => { + const predecessors = predecessorNodeIdsMap.get(nodeId) ?? new Set() + + if (!commonPredecessorNodeIds) { + commonPredecessorNodeIds = new Set(predecessors) + return + } + + Array.from(commonPredecessorNodeIds).forEach((predecessorNodeId) => { + if (!predecessors.has(predecessorNodeId)) + commonPredecessorNodeIds!.delete(predecessorNodeId) + }) + }) + + return Array.from(commonPredecessorNodeIds ?? []).sort() +} + export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => { const idMap = nodes.reduce((acc, node) => { acc[node.id] = uuid4() diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index a023ac2b91..c46ad45996 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -359,6 +359,7 @@ const translation = { zoomTo50: 'Zoom to 50%', zoomTo100: 'Zoom to 100%', zoomToFit: 'Zoom to Fit', + makeGroup: 'Make group', alignNodes: 'Align Nodes', alignLeft: 'Left', alignCenter: 'Center', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 7f4e7a3009..850796fa48 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -359,6 +359,7 @@ const translation = { zoomTo50: '50% サイズ', zoomTo100: '等倍表示', zoomToFit: '画面に合わせる', + makeGroup: 'グループ化', horizontal: '水平', alignBottom: '下', alignNodes: 'ノードを整列', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index fd86292252..78deb4bf84 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -359,6 +359,7 @@ const translation = { zoomTo50: '缩放到 50%', zoomTo100: '放大到 100%', zoomToFit: '自适应视图', + makeGroup: '创建分组', alignNodes: '对齐节点', alignLeft: '左对齐', alignCenter: '居中对齐', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 3da4cc172a..da8b4996cd 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -344,6 +344,7 @@ const translation = { zoomTo50: '縮放到 50%', zoomTo100: '放大到 100%', zoomToFit: '自適應視圖', + makeGroup: '建立群組', alignNodes: '對齊節點', distributeVertical: '垂直等間距', alignLeft: '左對齊',