feat: implement grouping availability checks in selection context menu

This commit is contained in:
zhsama
2025-12-18 17:11:34 +08:00
parent 752cb9e4f4
commit e3bfb95c52
5 changed files with 226 additions and 4 deletions

View File

@@ -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<string>()
const incomingEdgeCounts = new Map<string, number>()
const incomingFromSelectedTargets = new Set<string>()
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])
}

View File

@@ -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,
}
}

View File

@@ -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 })

View File

@@ -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 = () => {
<>
<div className='p-1'>
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
className={`flex h-8 items-center justify-between rounded-lg px-3 text-sm ${
canMakeGroup
? 'cursor-pointer text-text-secondary hover:bg-state-base-hover'
: 'cursor-not-allowed text-text-disabled'
}`}
onClick={() => {
if (!canMakeGroup)
return
console.log('make group')
// TODO: Make group functionality
handleSelectionContextmenuCancel()
}}
>
{t('workflow.operator.makeGroup')}
<ShortcutsName keys={['ctrl', 'g']} />
<ShortcutsName keys={['ctrl', 'g']} className={!canMakeGroup ? 'opacity-50' : ''} />
</div>
</div>
<div className='h-px bg-divider-regular' />

View File

@@ -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<string, Set<string>>() // 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<string>()
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<string> | null = null
uniqTargetNodeIds.forEach((nodeId) => {
const keys = predecessorHandleMap.get(nodeId) ?? new Set<string>()
if (!commonKeys) {
commonKeys = new Set(keys)
return
}
Array.from(commonKeys).forEach((key) => {
if (!keys.has(key))
commonKeys!.delete(key)
})
})
return Array.from<string>(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()