mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 14:19:28 +00:00
feat: implement grouping availability checks in selection context menu
This commit is contained in:
136
web/app/components/workflow/hooks/use-make-group.ts
Normal file
136
web/app/components/workflow/hooks/use-make-group.ts
Normal 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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user