feat(workflow): implement ungroup functionality for group nodes

- Added `handleUngroup`, `getCanUngroup`, and `getSelectedGroupId` methods to manage ungrouping of selected group nodes.
- Integrated ungrouping logic into the `useShortcuts` hook for keyboard shortcut support (Ctrl + Shift + G).
- Updated UI to include ungroup option in the panel operator popup for group nodes.
- Added translations for the ungroup action in multiple languages.
This commit is contained in:
zhsama 2026-01-04 21:40:34 +08:00
parent a6ce6a249b
commit b7a2957340
7 changed files with 112 additions and 1 deletions

View File

@ -2529,6 +2529,77 @@ export const useNodesInteractions = () => {
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore])
// check if the current selection can be ungrouped (single selected Group node)
const getCanUngroup = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
const selectedNodes = nodes.filter(node => node.selected)
if (selectedNodes.length !== 1)
return false
return selectedNodes[0].data.type === BlockEnum.Group
}, [store])
// get the selected group node id for ungroup operation
const getSelectedGroupId = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
const selectedNodes = nodes.filter(node => node.selected)
if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group)
return selectedNodes[0].id
return undefined
}, [store])
const handleUngroup = useCallback((groupId: string) => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const groupNode = nodes.find(n => n.id === groupId)
if (!groupNode || groupNode.data.type !== BlockEnum.Group)
return
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
// restore hidden member nodes
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (memberIds.has(node.id)) {
node.hidden = false
delete node.data._hiddenInGroupId
}
})
// remove group node
const groupIndex = draft.findIndex(n => n.id === groupId)
if (groupIndex !== -1)
draft.splice(groupIndex, 1)
})
// restore hidden edges and remove temp edges
const newEdges = produce(edges, (draft) => {
// restore hidden edges that involve member nodes
draft.forEach((edge) => {
if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target)))
edge.hidden = false
})
// remove temp edges connected to group (iterate backwards to safely splice)
for (let i = draft.length - 1; i >= 0; i--) {
const edge = draft[i]
if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId))
draft.splice(i, 1)
}
})
setNodes(newNodes)
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeDelete, {
nodeId: groupId,
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store])
return {
handleNodeDragStart,
handleNodeDrag,
@ -2550,6 +2621,7 @@ export const useNodesInteractions = () => {
handleNodesDuplicate,
handleNodesDelete,
handleMakeGroup,
handleUngroup,
handleNodeResize,
handleNodeDisconnect,
handleHistoryBack,
@ -2558,5 +2630,7 @@ export const useNodesInteractions = () => {
undimAllNodes,
hasBundledNodes,
getCanMakeGroup,
getCanUngroup,
getSelectedGroupId,
}
}

View File

@ -30,6 +30,9 @@ export const useShortcuts = (): void => {
hasBundledNodes,
getCanMakeGroup,
handleMakeGroup,
getCanUngroup,
getSelectedGroupId,
handleUngroup,
} = useNodesInteractions()
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
@ -113,6 +116,16 @@ export const useShortcuts = (): void => {
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.g`, (e) => {
// Only intercept when the selection can be ungrouped
if (shouldHandleShortcut(e) && getCanUngroup()) {
e.preventDefault()
const groupId = getSelectedGroupId()
if (groupId)
handleUngroup(groupId)
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()

View File

@ -41,13 +41,14 @@ const PanelOperatorPopup = ({
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
handleUngroup,
} = useNodesInteractions()
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { nodesReadOnly } = useNodesReadOnly()
const edge = edges.find(edge => edge.target === id)
const nodeMetaData = useNodeMetaData({ id, data } as Node)
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly && data.type !== BlockEnum.Group
const isChildNode = !!(data.isInIteration || data.isInLoop)
const { data: workflowTools } = useAllWorkflowTools()
@ -61,6 +62,25 @@ const PanelOperatorPopup = ({
return (
<div className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
{
!nodesReadOnly && data.type === BlockEnum.Group && (
<>
<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"
onClick={() => {
onClosePopup()
handleUngroup(id)
}}
>
{t('panel.ungroup', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'shift', 'g']} />
</div>
</div>
<div className="h-px bg-divider-regular"></div>
</>
)
}
{
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
<>

View File

@ -967,6 +967,7 @@
"panel.scrollToSelectedNode": "Scroll to selected node",
"panel.selectNextStep": "Select Next Step",
"panel.startNode": "Start Node",
"panel.ungroup": "Ungroup",
"panel.userInputField": "User Input Field",
"publishLimit.startNodeDesc": "Youve reached the limit of 2 triggers per workflow for this plan. Upgrade to publish this workflow.",
"publishLimit.startNodeTitlePrefix": "Upgrade to",

View File

@ -964,6 +964,7 @@
"panel.scrollToSelectedNode": "選択したノードまでスクロール",
"panel.selectNextStep": "次ノード選択",
"panel.startNode": "開始ノード",
"panel.ungroup": "グループ解除",
"panel.userInputField": "ユーザー入力欄",
"publishLimit.startNodeDesc": "このプランでは、各ワークフローのトリガー数は最大 2 個まで設定できます。公開するにはアップグレードが必要です。",
"publishLimit.startNodeTitlePrefix": "アップグレードして、",

View File

@ -965,6 +965,7 @@
"panel.scrollToSelectedNode": "滚动至选中节点",
"panel.selectNextStep": "选择下一个节点",
"panel.startNode": "开始节点",
"panel.ungroup": "取消编组",
"panel.userInputField": "用户输入字段",
"publishLimit.startNodeDesc": "您已达到此计划上每个工作流最多 2 个触发器的限制。请升级后再发布此工作流。",
"publishLimit.startNodeTitlePrefix": "升级以",

View File

@ -964,6 +964,7 @@
"panel.scrollToSelectedNode": "捲動至選取的節點",
"panel.selectNextStep": "選擇下一個節點",
"panel.startNode": "起始節點",
"panel.ungroup": "取消群組",
"panel.userInputField": "用戶輸入字段",
"publishLimit.startNodeDesc": "目前方案最多允許 2 個開始節點,升級後才能發布此工作流程。",
"publishLimit.startNodeTitlePrefix": "升級以",