Compare commits

...

15 Commits

Author SHA1 Message Date
zhsama
4bb76acc37 Merge branch 'main' into feat/grouping-branching 2025-12-23 23:56:26 +08:00
zhsama
b513933040 Merge branch 'main' into feat/grouping-branching
# Conflicts:
#	web/app/components/workflow/block-icon.tsx
#	web/app/components/workflow/hooks/use-nodes-interactions.ts
#	web/app/components/workflow/index.tsx
#	web/app/components/workflow/nodes/components.ts
#	web/app/components/workflow/selection-contextmenu.tsx
#	web/app/components/workflow/utils/workflow-init.ts
2025-12-23 23:55:21 +08:00
zhsama
18ea9d3f18 feat: Add GROUP node type and update node configuration filtering in Graph class 2025-12-23 20:44:36 +08:00
zhsama
7b660a9ebc feat: Simplify edge creation for group nodes in useNodesInteractions hook 2025-12-23 17:12:09 +08:00
zhsama
783a49bd97 feat: Refactor group node edge creation logic in useNodesInteractions hook 2025-12-23 16:44:11 +08:00
zhsama
d3c6b09354 feat: Implement group node edge handling in useNodesInteractions hook 2025-12-23 16:37:42 +08:00
zhsama
3d61496d25 feat: Enhance CustomGroupNode with exit ports and visual indicators 2025-12-23 15:36:53 +08:00
zhsama
16bff9e82f Merge branch 'refs/heads/main' into feat/grouping-branching 2025-12-23 15:27:54 +08:00
zhsama
22f25731e8 refactor: streamline edge building and node filtering in workflow graph 2025-12-22 18:59:08 +08:00
zhsama
035f51ad58 Merge branch 'main' into feat/grouping-branching 2025-12-22 18:18:37 +08:00
zhsama
e9795bd772 feat: refine workflow graph processing to exclude additional UI-only node types 2025-12-22 18:17:25 +08:00
zhsama
93b516a4ec feat: add UI-only group node types and enhance workflow graph processing 2025-12-22 17:35:33 +08:00
zhsama
fc9d5b2a62 feat: implement group node functionality and enhance grouping interactions 2025-12-19 15:17:45 +08:00
zhsama
e3bfb95c52 feat: implement grouping availability checks in selection context menu 2025-12-18 17:11:34 +08:00
zhsama
752cb9e4f4 feat: enhance selection context menu with alignment options and grouping functionality
- Added alignment buttons for nodes with tooltips in the selection context menu.
- Implemented grouping functionality with a new "Make group" option, including keyboard shortcuts.
- Updated translations for the new grouping feature in multiple languages.
- Refactored node selection logic to improve performance and readability.
2025-12-17 19:52:02 +08:00
31 changed files with 1534 additions and 135 deletions

1
.gitignore vendored
View File

@@ -208,6 +208,7 @@ api/.vscode
.history
.idea/
web/migration/
# pnpm
/.pnpm-store

View File

@@ -63,6 +63,7 @@ class NodeType(StrEnum):
TRIGGER_SCHEDULE = "trigger-schedule"
TRIGGER_PLUGIN = "trigger-plugin"
HUMAN_INPUT = "human-input"
GROUP = "group"
@property
def is_trigger_node(self) -> bool:

View File

@@ -305,7 +305,14 @@ class Graph:
if not node_configs:
raise ValueError("Graph must have at least one node")
node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"]
# Filter out UI-only node types:
# - custom-note: top-level type (node_config.type == "custom-note")
# - group: data-level type (node_config.data.type == "group")
node_configs = [
node_config for node_config in node_configs
if node_config.get("type", "") != "custom-note"
and node_config.get("data", {}).get("type", "") != "group"
]
# Parse node configurations
node_configs_map = cls._parse_node_configs(node_configs)

View File

@@ -36,7 +36,8 @@ export const SkeletonPoint: FC<SkeletonProps> = (props) => {
<div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
)
}
/** Usage
/**
* Usage
* <SkeletonContainer>
* <SkeletonRow>
* <SkeletonRectangle className="w-96" />

View File

@@ -6,20 +6,22 @@ type ModelDisplayProps = {
}
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
return currentModel ? (
<ModelName
className="flex grow items-center gap-1 px-1 py-[3px]"
modelItem={currentModel}
showMode
showFeatures
/>
) : (
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
{modelId}
</div>
</div>
)
return currentModel
? (
<ModelName
className="flex grow items-center gap-1 px-1 py-[3px]"
modelItem={currentModel}
showMode
showFeatures
/>
)
: (
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
{modelId}
</div>
</div>
)
}
export default ModelDisplay

View File

@@ -1,6 +1,7 @@
import type { FC } from 'react'
import { memo } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { Folder as FolderLine } from '@/app/components/base/icons/src/vender/line/files'
import {
Agent,
Answer,
@@ -54,6 +55,7 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin
[BlockEnum.TemplateTransform]: TemplatingTransform,
[BlockEnum.VariableAssigner]: VariableX,
[BlockEnum.VariableAggregator]: VariableX,
[BlockEnum.Group]: FolderLine,
[BlockEnum.Assigner]: Assigner,
[BlockEnum.Tool]: VariableX,
[BlockEnum.IterationStart]: VariableX,
@@ -97,6 +99,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[BlockEnum.VariableAssigner]: 'bg-util-colors-blue-blue-500',
[BlockEnum.VariableAggregator]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Tool]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Group]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Assigner]: 'bg-util-colors-blue-blue-500',
[BlockEnum.ParameterExtractor]: 'bg-util-colors-blue-blue-500',
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',

View File

@@ -0,0 +1,11 @@
export const CUSTOM_GROUP_NODE = 'custom-group'
export const CUSTOM_GROUP_INPUT_NODE = 'custom-group-input'
export const CUSTOM_GROUP_EXIT_PORT_NODE = 'custom-group-exit-port'
export const GROUP_CHILDREN_Z_INDEX = 1002
export const UI_ONLY_GROUP_NODE_TYPES = new Set([
CUSTOM_GROUP_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_EXIT_PORT_NODE,
])

View File

@@ -0,0 +1,54 @@
'use client'
import type { FC } from 'react'
import type { CustomGroupExitPortNodeData } from './types'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import { cn } from '@/utils/classnames'
type CustomGroupExitPortNodeProps = {
id: string
data: CustomGroupExitPortNodeData
}
const CustomGroupExitPortNode: FC<CustomGroupExitPortNodeProps> = ({ id: _id, data }) => {
return (
<div
className={cn(
'flex items-center justify-center',
'h-8 w-8 rounded-full',
'bg-util-colors-green-green-500 shadow-md',
data.selected && 'ring-2 ring-primary-400',
)}
>
{/* Target handle - receives internal connections from leaf nodes */}
<Handle
id="target"
type="target"
position={Position.Left}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Source handle - connects to external nodes */}
<Handle
id="source"
type="source"
position={Position.Right}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Icon */}
<svg
className="h-4 w-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
)
}
export default memo(CustomGroupExitPortNode)

View File

@@ -0,0 +1,55 @@
'use client'
import type { FC } from 'react'
import type { CustomGroupInputNodeData } from './types'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import { cn } from '@/utils/classnames'
type CustomGroupInputNodeProps = {
id: string
data: CustomGroupInputNodeData
}
const CustomGroupInputNode: FC<CustomGroupInputNodeProps> = ({ id: _id, data }) => {
return (
<div
className={cn(
'flex items-center justify-center',
'h-8 w-8 rounded-full',
'bg-util-colors-blue-blue-500 shadow-md',
data.selected && 'ring-2 ring-primary-400',
)}
>
{/* Target handle - receives external connections */}
<Handle
id="target"
type="target"
position={Position.Left}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Source handle - connects to entry nodes */}
<Handle
id="source"
type="source"
position={Position.Right}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Icon */}
<svg
className="h-4 w-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
</div>
)
}
export default memo(CustomGroupInputNode)

View File

@@ -0,0 +1,94 @@
'use client'
import type { FC } from 'react'
import type { CustomGroupNodeData } from './types'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import { Plus02 } from '@/app/components/base/icons/src/vender/line/general'
import { cn } from '@/utils/classnames'
type CustomGroupNodeProps = {
id: string
data: CustomGroupNodeData
}
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ data }) => {
const { group } = data
const exitPorts = group.exitPorts ?? []
const connectedSourceHandleIds = data._connectedSourceHandleIds ?? []
return (
<div
className={cn(
'bg-workflow-block-parma-bg/50 group relative rounded-2xl border-2 border-dashed border-components-panel-border',
data.selected && 'border-primary-400',
)}
style={{
width: data.width || 280,
height: data.height || 200,
}}
>
{/* Group Header */}
<div className="absolute -top-7 left-0 flex items-center gap-1 px-2">
<span className="text-xs font-medium text-text-tertiary">
{group.title}
</span>
</div>
{/* Target handle for incoming connections */}
<Handle
id="target"
type="target"
position={Position.Left}
className={cn(
'!h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
)}
style={{ top: '50%' }}
/>
<div className="px-3 pt-3">
{exitPorts.map((port, index) => {
const connected = connectedSourceHandleIds.includes(port.portNodeId)
return (
<div key={port.portNodeId} className="relative flex h-6 items-center px-1">
<div className="w-full text-right text-xs font-semibold text-text-secondary">
{port.name}
</div>
<Handle
id={port.portNodeId}
type="source"
position={Position.Right}
className={cn(
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
!connected && 'after:opacity-0',
'!-right-[21px] !top-1/2 !-translate-y-1/2',
)}
isConnectable
/>
{/* Visual "+" indicator (styling aligned with existing branch handles) */}
<div
className={cn(
'pointer-events-none absolute z-10 hidden h-4 w-4 items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface',
'-right-[21px] top-1/2 -translate-y-1/2',
'group-hover:flex',
data.selected && '!flex',
)}
>
<Plus02 className="h-2.5 w-2.5" />
</div>
</div>
)
})}
</div>
</div>
)
}
export default memo(CustomGroupNode)

View File

@@ -0,0 +1,19 @@
export {
CUSTOM_GROUP_EXIT_PORT_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_NODE,
GROUP_CHILDREN_Z_INDEX,
UI_ONLY_GROUP_NODE_TYPES,
} from './constants'
export { default as CustomGroupExitPortNode } from './custom-group-exit-port-node'
export { default as CustomGroupInputNode } from './custom-group-input-node'
export { default as CustomGroupNode } from './custom-group-node'
export type {
CustomGroupExitPortNodeData,
CustomGroupInputNodeData,
CustomGroupNodeData,
ExitPortInfo,
GroupMember,
} from './types'

View File

@@ -0,0 +1,82 @@
import type { BlockEnum } from '../types'
/**
* Exit port info stored in Group node
*/
export type ExitPortInfo = {
portNodeId: string
leafNodeId: string
sourceHandle: string
name: string
}
/**
* Group node data structure
* node.type = 'custom-group'
* node.data.type = '' (empty string to bypass backend NodeType validation)
*/
export type CustomGroupNodeData = {
type: '' // Empty string bypasses backend NodeType validation
title: string
desc?: string
_connectedSourceHandleIds?: string[]
_connectedTargetHandleIds?: string[]
group: {
groupId: string
title: string
memberNodeIds: string[]
entryNodeIds: string[]
inputNodeId: string
exitPorts: ExitPortInfo[]
collapsed: boolean
}
width?: number
height?: number
selected?: boolean
_isTempNode?: boolean
}
/**
* Group Input node data structure
* node.type = 'custom-group-input'
* node.data.type = ''
*/
export type CustomGroupInputNodeData = {
type: ''
title: string
desc?: string
groupInput: {
groupId: string
title: string
}
selected?: boolean
_isTempNode?: boolean
}
/**
* Exit Port node data structure
* node.type = 'custom-group-exit-port'
* node.data.type = ''
*/
export type CustomGroupExitPortNodeData = {
type: ''
title: string
desc?: string
exitPort: {
groupId: string
leafNodeId: string
sourceHandle: string
name: string
}
selected?: boolean
_isTempNode?: boolean
}
/**
* Member node info for display
*/
export type GroupMember = {
id: string
type: BlockEnum
label?: string
}

View File

@@ -0,0 +1,127 @@
# UI-only Group含 Group Input / Exit Port方案
## 设计方案
### 目标
- Group 可持久化:刷新后仍保留分组/命名/布局。
- Group 不影响执行Run Workflow 时不执行 Group/Input/ExitPort也不改变真实执行图语义。
- 新增入边:任意外部节点连到 Group或 Group Input等价于“通过 Group Input fan-out 到每个 entry”。
- handler 粒度:以 leaf 节点的 `sourceHandle` 为粒度生成 Exit PortIf-Else / Classifier 等多 handler 需要拆分)。
- 支持改名Group 标题、每个 Exit Port 名称可编辑并保存。
- 最小化副作用:真实节点/真实边不被“重接到 Group”只做 UI 折叠;状态订阅尽量只取最小字段,避免雪崩式 rerender。
### 核心模型(两层图)
1) **真实图(可执行、可保存)**
- 真实 workflow nodes + 真实 edges执行图语义只由它们决定
- Group 相关 UI 节点也会被保存到 graph.nodes但后端运行时会过滤掉不进入执行图
2) **展示图(仅 UI**
- 组内成员节点与其相关真实边标记 `hidden=true`(保存,用于刷新后仍保持折叠)。
- 额外生成 **临时 UI 边**`edge.data._isTemp = true`,不会 sync 到后端),用于:
- 外部 → Group Input表示外部连到该组的入边
- Exit Port → 外部(表示该组 handler 的出边)
## 影响范围
### 前端(`web/`
- 新增 3 个 UI-only node type`custom-group` / `custom-group-input` / `custom-group-exit-port`组件、样式、panel/rename 交互)。
- `workflow/index.tsx``workflow-preview/index.tsx`:注册 nodeTypes。
- `hooks/use-nodes-interactions.ts`
- 重做 `handleMakeGroup`:创建 group + input + exit ports隐藏成员节点/相关真实边;不做“重接真实边到 group”。
- 扩展 `handleNodeConnect`:遇到 group/input/exitPort 时做连线翻译。
- 扩展 edge delete若删除的是临时 UI 边,反向删除对应真实边。
- 新增派生 UI 边的 hook示例`hooks/use-group-ui-edges.ts`(从真实图派生临时 UI 边并写入 ReactFlow edges state
- 新增 `utils/get-node-source-handles.ts`:从节点数据提取可用 `sourceHandle`If-Else/Classifer 等)。
- 复用现有 `use-make-group.ts`:继续以“共同 pre node handler直接前序 handler”控制 `Make group` disabled。
### 后端(`api/`
- `api/core/workflow/graph/graph.py`:运行时过滤 `type in {'custom-note','custom-group','custom-group-input','custom-group-exit-port'}`,确保 UI 节点不进入执行图。
## 具体实施
### 1) 节点类型与数据结构(可持久化、无 `_` 前缀)
#### Group 容器节点UI-only
- `node.type = 'custom-group'`
- `node.data.type = ''`
- `node.data.group`
- `groupId: string`(可等于 node.id
- `title: string`
- `memberNodeIds: string[]`
- `entryNodeIds: string[]`
- `inputNodeId: string`
- `exitPorts: Array<{ portNodeId: string; leafNodeId: string; sourceHandle: string; name: string }>`
- `collapsed: boolean`
#### Group Input 节点UI-only
- `node.type = 'custom-group-input'`
- `node.data.type = ''`
- `node.data.groupInput`
- `groupId: string`
- `title: string`
#### Exit Port 节点UI-only
- `node.type = 'custom-group-exit-port'`
- `node.data.type = ''`
- `node.data.exitPort`
- `groupId: string`
- `leafNodeId: string`
- `sourceHandle: string`
- `name: string`
### 2) entry / leaf / handler 计算
- entrybranch 头结点):选区内“有入边且所有入边 source 在选区外”的节点。
- 禁止 side-entrance若存在 `outside -> selectedNonEntry` 入边,则不可 group。
- 共同 pre node handler直接前序 handler
- 对每个 entry收集其来自选区外的所有入边的 `(source, sourceHandle)` 集合
- 要求每个 entry 的集合 `size === 1`,且所有 entry 的该值完全一致
- 否则 `Make group` disabled
- leaf选区内“没有指向选区内节点的出边”的节点。
- leaf sourceHandles通过 `getNodeSourceHandles(node)` 枚举(普通 `'source'`、If-Else/Classifier 等拆分)。
### 3) Make group
- 创建 `custom-group` + `custom-group-input` + 多个 `custom-group-exit-port` 节点:
- group/input/exitPort 坐标按选区包围盒计算input 在左侧exitPort 右侧按 handler 列表排列
- 隐藏成员节点:对 `memberNodeIds``node.hidden = true`(持久化)
- 隐藏相关真实边:凡是 `edge.source/edge.target``memberNodeIds` 的真实边设 `edge.hidden = true`(持久化)
- 不创建/不重接任何“指向 group/input/exitPort 的真实边”
### 4) UI edge 派生
- 从“真实边 + group 定义”派生临时 UI 边并写入 edges state
- inbound真实 `outside -> entry` 映射为 `outside -> groupInput`
- outbound真实 `leaf(sourceHandle) -> outside` 映射为 `exitPort -> outside`
- 临时 UI 边统一标记 `edge.data._isTemp = true`,并在需要时写入用于反向映射的最小字段(`groupId / leafNodeId / sourceHandle / target / targetHandle` 等)。
- 为避免雪崩 rerender
- 派生逻辑只订阅最小字段edges 的 `source/sourceHandle/target/targetHandle/hidden` + group 定义),用 `shallow` 比较 key 列表
- UI 边增量更新:仅当派生 key 变化时才 `setEdges`
### 5) 连线翻译(拖线到 UI 节点最终只改真实边)
- `onConnect(target is custom-group or custom-group-input)`
- 翻译为:对该 group 的每个 `entryNodeId` 创建真实边 `source -> entryNodeId`fan-out
- 复用现有合法性校验available blocks + cycle check要求每条 fan-out 都合法
- `onConnect(source is custom-group-exit-port)`
- 翻译为:创建真实边 `leafNodeId(sourceHandle) -> target`
### 6) 删除 UI 边(反向翻译)
- 若选中并删除的是临时 inbound UI 边:删除所有匹配的真实边 `source -> entryNodeId`entryNodeIds 来自 group 定义source/sourceHandle 来自 UI 边)
- 若选中并删除的是临时 outbound UI 边:删除对应真实边 `leafNodeId(sourceHandle) -> target`
### 7) 可编辑
- Group 标题:更新 `custom-group.data.group.title`
- Exit Port 名称:更新 `custom-group-exit-port.data.exitPort.name`
- 通过 `useNodeDataUpdateWithSyncDraft` 写回并 sync draft

View File

@@ -0,0 +1,136 @@
import type { PredecessorHandle } from '../utils'
import { useMemo } from 'react'
import { useStore as useReactFlowStore } from 'reactflow'
import { shallow } from 'zustand/shallow'
import { getCommonPredecessorHandles } from '../utils'
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

@@ -8,6 +8,7 @@ import type {
ResizeParamsWithDirection,
} from 'reactflow'
import type { PluginDefaultValue } from '../block-selector/types'
import type { GroupHandler, GroupMember, GroupNodeData } from '../nodes/group/types'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
@@ -52,6 +53,7 @@ import { useWorkflowHistoryStore } from '../workflow-history-store'
import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url'
import { useHelpline } from './use-helpline'
import useInspectVarsCrud from './use-inspect-vars-crud'
import { checkMakeGroupAvailability } from './use-make-group'
import { useNodesMetaData } from './use-nodes-meta-data'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import {
@@ -73,6 +75,79 @@ const ENTRY_NODE_WRAPPER_OFFSET = {
y: 21, // Adjusted based on visual testing feedback
} as const
/**
* Parse group handler id to get original node id and sourceHandle
* Handler id format: `${nodeId}-${sourceHandle}`
*/
function parseGroupHandlerId(handlerId: string): { originalNodeId: string, originalSourceHandle: string } {
const lastDashIndex = handlerId.lastIndexOf('-')
return {
originalNodeId: handlerId.substring(0, lastDashIndex),
originalSourceHandle: handlerId.substring(lastDashIndex + 1),
}
}
/**
* Create a pair of edges for group node connections:
* - realEdge: hidden edge from original node to target (persisted to backend)
* - uiEdge: visible temp edge from group to target (UI-only, not persisted)
*/
function createGroupEdgePair(params: {
groupNodeId: string
handlerId: string
targetNodeId: string
targetHandle: string
nodes: Node[]
baseEdgeData?: Partial<Edge['data']>
zIndex?: number
}): { realEdge: Edge, uiEdge: Edge } | null {
const { groupNodeId, handlerId, targetNodeId, targetHandle, nodes, baseEdgeData = {}, zIndex = 0 } = params
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(handlerId)
const originalNode = nodes.find(node => node.id === originalNodeId)
const targetNode = nodes.find(node => node.id === targetNodeId)
if (!originalNode || !targetNode)
return null
// Create the real edge (from original node to target) - hidden because original node is in group
const realEdge: Edge = {
id: `${originalNodeId}-${originalSourceHandle}-${targetNodeId}-${targetHandle}`,
type: CUSTOM_EDGE,
source: originalNodeId,
sourceHandle: originalSourceHandle,
target: targetNodeId,
targetHandle,
hidden: true,
data: {
...baseEdgeData,
sourceType: originalNode.data.type,
targetType: targetNode.data.type,
_hiddenInGroupId: groupNodeId,
},
zIndex,
}
// Create the UI edge (from group to target) - temporary, not persisted to backend
const uiEdge: Edge = {
id: `${groupNodeId}-${handlerId}-${targetNodeId}-${targetHandle}`,
type: CUSTOM_EDGE,
source: groupNodeId,
sourceHandle: handlerId,
target: targetNodeId,
targetHandle,
data: {
...baseEdgeData,
sourceType: originalNode.data.type, // Use original node type, not group
targetType: targetNode.data.type,
_isTemp: true,
},
zIndex,
}
return { realEdge, uiEdge }
}
export const useNodesInteractions = () => {
const { t } = useTranslation()
const store = useStoreApi()
@@ -448,6 +523,77 @@ export const useNodesInteractions = () => {
return
}
// Check if source is a group node - need special handling
const isSourceGroup = sourceNode?.data.type === BlockEnum.Group
if (isSourceGroup && sourceHandle && target && targetHandle) {
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(sourceHandle)
// Check if real edge already exists
if (edges.find(edge =>
edge.source === originalNodeId
&& edge.sourceHandle === originalSourceHandle
&& edge.target === target
&& edge.targetHandle === targetHandle,
)) {
return
}
const parentNode = nodes.find(node => node.id === targetNode?.parentId)
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
const edgePair = createGroupEdgePair({
groupNodeId: source!,
handlerId: sourceHandle,
targetNodeId: target,
targetHandle,
nodes,
baseEdgeData: {
isInIteration,
iteration_id: isInIteration ? targetNode?.parentId : undefined,
isInLoop,
loop_id: isInLoop ? targetNode?.parentId : undefined,
},
})
if (!edgePair)
return
const { realEdge, uiEdge } = edgePair
// Update connected handle ids for the original node
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge: realEdge }],
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
const newEdges = produce(edges, (draft) => {
draft.push(realEdge)
draft.push(uiEdge)
})
setNodes(newNodes)
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
nodeId: targetNode?.id,
})
return
}
// Normal edge connection (non-group source)
if (
edges.find(
edge =>
@@ -909,8 +1055,34 @@ export const useNodesInteractions = () => {
}
}
let newEdge = null
if (nodeType !== BlockEnum.DataSource) {
// Check if prevNode is a group node - need special handling
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
let newEdge: Edge | null = null
let newUiEdge: Edge | null = null
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
const edgePair = createGroupEdgePair({
groupNodeId: prevNodeId,
handlerId: prevNodeSourceHandle,
targetNodeId: newNode.id,
targetHandle,
nodes: [...nodes, newNode],
baseEdgeData: {
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
})
if (edgePair) {
newEdge = edgePair.realEdge
newUiEdge = edgePair.uiEdge
}
}
else if (nodeType !== BlockEnum.DataSource) {
// Normal case: prevNode is not a group
newEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
@@ -935,9 +1107,10 @@ export const useNodesInteractions = () => {
}
}
const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge: edge! }))
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
(newEdge ? [{ type: 'add', edge: newEdge }] : []),
edgesToAdd,
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
@@ -1006,6 +1179,8 @@ export const useNodesInteractions = () => {
})
if (newEdge)
draft.push(newEdge)
if (newUiEdge)
draft.push(newUiEdge)
})
setNodes(newNodes)
@@ -1200,33 +1375,82 @@ export const useNodesInteractions = () => {
}
}
const currentEdgeIndex = edges.findIndex(
edge => edge.source === prevNodeId && edge.target === nextNodeId,
)
let newPrevEdge = null
// Check if prevNode is a group node - need special handling
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
let newPrevEdge: Edge | null = null
let newPrevUiEdge: Edge | null = null
const edgesToRemove: string[] = []
if (nodeType !== BlockEnum.DataSource) {
newPrevEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
source: prevNodeId,
sourceHandle: prevNodeSourceHandle,
target: newNode.id,
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(prevNodeSourceHandle)
// Find edges to remove: both hidden real edge and UI temp edge from group to nextNode
const hiddenEdge = edges.find(
edge => edge.source === originalNodeId
&& edge.sourceHandle === originalSourceHandle
&& edge.target === nextNodeId,
)
const uiTempEdge = edges.find(
edge => edge.source === prevNodeId
&& edge.sourceHandle === prevNodeSourceHandle
&& edge.target === nextNodeId,
)
if (hiddenEdge)
edgesToRemove.push(hiddenEdge.id)
if (uiTempEdge)
edgesToRemove.push(uiTempEdge.id)
const edgePair = createGroupEdgePair({
groupNodeId: prevNodeId,
handlerId: prevNodeSourceHandle,
targetNodeId: newNode.id,
targetHandle,
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
nodes: [...nodes, newNode],
baseEdgeData: {
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: prevNode.parentId
? isInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
})
if (edgePair) {
newPrevEdge = edgePair.realEdge
newPrevUiEdge = edgePair.uiEdge
}
}
else {
// Normal case: find edge to remove
const currentEdge = edges.find(
edge => edge.source === prevNodeId && edge.target === nextNodeId,
)
if (currentEdge)
edgesToRemove.push(currentEdge.id)
if (nodeType !== BlockEnum.DataSource) {
newPrevEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
source: prevNodeId,
sourceHandle: prevNodeSourceHandle,
target: newNode.id,
targetHandle,
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: prevNode.parentId
? isInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
}
}
@@ -1271,13 +1495,15 @@ export const useNodesInteractions = () => {
: 0,
}
}
const edgeChanges = [
...edgesToRemove.map(id => ({ type: 'remove' as const, edge: edges.find(e => e.id === id)! })).filter(c => c.edge),
...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []),
...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []),
...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []),
]
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
[
{ type: 'remove', edge: edges[currentEdgeIndex] },
...(newPrevEdge ? [{ type: 'add', edge: newPrevEdge }] : []),
...(newNextEdge ? [{ type: 'add', edge: newNextEdge }] : []),
],
edgeChanges,
[...nodes, newNode],
)
@@ -1342,7 +1568,11 @@ export const useNodesInteractions = () => {
})
}
const newEdges = produce(edges, (draft) => {
draft.splice(currentEdgeIndex, 1)
// Remove edges by id (supports removing multiple edges for group case)
const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id))
draft.length = 0
draft.push(...filteredDraft)
draft.forEach((item) => {
item.data = {
...item.data,
@@ -1351,7 +1581,8 @@ export const useNodesInteractions = () => {
})
if (newPrevEdge)
draft.push(newPrevEdge)
if (newPrevUiEdge)
draft.push(newPrevUiEdge)
if (newNextEdge)
draft.push(newNextEdge)
})
@@ -2078,6 +2309,221 @@ 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])
// 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])
const handleMakeGroup = useCallback(() => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const bundledNodes = nodes.filter(node => node.data._isBundled)
const bundledNodeIds = bundledNodes.map(node => node.id)
if (bundledNodeIds.length <= 1)
return
const minimalEdges = edges.map(edge => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle || 'source',
target: edge.target,
}))
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges)
if (!canMakeGroup)
return
const bundledNodeIdSet = new Set(bundledNodeIds)
const bundledNodeIdIsLeaf = new Set<string>()
const inboundEdges = edges.filter(edge => !bundledNodeIdSet.has(edge.source) && bundledNodeIdSet.has(edge.target))
const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target))
// leaf node: no outbound edges to other nodes in the selection
const leafNodeIds = bundledNodes
.filter(node => !edges.some(edge => edge.source === node.id && bundledNodeIdSet.has(edge.target)))
.map(node => node.id)
leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id))
const members: GroupMember[] = bundledNodes.map((node) => {
return {
id: node.id,
type: node.data.type,
label: node.data.title,
}
})
// Build handlers from all leaf nodes
// For multi-branch nodes (if-else, classifier), create one handler per branch
// For regular nodes, create one handler with 'source' handle
const handlerMap = new Map<string, GroupHandler>()
leafNodeIds.forEach((nodeId) => {
const node = bundledNodes.find(n => n.id === nodeId)
if (!node)
return
const targetBranches = node.data._targetBranches
if (targetBranches && targetBranches.length > 0) {
// Multi-branch node: create handler for each branch
targetBranches.forEach((branch: { id: string, name?: string }) => {
const handlerId = `${nodeId}-${branch.id}`
handlerMap.set(handlerId, {
id: handlerId,
label: branch.name || node.data.title || nodeId,
nodeId,
sourceHandle: branch.id,
})
})
}
else {
// Regular node: single 'source' handler
const handlerId = `${nodeId}-source`
handlerMap.set(handlerId, {
id: handlerId,
label: node.data.title || nodeId,
nodeId,
sourceHandle: 'source',
})
}
})
const handlers: GroupHandler[] = Array.from(handlerMap.values())
// put the group node at the top-left corner of the selection, slightly offset
const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes)
const groupNodeData: GroupNodeData = {
title: t('workflow.operator.makeGroup'),
desc: '',
type: BlockEnum.Group,
members,
handlers,
selected: true,
}
const { newNode: groupNode } = generateNewNode({
data: groupNodeData,
position: {
x: minX - 20,
y: minY - 20,
},
})
const nodeTypeMap = new Map(nodes.map(node => [node.id, node.data.type]))
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (bundledNodeIdSet.has(node.id)) {
node.data._isBundled = false
node.selected = false
node.hidden = true
node.data._hiddenInGroupId = groupNode.id
}
else {
node.data._isBundled = false
}
})
draft.push(groupNode)
})
const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => {
if (bundledNodeIdSet.has(edge.source) || bundledNodeIdSet.has(edge.target)) {
edge.hidden = true
edge.data = {
...edge.data,
_hiddenInGroupId: groupNode.id,
_isBundled: false,
}
}
else if (edge.data?._isBundled) {
edge.data._isBundled = false
}
})
// re-add the external inbound edges to the group node as UI-only edges (not persisted to backend)
inboundEdges.forEach((edge) => {
draft.push({
id: `${edge.id}__to-${groupNode.id}`,
type: edge.type || CUSTOM_EDGE,
source: edge.source,
target: groupNode.id,
sourceHandle: edge.sourceHandle,
targetHandle: 'target',
data: {
...edge.data,
sourceType: nodeTypeMap.get(edge.source)!,
targetType: BlockEnum.Group,
_hiddenInGroupId: undefined,
_isBundled: false,
_isTemp: true, // UI-only edge, not persisted to backend
},
zIndex: edge.zIndex,
})
})
// outbound edges of the group node as UI-only edges (not persisted to backend)
outboundEdges.forEach((edge) => {
if (!bundledNodeIdIsLeaf.has(edge.source))
return
// Use the same handler id format: nodeId-sourceHandle
const originalSourceHandle = edge.sourceHandle || 'source'
const handlerId = `${edge.source}-${originalSourceHandle}`
draft.push({
id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`,
type: edge.type || CUSTOM_EDGE,
source: groupNode.id,
target: edge.target,
sourceHandle: handlerId, // handler id: nodeId-sourceHandle
targetHandle: edge.targetHandle,
data: {
...edge.data,
sourceType: edge.data.sourceType, // Keep original node type, not group
targetType: nodeTypeMap.get(edge.target)!,
_hiddenInGroupId: undefined,
_isBundled: false,
_isTemp: true, // UI-only edge, not persisted to backend
},
zIndex: edge.zIndex,
})
})
})
setNodes(newNodes)
setEdges(newEdges)
workflowStore.setState({
selectionMenu: undefined,
})
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, {
nodeId: groupNode.id,
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore])
return {
handleNodeDragStart,
handleNodeDrag,
@@ -2098,11 +2544,14 @@ export const useNodesInteractions = () => {
handleNodesPaste,
handleNodesDuplicate,
handleNodesDelete,
handleMakeGroup,
handleNodeResize,
handleNodeDisconnect,
handleHistoryBack,
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
hasBundledNodes,
getCanMakeGroup,
}
}

View File

@@ -27,6 +27,9 @@ export const useShortcuts = (): void => {
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
hasBundledNodes,
getCanMakeGroup,
handleMakeGroup,
} = useNodesInteractions()
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
@@ -73,7 +76,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 +98,16 @@ export const useShortcuts = (): void => {
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => {
// Only intercept when the selection can be grouped
if (shouldHandleShortcut(e) && getCanMakeGroup()) {
e.preventDefault()
// Close selection context menu if open
workflowStore.setState({ selectionMenu: undefined })
handleMakeGroup()
}
}, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()

View File

@@ -53,6 +53,14 @@ import {
} from './constants'
import CustomConnectionLine from './custom-connection-line'
import CustomEdge from './custom-edge'
import {
CUSTOM_GROUP_EXIT_PORT_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_NODE,
CustomGroupExitPortNode,
CustomGroupInputNode,
CustomGroupNode,
} from './custom-group-node'
import DatasetsDetailProvider from './datasets-detail-store/provider'
import HelpLine from './help-line'
import {
@@ -111,6 +119,9 @@ const nodeTypes = {
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
[CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
[CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode,
[CUSTOM_GROUP_NODE]: CustomGroupNode,
[CUSTOM_GROUP_INPUT_NODE]: CustomGroupInputNode,
[CUSTOM_GROUP_EXIT_PORT_NODE]: CustomGroupExitPortNode,
}
const edgeTypes = {
[CUSTOM_EDGE]: CustomEdge,

View File

@@ -56,6 +56,7 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = {
[BlockEnum.VariableAggregator]: useVariableAggregatorSingleRunFormParams,
[BlockEnum.Assigner]: useVariableAssignerSingleRunFormParams,
[BlockEnum.KnowledgeBase]: useKnowledgeBaseSingleRunFormParams,
[BlockEnum.Group]: undefined,
[BlockEnum.VariableAssigner]: undefined,
[BlockEnum.End]: undefined,
[BlockEnum.Answer]: undefined,
@@ -103,6 +104,7 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = {
[BlockEnum.DataSource]: undefined,
[BlockEnum.DataSourceEmpty]: undefined,
[BlockEnum.KnowledgeBase]: undefined,
[BlockEnum.Group]: undefined,
[BlockEnum.TriggerWebhook]: undefined,
[BlockEnum.TriggerSchedule]: undefined,
[BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore,

View File

@@ -221,7 +221,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
}
{
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.Group && !data._isCandidate && (
<NodeSourceHandle
id={id}
data={data}

View File

@@ -14,6 +14,8 @@ import DocExtractorNode from './document-extractor/node'
import DocExtractorPanel from './document-extractor/panel'
import EndNode from './end/node'
import EndPanel from './end/panel'
import GroupNode from './group/node'
import GroupPanel from './group/panel'
import HttpNode from './http/node'
import HttpPanel from './http/panel'
import IfElseNode from './if-else/node'
@@ -75,6 +77,7 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
[BlockEnum.Group]: GroupNode,
}
export const PanelComponentMap: Record<string, ComponentType<any>> = {
@@ -103,4 +106,5 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
[BlockEnum.Group]: GroupPanel,
}

View File

@@ -0,0 +1,93 @@
import type { GroupHandler, GroupMember, GroupNodeData } from './types'
import type { BlockEnum, NodeProps } from '@/app/components/workflow/types'
import { RiArrowRightSLine } from '@remixicon/react'
import { memo, useMemo } from 'react'
import BlockIcon from '@/app/components/workflow/block-icon'
import { cn } from '@/utils/classnames'
import { NodeSourceHandle } from '../_base/components/node-handle'
const MAX_MEMBER_ICONS = 12
const GroupNode = (props: NodeProps<GroupNodeData>) => {
const { data } = props
// show the explicitly passed members first; otherwise use the _children information to fill the type
const members: GroupMember[] = useMemo(() => (
data.members?.length
? data.members
: data._children?.length
? data._children.map(child => ({
id: child.nodeId,
type: child.nodeType as BlockEnum,
label: child.nodeType,
}))
: []
), [data._children, data.members])
// handler 列表:优先使用传入的 handlers缺省时用 members 的 label 填充。
const handlers: GroupHandler[] = useMemo(() => (
data.handlers?.length
? data.handlers
: members.length
? members.map(member => ({
id: member.id,
label: member.label || member.id,
}))
: []
), [data.handlers, members])
return (
<div className="space-y-2 px-3 pb-3">
{members.length > 0 && (
<div className="flex items-center gap-1 overflow-hidden">
<div className="flex flex-wrap items-center gap-1 overflow-hidden">
{members.slice(0, MAX_MEMBER_ICONS).map(member => (
<div
key={member.id}
className="flex h-7 items-center rounded-full bg-components-input-bg-normal px-1.5 shadow-xs"
>
<BlockIcon
type={member.type}
size="xs"
className="!shadow-none"
/>
</div>
))}
{members.length > MAX_MEMBER_ICONS && (
<div className="system-xs-medium rounded-full bg-components-input-bg-normal px-2 py-1 text-text-tertiary">
+
{members.length - MAX_MEMBER_ICONS}
</div>
)}
</div>
<RiArrowRightSLine className="ml-auto h-4 w-4 shrink-0 text-text-tertiary" />
</div>
)}
{handlers.length > 0 && (
<div className="space-y-1">
{handlers.map(handler => (
<div
key={handler.id}
className={cn(
'relative',
'system-sm-semibold uppercase',
'flex h-9 items-center rounded-md bg-components-panel-on-panel-item-bg px-3 text-text-primary shadow-xs',
)}
>
{handler.label || handler.id}
<NodeSourceHandle
{...props}
handleId={handler.id}
handleClassName="!top-1/2 !-translate-y-1/2 !-right-[21px]"
/>
</div>
))}
</div>
)}
</div>
)
}
GroupNode.displayName = 'GroupNode'
export default memo(GroupNode)

View File

@@ -0,0 +1,9 @@
import { memo } from 'react'
const GroupPanel = () => {
return null
}
GroupPanel.displayName = 'GroupPanel'
export default memo(GroupPanel)

View File

@@ -0,0 +1,19 @@
import type { BlockEnum, CommonNodeType } from '../../types'
export type GroupMember = {
id: string
type: BlockEnum
label?: string
}
export type GroupHandler = {
id: string
label?: string
nodeId?: string // leaf node id for multi-branch nodes
sourceHandle?: string // original sourceHandle (e.g., case_id for if-else)
}
export type GroupNodeData = CommonNodeType<{
members?: GroupMember[]
handlers?: GroupHandler[]
}>

View File

@@ -1,3 +1,4 @@
import type { FC, ReactElement } from 'react'
import {
RiAlignBottom,
RiAlignCenter,
@@ -17,9 +18,13 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { shallow } from 'zustand/shallow'
import Tooltip from '@/app/components/base/tooltip'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useMakeGroupAvailability } from './hooks/use-make-group'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import ShortcutsName from './shortcuts-name'
import { useStore, useWorkflowStore } from './store'
enum AlignType {
@@ -33,21 +38,66 @@ 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<AlignButtonProps> = ({ config, onClick, position = 'bottom' }) => {
return (
<Tooltip position={position} popupContent={config.labelKey}>
<div
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-text-secondary hover:bg-state-base-hover"
onClick={() => onClick(config.type)}
>
{config.icon}
</div>
</Tooltip>
)
}
const ALIGN_BUTTONS: AlignButtonConfig[] = [
{ type: AlignType.Left, icon: <RiAlignLeft className="h-4 w-4" />, labelKey: 'workflow.operator.alignLeft' },
{ type: AlignType.Center, icon: <RiAlignCenter className="h-4 w-4" />, labelKey: 'workflow.operator.alignCenter' },
{ type: AlignType.Right, icon: <RiAlignRight className="h-4 w-4" />, labelKey: 'workflow.operator.alignRight' },
{ type: AlignType.DistributeHorizontal, icon: <RiAlignJustify className="h-4 w-4" />, labelKey: 'workflow.operator.distributeHorizontal' },
{ type: AlignType.Top, icon: <RiAlignTop className="h-4 w-4" />, labelKey: 'workflow.operator.alignTop' },
{ type: AlignType.Middle, icon: <RiAlignCenter className="h-4 w-4 rotate-90" />, labelKey: 'workflow.operator.alignMiddle' },
{ type: AlignType.Bottom, icon: <RiAlignBottom className="h-4 w-4" />, labelKey: 'workflow.operator.alignBottom' },
{ type: AlignType.DistributeVertical, icon: <RiAlignJustify className="h-4 w-4 rotate-90" />, 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,
handleMakeGroup,
} = 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 { canMakeGroup } = useMakeGroupAvailability(selectedNodeIds)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
@@ -65,9 +115,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 +137,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) => {
@@ -248,7 +298,7 @@ const SelectionContextmenu = () => {
}, [])
const handleAlignNodes = useCallback((alignType: AlignType) => {
if (getNodesReadOnly() || selectedNodes.length <= 1) {
if (getNodesReadOnly() || selectedNodeIds.length <= 1) {
handleSelectionContextmenuCancel()
return
}
@@ -259,9 +309,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.
@@ -367,7 +414,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
@@ -381,73 +428,74 @@ const SelectionContextmenu = () => {
}}
ref={ref}
>
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('workflow.operator.vertical')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Top)}
>
<RiAlignTop className="h-4 w-4" />
{t('workflow.operator.alignTop')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Middle)}
>
<RiAlignCenter className="h-4 w-4 rotate-90" />
{t('workflow.operator.alignMiddle')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Bottom)}
>
<RiAlignBottom className="h-4 w-4" />
{t('workflow.operator.alignBottom')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
>
<RiAlignJustify className="h-4 w-4 rotate-90" />
{t('workflow.operator.distributeVertical')}
</div>
</div>
<div className="h-px bg-divider-regular"></div>
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('workflow.operator.horizontal')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Left)}
>
<RiAlignLeft className="h-4 w-4" />
{t('workflow.operator.alignLeft')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Center)}
>
<RiAlignCenter className="h-4 w-4" />
{t('workflow.operator.alignCenter')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Right)}
>
<RiAlignRight className="h-4 w-4" />
{t('workflow.operator.alignRight')}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
>
<RiAlignJustify className="h-4 w-4" />
{t('workflow.operator.distributeHorizontal')}
</div>
<div ref={menuRef} className="w-[244px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
{!nodesReadOnly && (
<>
<div className="p-1">
<div
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
handleMakeGroup()
handleSelectionContextmenuCancel()
}}
>
{t('workflow.operator.makeGroup')}
<ShortcutsName keys={['ctrl', 'g']} className={!canMakeGroup ? 'opacity-50' : ''} />
</div>
</div>
<div className="h-px bg-divider-regular" />
<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={() => {
handleNodesCopy()
handleSelectionContextmenuCancel()
}}
>
{t('workflow.common.copy')}
<ShortcutsName keys={['ctrl', 'c']} />
</div>
<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={() => {
handleNodesDuplicate()
handleSelectionContextmenuCancel()
}}
>
{t('workflow.common.duplicate')}
<ShortcutsName keys={['ctrl', 'd']} />
</div>
</div>
<div className="h-px bg-divider-regular" />
<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-destructive-hover hover:text-text-destructive"
onClick={() => {
handleNodesDelete()
handleSelectionContextmenuCancel()
}}
>
{t('common.operation.delete')}
<ShortcutsName keys={['del']} />
</div>
</div>
<div className="h-px bg-divider-regular" />
</>
)}
<div className="flex items-center justify-between p-1">
{ALIGN_BUTTONS.map(config => (
<AlignButton
key={config.type}
config={{ ...config, labelKey: t(config.labelKey) }}
onClick={handleAlignNodes}
/>
))}
</div>
</div>
</div>

View File

@@ -30,6 +30,7 @@ export enum BlockEnum {
Code = 'code',
TemplateTransform = 'template-transform',
HttpRequest = 'http-request',
Group = 'group',
VariableAssigner = 'variable-assigner',
VariableAggregator = 'variable-aggregator',
Tool = 'tool',
@@ -79,6 +80,7 @@ export type CommonNodeType<T = {}> = {
_isEntering?: boolean
_showAddVariablePopup?: boolean
_holdAddVariablePopup?: boolean
_hiddenInGroupId?: string
_iterationLength?: number
_iterationIndex?: number
_waitingRun?: boolean
@@ -113,6 +115,7 @@ export type CommonEdgeType = {
_connectedNodeIsHovering?: boolean
_connectedNodeIsSelected?: boolean
_isBundled?: boolean
_hiddenInGroupId?: string
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean

View File

@@ -1,3 +1,4 @@
import type { CustomGroupNodeData } from '../custom-group-node'
import type { IfElseNodeType } from '../nodes/if-else/types'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
@@ -13,11 +14,8 @@ import {
import {
getConnectedEdges,
} from 'reactflow'
import { getIterationStartNode, getLoopStartNode } from '@/app/components/workflow/utils/node'
import { correctModelProvider } from '@/utils'
import {
getIterationStartNode,
getLoopStartNode,
} from '.'
import {
CUSTOM_NODE,
DEFAULT_RETRY_INTERVAL,
@@ -27,6 +25,10 @@ import {
NODE_WIDTH_X_OFFSET,
START_INITIAL_POSITION,
} from '../constants'
import {
CUSTOM_GROUP_NODE,
GROUP_CHILDREN_Z_INDEX,
} from '../custom-group-node'
import { branchNameCorrect } from '../nodes/if-else/utils'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
@@ -91,8 +93,9 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE)
if (!hasIterationNode && !hasLoopNode) {
if (!hasIterationNode && !hasLoopNode && !hasGroupNode) {
return {
nodes,
edges,
@@ -189,9 +192,67 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
(node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id
})
// Derive Group internal edges (input → entries, leaves → exits)
const groupInternalEdges: Edge[] = []
const groupNodes = nodes.filter(node => node.type === CUSTOM_GROUP_NODE)
for (const groupNode of groupNodes) {
const groupData = groupNode.data as unknown as CustomGroupNodeData
const { group } = groupData
if (!group)
continue
const { inputNodeId, entryNodeIds, exitPorts } = group
// Derive edges: input → each entry node
for (const entryId of entryNodeIds) {
const entryNode = nodesMap[entryId]
if (entryNode) {
groupInternalEdges.push({
id: `group-internal-${inputNodeId}-source-${entryId}-target`,
type: 'custom',
source: inputNodeId,
sourceHandle: 'source',
target: entryId,
targetHandle: 'target',
data: {
sourceType: '' as any, // Group input has empty type
targetType: entryNode.data.type,
_isGroupInternal: true,
_groupId: groupNode.id,
},
zIndex: GROUP_CHILDREN_Z_INDEX,
} as Edge)
}
}
// Derive edges: each leaf node → exit port
for (const exitPort of exitPorts) {
const leafNode = nodesMap[exitPort.leafNodeId]
if (leafNode) {
groupInternalEdges.push({
id: `group-internal-${exitPort.leafNodeId}-${exitPort.sourceHandle}-${exitPort.portNodeId}-target`,
type: 'custom',
source: exitPort.leafNodeId,
sourceHandle: exitPort.sourceHandle,
target: exitPort.portNodeId,
targetHandle: 'target',
data: {
sourceType: leafNode.data.type,
targetType: '' as any, // Exit port has empty type
_isGroupInternal: true,
_groupId: groupNode.id,
},
zIndex: GROUP_CHILDREN_Z_INDEX,
} as Edge)
}
}
}
return {
nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes],
edges: [...edges, ...newEdges],
edges: [...edges, ...newEdges, ...groupInternalEdges],
}
}

View File

@@ -157,6 +157,95 @@ 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<string, Set<string>>()
edges.forEach((edge) => {
if (!selectedNodeIdSet.has(edge.target))
return
const predecessors = predecessorNodeIdsMap.get(edge.target) ?? new Set<string>()
predecessors.add(edge.source)
predecessorNodeIdsMap.set(edge.target, predecessors)
})
let commonPredecessorNodeIds: Set<string> | null = null
uniqSelectedNodeIds.forEach((nodeId) => {
const predecessors = predecessorNodeIdsMap.get(nodeId) ?? new Set<string>()
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 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()

View File

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

View File

@@ -359,6 +359,7 @@ const translation = {
zoomTo50: '50% サイズ',
zoomTo100: '等倍表示',
zoomToFit: '画面に合わせる',
makeGroup: 'グループ化',
horizontal: '水平',
alignBottom: '下',
alignNodes: 'ノードを整列',

View File

@@ -359,6 +359,7 @@ const translation = {
zoomTo50: '缩放到 50%',
zoomTo100: '放大到 100%',
zoomToFit: '自适应视图',
makeGroup: '创建分组',
alignNodes: '对齐节点',
alignLeft: '左对齐',
alignCenter: '居中对齐',

View File

@@ -344,6 +344,7 @@ const translation = {
zoomTo50: '縮放到 50%',
zoomTo100: '放大到 100%',
zoomToFit: '自適應視圖',
makeGroup: '建立群組',
alignNodes: '對齊節點',
distributeVertical: '垂直等間距',
alignLeft: '左對齊',