From e3bfb95c52f4dc2183866738201d8e2cb86568bf Mon Sep 17 00:00:00 2001 From: zhsama Date: Thu, 18 Dec 2025 17:11:34 +0800 Subject: [PATCH] feat: implement grouping availability checks in selection context menu --- .../workflow/hooks/use-make-group.ts | 136 ++++++++++++++++++ .../workflow/hooks/use-nodes-interactions.ts | 24 +++- .../workflow/hooks/use-shortcuts.ts | 4 +- .../workflow/selection-contextmenu.tsx | 13 +- web/app/components/workflow/utils/workflow.ts | 53 +++++++ 5 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 web/app/components/workflow/hooks/use-make-group.ts diff --git a/web/app/components/workflow/hooks/use-make-group.ts b/web/app/components/workflow/hooks/use-make-group.ts new file mode 100644 index 0000000000..2326e10390 --- /dev/null +++ b/web/app/components/workflow/hooks/use-make-group.ts @@ -0,0 +1,136 @@ +import { useMemo } from 'react' +import { useStore as useReactFlowStore } from 'reactflow' +import { getCommonPredecessorHandles } from '../utils' +import type { PredecessorHandle } from '../utils' +import { shallow } from 'zustand/shallow' + +export type MakeGroupAvailability = { + canMakeGroup: boolean + branchEntryNodeIds: string[] + commonPredecessorHandle?: PredecessorHandle +} + +type MinimalEdge = { + id: string + source: string + sourceHandle: string + target: string +} + +/** + * Pure function to check if the selected nodes can be grouped. + * Can be called both from React hooks and imperatively. + */ +export const checkMakeGroupAvailability = ( + selectedNodeIds: string[], + edges: MinimalEdge[], +): MakeGroupAvailability => { + // Make group requires selecting at least 2 nodes. + if (selectedNodeIds.length <= 1) { + return { + canMakeGroup: false, + branchEntryNodeIds: [], + commonPredecessorHandle: undefined, + } + } + + const selectedNodeIdSet = new Set(selectedNodeIds) + const inboundFromOutsideTargets = new Set() + const incomingEdgeCounts = new Map() + const incomingFromSelectedTargets = new Set() + + edges.forEach((edge) => { + // Only consider edges whose target is inside the selected subgraph. + if (!selectedNodeIdSet.has(edge.target)) + return + + incomingEdgeCounts.set(edge.target, (incomingEdgeCounts.get(edge.target) ?? 0) + 1) + + if (selectedNodeIdSet.has(edge.source)) + incomingFromSelectedTargets.add(edge.target) + else + inboundFromOutsideTargets.add(edge.target) + }) + + // Branch head (entry) definition: + // - has at least one incoming edge + // - and all its incoming edges come from outside the selected subgraph + const branchEntryNodeIds = selectedNodeIds.filter((nodeId) => { + const incomingEdgeCount = incomingEdgeCounts.get(nodeId) ?? 0 + if (incomingEdgeCount === 0) + return false + + return !incomingFromSelectedTargets.has(nodeId) + }) + + // No branch head means we cannot tell how many branches are represented by this selection. + if (branchEntryNodeIds.length === 0) { + return { + canMakeGroup: false, + branchEntryNodeIds, + commonPredecessorHandle: undefined, + } + } + + // Guardrail: disallow side entrances into the selected subgraph. + // If an outside node connects to a non-entry node inside the selection, the grouping boundary is ambiguous. + const branchEntryNodeIdSet = new Set(branchEntryNodeIds) + const hasInboundToNonEntryNode = Array.from(inboundFromOutsideTargets).some(nodeId => !branchEntryNodeIdSet.has(nodeId)) + + if (hasInboundToNonEntryNode) { + return { + canMakeGroup: false, + branchEntryNodeIds, + commonPredecessorHandle: undefined, + } + } + + // Compare the branch heads by their common predecessor "handler" (source node + sourceHandle). + // This is required for multi-handle nodes like If-Else / Classifier where different branches use different handles. + const commonPredecessorHandles = getCommonPredecessorHandles( + branchEntryNodeIds, + // Only look at edges coming from outside the selected subgraph when determining the "pre" handler. + edges.filter(edge => !selectedNodeIdSet.has(edge.source)), + ) + + if (commonPredecessorHandles.length !== 1) { + return { + canMakeGroup: false, + branchEntryNodeIds, + commonPredecessorHandle: undefined, + } + } + + return { + canMakeGroup: true, + branchEntryNodeIds, + commonPredecessorHandle: commonPredecessorHandles[0], + } +} + +export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAvailability => { + // Subscribe to the minimal edge state we need (source/sourceHandle/target) to avoid + // snowball rerenders caused by subscribing to the entire `edges` objects. + const edgeKeys = useReactFlowStore((state) => { + const delimiter = '\u0000' + const keys = state.edges.map(edge => `${edge.source}${delimiter}${edge.sourceHandle || 'source'}${delimiter}${edge.target}`) + keys.sort() + return keys + }, shallow) + + return useMemo(() => { + // Reconstruct a minimal edge list from `edgeKeys` for downstream graph checks. + const delimiter = '\u0000' + const edges = edgeKeys.map((key) => { + const [source, handleId, target] = key.split(delimiter) + return { + id: key, + source, + sourceHandle: handleId || 'source', + target, + } + }) + + return checkMakeGroupAvailability(selectedNodeIds, edges) + }, [edgeKeys, selectedNodeIds]) +} diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 55640e3af4..bb908a26e4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -49,6 +49,7 @@ import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' import { useWorkflowHistoryStore } from '../workflow-history-store' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useHelpline } from './use-helpline' +import { checkMakeGroupAvailability } from './use-make-group' import { useNodesReadOnly, useWorkflow, @@ -1996,13 +1997,33 @@ export const useNodesInteractions = () => { setEdges(newEdges) }, [store]) - // Check if there are any nodes selected via box selection (框选) + // 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]) + // Check if the current box selection can be grouped + const getCanMakeGroup = useCallback(() => { + const { getNodes, edges } = store.getState() + const nodes = getNodes() + const bundledNodeIds = nodes.filter(node => node.data._isBundled).map(node => node.id) + + if (bundledNodeIds.length <= 1) + return false + + const minimalEdges = edges.map(edge => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle || 'source', + target: edge.target, + })) + + const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges) + return canMakeGroup + }, [store]) + return { handleNodeDragStart, handleNodeDrag, @@ -2030,5 +2051,6 @@ export const useNodesInteractions = () => { dimOtherNodes, undimAllNodes, hasBundledNodes, + getCanMakeGroup, } } diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 5e68989aa4..d4689ebe68 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -28,6 +28,7 @@ export const useShortcuts = (): void => { dimOtherNodes, undimAllNodes, hasBundledNodes, + getCanMakeGroup, } = useNodesInteractions() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -97,7 +98,8 @@ export const useShortcuts = (): void => { }, { exactMatch: true, useCapture: true }) useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => { - if (shouldHandleShortcut(e) && hasBundledNodes()) { + // Only intercept when the selection can be grouped + if (shouldHandleShortcut(e) && getCanMakeGroup()) { e.preventDefault() // Close selection context menu if open workflowStore.setState({ selectionMenu: undefined }) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index ffebe89c26..e3ef16acc2 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -25,6 +25,7 @@ import { produce } from 'immer' import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history' import { useStore } from './store' import { useSelectionInteractions } from './hooks/use-selection-interactions' +import { useMakeGroupAvailability } from './hooks/use-make-group' import { useWorkflowStore } from './store' enum AlignType { @@ -96,6 +97,8 @@ const SelectionContextmenu = () => { return ids }, shallow) + const { canMakeGroup } = useMakeGroupAvailability(selectedNodeIds) + const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() @@ -428,15 +431,21 @@ const SelectionContextmenu = () => { <>
{ + if (!canMakeGroup) + return console.log('make group') // TODO: Make group functionality handleSelectionContextmenuCancel() }} > {t('workflow.operator.makeGroup')} - +
diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 865391e0b8..2c36ef82e7 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -193,6 +193,59 @@ export const getCommonPredecessorNodeIds = (selectedNodeIds: string[], edges: Ed return Array.from(commonPredecessorNodeIds ?? []).sort() } +export type PredecessorHandle = { + nodeId: string + handleId: string +} + +export const getCommonPredecessorHandles = (targetNodeIds: string[], edges: Edge[]): PredecessorHandle[] => { + const uniqTargetNodeIds = Array.from(new Set(targetNodeIds)) + if (uniqTargetNodeIds.length === 0) + return [] + + // Get the "direct predecessor handler", which is: + // - edge.source (predecessor node) + // - edge.sourceHandle (the specific output handle of the predecessor; defaults to 'source' if not set) + // Used to handle multi-handle branch scenarios like If-Else / Classifier. + const targetNodeIdSet = new Set(uniqTargetNodeIds) + const predecessorHandleMap = new Map>() // targetNodeId -> Set<`${source}\0${handleId}`> + const delimiter = '\u0000' + + edges.forEach((edge) => { + if (!targetNodeIdSet.has(edge.target)) + return + + const predecessors = predecessorHandleMap.get(edge.target) ?? new Set() + const handleId = edge.sourceHandle || 'source' + predecessors.add(`${edge.source}${delimiter}${handleId}`) + predecessorHandleMap.set(edge.target, predecessors) + }) + + // Intersect predecessor handlers of all targets, keeping only handlers common to all targets. + let commonKeys: Set | null = null + + uniqTargetNodeIds.forEach((nodeId) => { + const keys = predecessorHandleMap.get(nodeId) ?? new Set() + + if (!commonKeys) { + commonKeys = new Set(keys) + return + } + + Array.from(commonKeys).forEach((key) => { + if (!keys.has(key)) + commonKeys!.delete(key) + }) + }) + + return Array.from(commonKeys ?? []) + .map((key) => { + const [nodeId, handleId] = key.split(delimiter) + return { nodeId, handleId } + }) + .sort((a, b) => a.nodeId.localeCompare(b.nodeId) || a.handleId.localeCompare(b.handleId)) +} + export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => { const idMap = nodes.reduce((acc, node) => { acc[node.id] = uuid4()