feat(flows): 新增流程编辑器基础功能与相关组件

feat(backend): 添加流程模型与服务支持
feat(frontend): 实现流程编辑器UI与交互
feat(assets): 添加流程节点图标资源
feat(plugins): 实现上下文菜单和运行时插件
feat(components): 新增基础节点和侧边栏组件
feat(routes): 添加流程相关路由配置
feat(models): 创建流程和运行日志数据模型
feat(services): 实现流程服务层逻辑
feat(migration): 添加流程相关数据库迁移
feat(config): 更新前端配置支持流程编辑器
feat(utils): 增强axios错误处理和工具函数
This commit is contained in:
2025-09-15 00:27:13 +08:00
parent 9da3978f91
commit b0963e5e37
291 changed files with 17947 additions and 86 deletions

View File

@ -0,0 +1,346 @@
# 创建自由布局画布
本案例可通过 `npx @flowgram.ai/create-app@latest free-layout-simple` 安装,完整代码及效果见:
<div className="rs-tip">
<a className="rs-link" href="/examples/free-layout/free-layout-simple.html">
自由布局基础用法
</a>
</div>
文件结构:
```
- hooks
- use-editor-props.ts # 画布配置
- components
- base-node.tsx # 节点渲染
- tools.tsx # 画布工具栏
- initial-data.ts # 初始化数据
- node-registries.ts # 节点配置
- app.tsx # 画布入口
```
### 1. 画布入口
* `FreeLayoutEditorProvider`: 画布配置器, 内部会生成 react-context 供子组件消费
* `EditorRenderer`: 为最终渲染的画布,可以包装在其他组件下边方便定制画布位置
```tsx pure title="app.tsx"
import {
FreeLayoutEditorProvider,
EditorRenderer,
} from '@flowgram.ai/free-layout-editor';
import '@flowgram.ai/free-layout-editor/index.css'; // 加载样式
import { useEditorProps } from './use-editor-props' // 画布详细的 props 配置
import { Tools } from './components/tools' // 画布工具
function App() {
const editorProps = useEditorProps()
return (
<FreeLayoutEditorProvider {...editorProps}>
<EditorRenderer className="demo-editor" />
<Tools />
</FreeLayoutEditorProvider>
);
}
```
### 2. 配置画布
画布配置采用声明式,提供 数据、渲染、事件、插件相关配置
```tsx pure title="hooks/use-editor-props.tsx"
import { useMemo } from 'react';
import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { initialData } from './initial-data' // 初始化数据
import { nodeRegistries } from './node-registries' // 节点声明配置
import { BaseNode } from './components/base-node' // 节点渲染
export function useEditorProps(
): FreeLayoutProps {
return useMemo<FreeLayoutProps>(
() => ({
/**
* 初始化数据
*/
initialData,
/**
* 画布节点定义
*/
nodeRegistries,
/**
* 物料
*/
materials: {
renderDefaultNode: BaseNode, // 节点渲染组件
},
/**
* 节点引擎, 用于渲染节点表单
*/
nodeEngine: {
enable: true,
},
/**
* 画布历史记录, 用于控制 redo/undo
*/
history: {
enable: true,
enableChangeNode: true, // 用于监听节点表单数据变化
},
/**
* 画布初始化回调
*/
onInit: ctx => {
// 如果要动态加载数据,可以通过如下方法异步执行
// ctx.docuemnt.fromJSON(initialData)
},
/**
* 画布第一次渲染完整回调
*/
onAllLayersRendered: (ctx) => {},
/**
* 画布销毁回调
*/
onDispose: () => { },
plugins: () => [
/**
* 缩略图插件
*/
createMinimapPlugin({}),
],
}),
[],
);
}
```
### 3. 配置数据
画布文档数据采用树形结构,支持嵌套
:::note 文档数据基本结构:
* nodes `array` 节点列表, 支持嵌套
* edges `array` 边列表
:::
:::note 节点数据基本结构:
* id: `string` 节点唯一标识, 必须保证唯一
* meta: `object` 节点的 ui 配置信息,如自由布局的 `position` 信息放这里
* type: `string | number` 节点类型,会和 `nodeRegistries` 中的 `type` 对应
* data: `object` 节点表单数据, 业务可自定义
* blocks: `array` 节点的分支, 采用 `block` 更贴近 `Gramming`, 目前会存子画布的节点
* edges: `array` 子画布的边数据
:::
:::note 边数据基本结构:
* sourceNodeID: `string` 开始节点 id
* targetNodeID: `string` 目标节点 id
* sourcePortID?: `string | number` 开始端口 id, 缺省则采用开始节点的默认端口
* targetPortID?: `string | number` 目标端口 id, 缺省则采用目标节点的默认端口
:::
```tsx pure title="initial-data.ts"
import { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
export const initialData: WorkflowJSON = {
nodes: [
{
id: 'start_0',
type: 'start',
meta: {
position: { x: 0, y: 0 },
},
data: {
title: 'Start',
content: 'Start content'
},
},
{
id: 'node_0',
type: 'custom',
meta: {
position: { x: 400, y: 0 },
},
data: {
title: 'Custom',
content: 'Custom node content'
},
},
{
id: 'end_0',
type: 'end',
meta: {
position: { x: 800, y: 0 },
},
data: {
title: 'End',
content: 'End content'
},
},
],
edges: [
{
sourceNodeID: 'start_0',
targetNodeID: 'node_0',
},
{
sourceNodeID: 'node_0',
targetNodeID: 'end_0',
},
],
};
```
### 4. 声明节点
声明节点可以用于确定节点的类型及渲染方式
```tsx pure title="node-registries.tsx"
import { WorkflowNodeRegistry, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
/**
* You can customize your own node registry
* 你可以自定义节点的注册器
*/
export const nodeRegistries: WorkflowNodeRegistry[] = [
{
type: 'start',
meta: {
isStart: true, // 标记为开始节点
deleteDisable: true, // 开始节点不能删除
copyDisable: true, // 开始节点不能复制
defaultPorts: [{ type: 'output' }], // 用于定义节点的输入和输出端口, 开始节点只有输出端口
// useDynamicPort: true, // 用于动态端口,会寻找 data-port-id 和 data-port-type 属性的 dom 作为端口
},
/**
* 配置节点表单的校验及渲染,
* 注validate 采用数据和渲染分离,保证节点即使不渲染也能对数据做校验
*/
formMeta: {
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }) => (value ? undefined : 'Title is required'),
},
/**
* Render form
*/
render: () => (
<>
<Field name="title">
{({ field }) => <div className="demo-free-node-title">{field.value}</div>}
</Field>
<Field name="content">
{({ field }) => <input onChange={field.onChange} value={field.value}/>}
</Field>
</>
)
},
},
{
type: 'end',
meta: {
deleteDisable: true,
copyDisable: true,
defaultPorts: [{ type: 'input' }],
},
formMeta: {
// ...
}
},
{
type: 'custom',
meta: {
},
formMeta: {
// ...
},
defaultPorts: [{ type: 'output' }, { type: 'input' }], // 普通节点有两个端口
},
];
```
### 5. 渲染节点
渲染节点用于添加样式、事件及表单渲染的位置
```tsx pure title="components/base-node.tsx"
import { useNodeRender, WorkflowNodeRenderer } from '@flowgram.ai/free-layout-editor';
export const BaseNode = () => {
/**
* 提供节点渲染相关的方法
*/
const { form } = useNodeRender()
/**
* WorkflowNodeRenderer 会添加节点拖拽事件及 端口渲染,如果要深度定制,可以看该组件源代码:
* https://github.com/bytedance/flowgram.ai/blob/main/packages/client/free-layout-editor/src/components/workflow-node-renderer.tsx
*/
return (
<WorkflowNodeRenderer className="demo-free-node" node={props.node}>
{
// 表单渲染通过 formMeta 生成
form?.render()
}
</WorkflowNodeRenderer>
)
};
```
### 6. 添加工具
工具主要用于控制画布缩放等操作, 工具汇总在 `usePlaygroundTools` 中, 而 `useClientContext` 用于获取画布的上下文, 里边包含画布的核心模块如 `history`
```tsx pure title="components/tools.tsx"
import { useEffect, useState } from 'react'
import { usePlaygroundTools, useClientContext } from '@flowgram.ai/free-layout-editor';
export function Tools() {
const { history } = useClientContext();
const tools = usePlaygroundTools();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
useEffect(() => {
const disposable = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
});
return () => disposable.dispose();
}, [history]);
return <div style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 226, display: 'flex', gap: 8 }}>
<button onClick={() => tools.zoomin()}>ZoomIn</button>
<button onClick={() => tools.zoomout()}>ZoomOut</button>
<button onClick={() => tools.fitView()}>Fitview</button>
<button onClick={() => tools.autoLayout()}>AutoLayout</button>
<button onClick={() => history.undo()} disabled={!canUndo}>Undo</button>
<button onClick={() => history.redo()} disabled={!canRedo}>Redo</button>
<span>{Math.floor(tools.zoom * 100)}%</span>
</div>
}
```
### 7. 效果
import { FreeLayoutSimple } from '../../../../components';
<div style={{ position: 'relative', width: '100%', height: '600px'}}>
<FreeLayoutSimple />
</div>

View File

@ -0,0 +1,346 @@
# 创建自由布局画布
本案例可通过 `npx @flowgram.ai/create-app@latest free-layout-simple` 安装,完整代码及效果见:
<div className="rs-tip">
<a className="rs-link" href="/examples/free-layout/free-layout-simple.html">
自由布局基础用法
</a>
</div>
文件结构:
```
- hooks
- use-editor-props.ts # 画布配置
- components
- base-node.tsx # 节点渲染
- tools.tsx # 画布工具栏
- initial-data.ts # 初始化数据
- node-registries.ts # 节点配置
- app.tsx # 画布入口
```
### 1. 画布入口
* `FreeLayoutEditorProvider`: 画布配置器, 内部会生成 react-context 供子组件消费
* `EditorRenderer`: 为最终渲染的画布,可以包装在其他组件下边方便定制画布位置
```tsx pure title="app.tsx"
import {
FreeLayoutEditorProvider,
EditorRenderer,
} from '@flowgram.ai/free-layout-editor';
import '@flowgram.ai/free-layout-editor/index.css'; // 加载样式
import { useEditorProps } from './use-editor-props' // 画布详细的 props 配置
import { Tools } from './components/tools' // 画布工具
function App() {
const editorProps = useEditorProps()
return (
<FreeLayoutEditorProvider {...editorProps}>
<EditorRenderer className="demo-editor" />
<Tools />
</FreeLayoutEditorProvider>
);
}
```
### 2. 配置画布
画布配置采用声明式,提供 数据、渲染、事件、插件相关配置
```tsx pure title="hooks/use-editor-props.tsx"
import { useMemo } from 'react';
import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { initialData } from './initial-data' // 初始化数据
import { nodeRegistries } from './node-registries' // 节点声明配置
import { BaseNode } from './components/base-node' // 节点渲染
export function useEditorProps(
): FreeLayoutProps {
return useMemo<FreeLayoutProps>(
() => ({
/**
* 初始化数据
*/
initialData,
/**
* 画布节点定义
*/
nodeRegistries,
/**
* 物料
*/
materials: {
renderDefaultNode: BaseNode, // 节点渲染组件
},
/**
* 节点引擎, 用于渲染节点表单
*/
nodeEngine: {
enable: true,
},
/**
* 画布历史记录, 用于控制 redo/undo
*/
history: {
enable: true,
enableChangeNode: true, // 用于监听节点表单数据变化
},
/**
* 画布初始化回调
*/
onInit: ctx => {
// 如果要动态加载数据,可以通过如下方法异步执行
// ctx.docuemnt.fromJSON(initialData)
},
/**
* 画布第一次渲染完整回调
*/
onAllLayersRendered: (ctx) => {},
/**
* 画布销毁回调
*/
onDispose: () => { },
plugins: () => [
/**
* 缩略图插件
*/
createMinimapPlugin({}),
],
}),
[],
);
}
```
### 3. 配置数据
画布文档数据采用树形结构,支持嵌套
:::note 文档数据基本结构:
* nodes `array` 节点列表, 支持嵌套
* edges `array` 边列表
:::
:::note 节点数据基本结构:
* id: `string` 节点唯一标识, 必须保证唯一
* meta: `object` 节点的 ui 配置信息,如自由布局的 `position` 信息放这里
* type: `string | number` 节点类型,会和 `nodeRegistries` 中的 `type` 对应
* data: `object` 节点表单数据, 业务可自定义
* blocks: `array` 节点的分支, 采用 `block` 更贴近 `Gramming`, 目前会存子画布的节点
* edges: `array` 子画布的边数据
:::
:::note 边数据基本结构:
* sourceNodeID: `string` 开始节点 id
* targetNodeID: `string` 目标节点 id
* sourcePortID?: `string | number` 开始端口 id, 缺省则采用开始节点的默认端口
* targetPortID?: `string | number` 目标端口 id, 缺省则采用目标节点的默认端口
:::
```tsx pure title="initial-data.ts"
import { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
export const initialData: WorkflowJSON = {
nodes: [
{
id: 'start_0',
type: 'start',
meta: {
position: { x: 0, y: 0 },
},
data: {
title: 'Start',
content: 'Start content'
},
},
{
id: 'node_0',
type: 'custom',
meta: {
position: { x: 400, y: 0 },
},
data: {
title: 'Custom',
content: 'Custom node content'
},
},
{
id: 'end_0',
type: 'end',
meta: {
position: { x: 800, y: 0 },
},
data: {
title: 'End',
content: 'End content'
},
},
],
edges: [
{
sourceNodeID: 'start_0',
targetNodeID: 'node_0',
},
{
sourceNodeID: 'node_0',
targetNodeID: 'end_0',
},
],
};
```
### 4. 声明节点
声明节点可以用于确定节点的类型及渲染方式
```tsx pure title="node-registries.tsx"
import { WorkflowNodeRegistry, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
/**
* You can customize your own node registry
* 你可以自定义节点的注册器
*/
export const nodeRegistries: WorkflowNodeRegistry[] = [
{
type: 'start',
meta: {
isStart: true, // 标记为开始节点
deleteDisable: true, // 开始节点不能删除
copyDisable: true, // 开始节点不能复制
defaultPorts: [{ type: 'output' }], // 用于定义节点的输入和输出端口, 开始节点只有输出端口
// useDynamicPort: true, // 用于动态端口,会寻找 data-port-id 和 data-port-type 属性的 dom 作为端口
},
/**
* 配置节点表单的校验及渲染,
* 注validate 采用数据和渲染分离,保证节点即使不渲染也能对数据做校验
*/
formMeta: {
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }) => (value ? undefined : 'Title is required'),
},
/**
* Render form
*/
render: () => (
<>
<Field name="title">
{({ field }) => <div className="demo-free-node-title">{field.value}</div>}
</Field>
<Field name="content">
{({ field }) => <input onChange={field.onChange} value={field.value}/>}
</Field>
</>
)
},
},
{
type: 'end',
meta: {
deleteDisable: true,
copyDisable: true,
defaultPorts: [{ type: 'input' }],
},
formMeta: {
// ...
}
},
{
type: 'custom',
meta: {
},
formMeta: {
// ...
},
defaultPorts: [{ type: 'output' }, { type: 'input' }], // 普通节点有两个端口
},
];
```
### 5. 渲染节点
渲染节点用于添加样式、事件及表单渲染的位置
```tsx pure title="components/base-node.tsx"
import { useNodeRender, WorkflowNodeRenderer } from '@flowgram.ai/free-layout-editor';
export const BaseNode = () => {
/**
* 提供节点渲染相关的方法
*/
const { form } = useNodeRender()
/**
* WorkflowNodeRenderer 会添加节点拖拽事件及 端口渲染,如果要深度定制,可以看该组件源代码:
* https://github.com/bytedance/flowgram.ai/blob/main/packages/client/free-layout-editor/src/components/workflow-node-renderer.tsx
*/
return (
<WorkflowNodeRenderer className="demo-free-node" node={props.node}>
{
// 表单渲染通过 formMeta 生成
form?.render()
}
</WorkflowNodeRenderer>
)
};
```
### 6. 添加工具
工具主要用于控制画布缩放等操作, 工具汇总在 `usePlaygroundTools` 中, 而 `useClientContext` 用于获取画布的上下文, 里边包含画布的核心模块如 `history`
```tsx pure title="components/tools.tsx"
import { useEffect, useState } from 'react'
import { usePlaygroundTools, useClientContext } from '@flowgram.ai/free-layout-editor';
export function Tools() {
const { history } = useClientContext();
const tools = usePlaygroundTools();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
useEffect(() => {
const disposable = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
});
return () => disposable.dispose();
}, [history]);
return <div style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 226, display: 'flex', gap: 8 }}>
<button onClick={() => tools.zoomin()}>ZoomIn</button>
<button onClick={() => tools.zoomout()}>ZoomOut</button>
<button onClick={() => tools.fitView()}>Fitview</button>
<button onClick={() => tools.autoLayout()}>AutoLayout</button>
<button onClick={() => history.undo()} disabled={!canUndo}>Undo</button>
<button onClick={() => history.redo()} disabled={!canRedo}>Redo</button>
<span>{Math.floor(tools.zoom * 100)}%</span>
</div>
}
```
### 7. 效果
import { FreeLayoutSimple } from '../../../../components';
<div style={{ position: 'relative', width: '100%', height: '600px'}}>
<FreeLayoutSimple />
</div>

View File

@ -0,0 +1,635 @@
{
"nodes": [
{
"id": "start_0",
"type": "start",
"meta": {
"position": {
"x": 180,
"y": 573.7
}
},
"data": {
"title": "Start",
"outputs": {
"type": "object",
"properties": {
"query": {
"type": "string",
"default": "Hello Flow."
},
"enable": {
"type": "boolean",
"default": true
},
"array_obj": {
"type": "array",
"items": {
"type": "object",
"properties": {
"int": {
"type": "number"
},
"str": {
"type": "string"
}
}
}
}
}
}
}
},
{
"id": "condition_0",
"type": "condition",
"meta": {
"position": {
"x": 1100,
"y": 510.20000000000005
}
},
"data": {
"title": "Condition",
"conditions": [
{
"key": "if_0",
"value": {
"left": {
"type": "ref",
"content": [
"start_0",
"query"
]
},
"operator": "contains",
"right": {
"type": "constant",
"content": "Hello Flow."
}
}
},
{
"key": "if_f0rOAt",
"value": {
"left": {
"type": "ref",
"content": [
"start_0",
"enable"
]
},
"operator": "is_true"
}
}
]
}
},
{
"id": "end_0",
"type": "end",
"meta": {
"position": {
"x": 3008,
"y": 573.7
}
},
"data": {
"title": "End",
"inputsValues": {
"success": {
"type": "constant",
"content": true,
"schema": {
"type": "boolean"
}
},
"query": {
"type": "ref",
"content": [
"start_0",
"query"
]
}
},
"inputs": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"query": {
"type": "string"
}
}
}
}
},
{
"id": "159623",
"type": "comment",
"meta": {
"position": {
"x": 180,
"y": 756.7
}
},
"data": {
"size": {
"width": 240,
"height": 150
},
"note": "hi ~\n\nthis is a comment node\n\n- flowgram.ai"
}
},
{
"id": "http_rDGIH",
"type": "http",
"meta": {
"position": {
"x": 640,
"y": 447.35
}
},
"data": {
"title": "HTTP_1",
"outputs": {
"type": "object",
"properties": {
"body": {
"type": "string"
},
"headers": {
"type": "object"
},
"statusCode": {
"type": "integer"
}
}
},
"api": {
"method": "GET",
"url": {
"type": "template",
"content": ""
}
},
"body": {
"bodyType": "JSON"
},
"timeout": {
"timeout": 10000,
"retryTimes": 1
}
}
},
{
"id": "loop_Ycnsk",
"type": "loop",
"meta": {
"position": {
"x": 1480,
"y": 90.00000000000003
}
},
"data": {
"title": "Loop_1",
"loopFor": {
"type": "ref",
"content": [
"start_0",
"array_obj"
]
},
"loopOutputs": {
"acm": {
"type": "ref",
"content": [
"llm_6aSyo",
"result"
]
}
}
},
"blocks": [
{
"id": "llm_6aSyo",
"type": "llm",
"meta": {
"position": {
"x": 344,
"y": 0
}
},
"data": {
"title": "LLM_3",
"inputsValues": {
"modelName": {
"type": "constant",
"content": "gpt-3.5-turbo"
},
"apiKey": {
"type": "constant",
"content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"apiHost": {
"type": "constant",
"content": "https://mock-ai-url/api/v3"
},
"temperature": {
"type": "constant",
"content": 0.5
},
"systemPrompt": {
"type": "template",
"content": "# Role\nYou are an AI assistant.\n"
},
"prompt": {
"type": "template",
"content": ""
}
},
"inputs": {
"type": "object",
"required": [
"modelName",
"apiKey",
"apiHost",
"temperature",
"prompt"
],
"properties": {
"modelName": {
"type": "string"
},
"apiKey": {
"type": "string"
},
"apiHost": {
"type": "string"
},
"temperature": {
"type": "number"
},
"systemPrompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
},
"prompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
}
}
},
"outputs": {
"type": "object",
"properties": {
"result": {
"type": "string"
}
}
}
}
},
{
"id": "llm_ZqKlP",
"type": "llm",
"meta": {
"position": {
"x": 804,
"y": 0
}
},
"data": {
"title": "LLM_4",
"inputsValues": {
"modelName": {
"type": "constant",
"content": "gpt-3.5-turbo"
},
"apiKey": {
"type": "constant",
"content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"apiHost": {
"type": "constant",
"content": "https://mock-ai-url/api/v3"
},
"temperature": {
"type": "constant",
"content": 0.5
},
"systemPrompt": {
"type": "template",
"content": "# Role\nYou are an AI assistant.\n"
},
"prompt": {
"type": "template",
"content": ""
}
},
"inputs": {
"type": "object",
"required": [
"modelName",
"apiKey",
"apiHost",
"temperature",
"prompt"
],
"properties": {
"modelName": {
"type": "string"
},
"apiKey": {
"type": "string"
},
"apiHost": {
"type": "string"
},
"temperature": {
"type": "number"
},
"systemPrompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
},
"prompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
}
}
},
"outputs": {
"type": "object",
"properties": {
"result": {
"type": "string"
}
}
}
}
},
{
"id": "block_start_PUDtS",
"type": "block-start",
"meta": {
"position": {
"x": 32,
"y": 163.1
}
},
"data": {}
},
{
"id": "block_end_leBbs",
"type": "block-end",
"meta": {
"position": {
"x": 1116,
"y": 163.1
}
},
"data": {}
}
],
"edges": [
{
"sourceNodeID": "block_start_PUDtS",
"targetNodeID": "llm_6aSyo"
},
{
"sourceNodeID": "llm_6aSyo",
"targetNodeID": "llm_ZqKlP"
},
{
"sourceNodeID": "llm_ZqKlP",
"targetNodeID": "block_end_leBbs"
}
]
},
{
"id": "group_nYl6D",
"type": "group",
"meta": {
"position": {
"x": 1644,
"y": 730.2
}
},
"data": {
"parentID": "root",
"blockIDs": [
"llm_8--A3",
"llm_vTyMa"
]
}
},
{
"id": "llm_8--A3",
"type": "llm",
"meta": {
"position": {
"x": 180,
"y": 0
}
},
"data": {
"title": "LLM_1",
"inputsValues": {
"modelName": {
"type": "constant",
"content": "gpt-3.5-turbo"
},
"apiKey": {
"type": "constant",
"content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"apiHost": {
"type": "constant",
"content": "https://mock-ai-url/api/v3"
},
"temperature": {
"type": "constant",
"content": 0.5
},
"systemPrompt": {
"type": "template",
"content": "# Role\nYou are an AI assistant.\n"
},
"prompt": {
"type": "template",
"content": "# User Input\nquery:{{start_0.query}}\nenable:{{start_0.enable}}"
}
},
"inputs": {
"type": "object",
"required": [
"modelName",
"apiKey",
"apiHost",
"temperature",
"prompt"
],
"properties": {
"modelName": {
"type": "string"
},
"apiKey": {
"type": "string"
},
"apiHost": {
"type": "string"
},
"temperature": {
"type": "number"
},
"systemPrompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
},
"prompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
}
}
},
"outputs": {
"type": "object",
"properties": {
"result": {
"type": "string"
}
}
}
}
},
{
"id": "llm_vTyMa",
"type": "llm",
"meta": {
"position": {
"x": 640,
"y": 10
}
},
"data": {
"title": "LLM_2",
"inputsValues": {
"modelName": {
"type": "constant",
"content": "gpt-3.5-turbo"
},
"apiKey": {
"type": "constant",
"content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"apiHost": {
"type": "constant",
"content": "https://mock-ai-url/api/v3"
},
"temperature": {
"type": "constant",
"content": 0.5
},
"systemPrompt": {
"type": "template",
"content": "# Role\nYou are an AI assistant.\n"
},
"prompt": {
"type": "template",
"content": "# LLM Input\nresult:{{llm_8--A3.result}}"
}
},
"inputs": {
"type": "object",
"required": [
"modelName",
"apiKey",
"apiHost",
"temperature",
"prompt"
],
"properties": {
"modelName": {
"type": "string"
},
"apiKey": {
"type": "string"
},
"apiHost": {
"type": "string"
},
"temperature": {
"type": "number"
},
"systemPrompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
},
"prompt": {
"type": "string",
"extra": {
"formComponent": "prompt-editor"
}
}
}
},
"outputs": {
"type": "object",
"properties": {
"result": {
"type": "string"
}
}
}
}
}
],
"edges": [
{
"sourceNodeID": "start_0",
"targetNodeID": "http_rDGIH"
},
{
"sourceNodeID": "http_rDGIH",
"targetNodeID": "condition_0"
},
{
"sourceNodeID": "condition_0",
"targetNodeID": "llm_8--A3",
"sourcePortID": "if_f0rOAt"
},
{
"sourceNodeID": "condition_0",
"targetNodeID": "loop_Ycnsk",
"sourcePortID": "if_0"
},
{
"sourceNodeID": "llm_vTyMa",
"targetNodeID": "end_0"
},
{
"sourceNodeID": "loop_Ycnsk",
"targetNodeID": "end_0"
},
{
"sourceNodeID": "llm_8--A3",
"targetNodeID": "llm_vTyMa"
}
]
}

View File

@ -0,0 +1,412 @@
# 最佳实践
import { FreeFeatureOverview } from '../../../../components';
<FreeFeatureOverview />
## 安装
```shell
npx @flowgram.ai/create-app@latest free-layout
```
## 源码
https://github.com/bytedance/flowgram.ai/tree/main/apps/demo-free-layout
## 项目概览
### 核心技术栈
* **前端框架**: React 18 + TypeScript
* **构建工具**: Rsbuild (基于 Rspack 的现代构建工具)
* **样式方案**: Less + Styled Components + CSS Variables
* **UI 组件库**: Semi Design (@douyinfe/semi-ui)
* **状态管理**: 基于 Flowgram 自研的编辑器框架
* **依赖注入**: Inversify
### 核心依赖包
* **@flowgram.ai/free-layout-editor**: 自由布局编辑器核心依赖
* **@flowgram.ai/free-snap-plugin**: 自动对齐及辅助线插件
* **@flowgram.ai/free-lines-plugin**: 连线渲染插件
* **@flowgram.ai/free-node-panel-plugin**: 节点添加面板渲染插件
* **@flowgram.ai/minimap-plugin**: 缩略图插件
* **@flowgram.ai/free-container-plugin**: 子画布插件
* **@flowgram.ai/free-group-plugin**: 分组插件
* **@flowgram.ai/form-materials**: 表单物料
* **@flowgram.ai/runtime-interface**: 运行时接口
* **@flowgram.ai/runtime-js**: js 运行时模块
## 代码说明
### 目录结构
```
src/
├── app.tsx # 应用入口文件
├── editor.tsx # 编辑器主组件
├── initial-data.ts # 初始化数据配置
├── assets/ # 静态资源
├── components/ # 组件库
│ ├── index.ts
│ ├── add-node/ # 添加节点组件
│ ├── base-node/ # 基础节点组件
│ ├── comment/ # 注释组件
│ ├── group/ # 分组组件
│ ├── line-add-button/ # 连线添加按钮
│ ├── node-menu/ # 节点菜单
│ ├── node-panel/ # 节点添加面板
│ ├── selector-box-popover/ # 选择框弹窗
│ ├── sidebar/ # 侧边栏
│ ├── testrun/ # 测试运行组件
│ │ ├── hooks/ # 测试运行钩子
│ │ ├── node-status-bar/ # 节点状态栏
│ │ ├── testrun-button/ # 测试运行按钮
│ │ ├── testrun-form/ # 测试运行表单
│ │ ├── testrun-json-input/ # JSON输入组件
│ │ └── testrun-panel/ # 测试运行面板
│ └── tools/ # 工具组件
├── context/ # React Context
│ ├── node-render-context.ts # 当前渲染节点 Context
│ ├── sidebar-context # 侧边栏 Context
├── form-components/ # 表单组件库
│ ├── form-content/ # 表单内容
│ ├── form-header/ # 表单头部
│ ├── form-inputs/ # 表单输入
│ └── form-item/ # 表单项
│ └── feedback.tsx # 表单校验错误渲染
├── hooks/
│ ├── index.ts
│ ├── use-editor-props.tsx # 编辑器属性钩子
│ ├── use-is-sidebar.ts # 侧边栏状态钩子
│ ├── use-node-render-context.ts # 节点渲染上下文钩子
│ └── use-port-click.ts # 端口点击钩子
├── nodes/ # 节点定义
│ ├── index.ts
│ ├── constants.ts # 节点常量定义
│ ├── default-form-meta.ts # 默认表单元数据
│ ├── block-end/ # 块结束节点
│ ├── block-start/ # 块开始节点
│ ├── break/ # 中断节点
│ ├── code/ # 代码节点
│ ├── comment/ # 注释节点
│ ├── condition/ # 条件节点
│ ├── continue/ # 继续节点
│ ├── end/ # 结束节点
│ ├── group/ # 分组节点
│ ├── http/ # HTTP节点
│ ├── llm/ # LLM节点
│ ├── loop/ # 循环节点
│ ├── start/ # 开始节点
│ └── variable/ # 变量节点
├── plugins/ # 插件系统
│ ├── index.ts
│ ├── context-menu-plugin/ # 右键菜单插件
│ ├── runtime-plugin/ # 运行时插件
│ │ ├── client/ # 客户端
│ │ │ ├── browser-client/ # 浏览器客户端
│ │ │ └── server-client/ # 服务器客户端
│ │ └── runtime-service/ # 运行时服务
│ └── variable-panel-plugin/ # 变量面板插件
│ └── components/ # 变量面板组件
├── services/ # 服务层
│ ├── index.ts
│ └── custom-service.ts # 自定义服务
├── shortcuts/ # 快捷键系统
│ ├── index.ts
│ ├── constants.ts # 快捷键常量
│ ├── shortcuts.ts # 快捷键定义
│ ├── type.ts # 类型定义
│ ├── collapse/ # 折叠快捷键
│ ├── copy/ # 复制快捷键
│ ├── delete/ # 删除快捷键
│ ├── expand/ # 展开快捷键
│ ├── paste/ # 粘贴快捷键
│ ├── select-all/ # 全选快捷键
│ ├── zoom-in/ # 放大快捷键
│ └── zoom-out/ # 缩小快捷键
├── styles/ # 样式文件
├── typings/ # 类型定义
│ ├── index.ts
│ ├── json-schema.ts # JSON Schema类型
│ └── node.ts # 节点类型定义
└── utils/ # 工具函数
├── index.ts
└── on-drag-line-end.ts # 拖拽连线结束处理
```
### 关键目录功能说明
#### 1. `/components` - 组件库
* **base-node**: 所有节点的基础渲染组件
* **testrun**: 完整的测试运行功能模块,包含状态栏、表单、面板等
* **sidebar**: 侧边栏组件,提供工具和属性面板
* **node-panel**: 节点添加面板,支持拖拽添加新节点
#### 2. `/nodes` - 节点系统
每个节点类型都有独立的目录,包含:
* 节点注册信息 (`index.ts`)
* 表单元数据定义 (`form-meta.ts`)
* 节点特定的组件和逻辑
#### 3. `/plugins` - 插件系统
* **runtime-plugin**: 支持浏览器和服务器两种运行模式
* **context-menu-plugin**: 右键菜单功能
* **variable-panel-plugin**: 变量管理面板
#### 4. `/shortcuts` - 快捷键系统
完整的快捷键支持,包括:
* 基础操作:复制、粘贴、删除、全选
* 视图操作:放大、缩小、折叠、展开
* 每个快捷键都有独立的实现模块
## 应用架构设计
### 核心设计模式
#### 1. 插件化架构 (Plugin Architecture)
应用采用高度模块化的插件系统,每个功能都作为独立插件存在:
```typescript
plugins: () => [
createFreeLinesPlugin({ renderInsideLine: LineAddButton }),
createMinimapPlugin({ /* 配置 */ }),
createFreeSnapPlugin({ /* 对齐配置 */ }),
createFreeNodePanelPlugin({ renderer: NodePanel }),
createContainerNodePlugin({}),
createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),
createContextMenuPlugin({}),
createRuntimePlugin({ mode: 'browser' }),
createVariablePanelPlugin({})
]
```
#### 2. 节点注册系统 (Node Registry Pattern)
通过注册表模式管理不同类型的工作流节点:
```typescript
export const nodeRegistries: FlowNodeRegistry[] = [
ConditionNodeRegistry, // 条件节点
StartNodeRegistry, // 开始节点
EndNodeRegistry, // 结束节点
LLMNodeRegistry, // LLM节点
LoopNodeRegistry, // 循环节点
CommentNodeRegistry, // 注释节点
HTTPNodeRegistry, // HTTP节点
CodeNodeRegistry, // 代码节点
// ... 更多节点类型
];
```
#### 3. 依赖注入模式 (Dependency Injection)
使用 Inversify 框架实现服务的依赖注入:
```typescript
onBind: ({ bind }) => {
bind(CustomService).toSelf().inSingletonScope();
}
```
## 核心功能分析
### 1. 编辑器配置系统
`useEditorProps` 是整个编辑器的配置中心,包含:
```typescript
export function useEditorProps(
initialData: FlowDocumentJSON,
nodeRegistries: FlowNodeRegistry[]
): FreeLayoutProps {
return useMemo<FreeLayoutProps>(() => ({
background: true, // 背景网格
readonly: false, // 是否只读
initialData, // 初始数据
nodeRegistries, // 节点注册表
// 核心功能配置
playground: { preventGlobalGesture: true /* 阻止 mac 浏览器手势翻页 */ },
nodeEngine: { enable: true },
variableEngine: { enable: true },
history: { enable: true, enableChangeNode: true },
// 业务逻辑配置
canAddLine: (ctx, fromPort, toPort) => { /* 连线规则 */ },
canDeleteLine: (ctx, line) => { /* 删除连线规则 */ },
canDeleteNode: (ctx, node) => { /* 删除节点规则 */ },
canDropToNode: (ctx, params) => { /* 拖拽规则 */ },
// 插件配置
plugins: () => [/* 插件列表 */],
// 事件处理
onContentChange: debounce((ctx, event) => { /* 自动保存 */ }, 1000),
onInit: (ctx) => { /* 初始化 */ },
onAllLayersRendered: (ctx) => { /* 渲染完成 */ }
}), []);
}
```
### 2. 节点类型系统
应用支持多种工作流节点类型:
```typescript
export enum WorkflowNodeType {
Start = 'start', // 开始节点
End = 'end', // 结束节点
LLM = 'llm', // 大语言模型节点
HTTP = 'http', // HTTP请求节点
Code = 'code', // 代码执行节点
Variable = 'variable', // 变量节点
Condition = 'condition', // 条件判断节点
Loop = 'loop', // 循环节点
BlockStart = 'block-start', // 子画布开始节点
BlockEnd = 'block-end', // 子画布结束节点
Comment = 'comment', // 注释节点
Continue = 'continue', // 继续节点
Break = 'break', // 中断节点
}
```
每个节点都遵循统一的注册模式:
```typescript
export const StartNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Start,
meta: {
isStart: true,
deleteDisable: true, // 不可删除
copyDisable: true, // 不可复制
nodePanelVisible: false, // 不在节点面板显示
defaultPorts: [{ type: 'output' }],
size: { width: 360, height: 211 }
},
info: {
icon: iconStart,
description: '工作流的起始节点,用于设置启动工作流所需的信息。'
},
formMeta, // 表单配置
canAdd() { return false; } // 不允许添加多个开始节点
};
```
### 3. 插件化架构
应用的功能通过插件系统实现模块化:
#### 核心插件列表
1. **FreeLinesPlugin** - 连线渲染和交互
2. **MinimapPlugin** - 缩略图导航
3. **FreeSnapPlugin** - 自动对齐和辅助线
4. **FreeNodePanelPlugin** - 节点添加面板
5. **ContainerNodePlugin** - 容器节点(如循环节点)
6. **FreeGroupPlugin** - 节点分组功能
7. **ContextMenuPlugin** - 右键菜单
8. **RuntimePlugin** - 工作流运行时
9. **VariablePanelPlugin** - 变量管理面板
### 4. 运行时系统
应用支持两种运行模式:
```typescript
createRuntimePlugin({
mode: 'browser', // 浏览器模式
// mode: 'server', // 服务器模式
// serverConfig: {
// domain: 'localhost',
// port: 4000,
// protocol: 'http',
// },
})
```
## 设计理念与架构优势
### 1. 高度模块化
* **插件化架构**: 每个功能都是独立插件,易于扩展和维护
* **节点注册系统**: 新节点类型可以轻松添加,无需修改核心代码
* **组件化设计**: UI组件高度复用职责清晰
### 2. 类型安全
* **完整的TypeScript支持**: 从配置到运行时的全链路类型保护
* **JSON Schema集成**: 节点数据结构通过Schema验证
* **强类型的插件接口**: 插件开发有明确的类型约束
### 3. 用户体验优化
* **实时预览**: 支持工作流的实时运行和调试
* **丰富的交互**: 拖拽、缩放、对齐、快捷键等完整的编辑体验
* **可视化反馈**: 缩略图、状态指示、连线动画等视觉反馈
### 4. 扩展性设计
* **开放的插件系统**: 第三方可以轻松开发自定义插件
* **灵活的节点系统**: 支持自定义节点类型和表单配置
* **多运行时支持**: 浏览器和服务器双模式运行
### 5. 性能优化
* **按需加载**: 组件和插件支持按需加载
* **防抖处理**: 自动保存等高频操作的性能优化
## 技术亮点
### 1. 自研编辑器框架
基于 `@flowgram.ai/free-layout-editor` 自研框架,提供:
* 自由布局的画布系统
* 完整的撤销/重做功能
* 节点和连线的生命周期管理
* 变量引擎和表达式系统
### 2. 先进的构建配置
使用 Rsbuild 作为构建工具:
```typescript
export default defineConfig({
plugins: [pluginReact(), pluginLess()],
source: {
entry: { index: './src/app.tsx' },
decorators: { version: 'legacy' } // 支持装饰器
},
tools: {
rspack: {
ignoreWarnings: [/Critical dependency/] // 忽略特定警告
}
}
});
```
### 3. 国际化支持
内置多语言支持:
```typescript
i18n: {
locale: navigator.language,
languages: {
'zh-CN': {
'Never Remind': '不再提示',
'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',
},
'en-US': {},
}
}
```

View File

@ -10,18 +10,39 @@
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"@douyinfe/semi-icons": "^2.80.0",
"@douyinfe/semi-ui": "^2.80.0",
"@flowgram.ai/free-container-plugin": "^0.4.7",
"@flowgram.ai/free-group-plugin": "^0.4.7",
"@flowgram.ai/free-layout-editor": "^0.4.7",
"@flowgram.ai/free-lines-plugin": "^0.4.7",
"@flowgram.ai/free-node-panel-plugin": "^0.4.7",
"@flowgram.ai/free-snap-plugin": "^0.4.7",
"@flowgram.ai/form-materials": "^0.4.7",
"@flowgram.ai/minimap-plugin": "^0.4.7",
"@flowgram.ai/runtime-interface": "^0.4.7",
"@flowgram.ai/runtime-js": "^0.4.7",
"antd": "^5.17.0",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"highlight.js": "^11.11.1",
"js-yaml": "^4.1.0",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-json-view-lite": "^2.4.2",
"react-router-dom": "^6.26.2"
"react-router-dom": "^6.26.2",
"styled-components": "^5.3.11"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@types/styled-components": "^5.1.34",
"@vitejs/plugin-react": "^4.2.0",
"less": "^4.2.0",
"typescript": "^5.4.0",
"vite": "^5.2.0"
}

View File

@ -12,6 +12,10 @@ import Logs from './pages/Logs'
// 移除不存在的 Layout/RequireAuth 组件导入
// 新增
import Positions from './pages/Positions'
import FlowList from './pages/FlowList'
// 引入流程编辑器
import { Flows } from './flows'
import FlowRunLogs from './pages/FlowRunLogs'
function RequireAuth({ children }: { children: any }) {
const token = getToken()
@ -33,6 +37,12 @@ export default function App() {
<Route path="/logs" element={<Logs />} />
{/* 新增 */}
<Route path="/positions" element={<Positions />} />
{/* 将 /flows 映射为流程列表 */}
<Route path="/flows" element={<FlowList />} />
{/* 编辑器:新增与编辑都跳转到此路由,使用查询参数 id 作为标识 */}
<Route path="/flows/editor" element={<Flows />} />
{/* 流程运行日志 */}
<Route path="/flow-run-logs" element={<FlowRunLogs />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { createRoot } from 'react-dom/client';
import { Editor } from './editor';
const app = createRoot(document.getElementById('root')!);
app.render(<Editor />);

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconAutoLayout = (
<svg width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M3 2C2.44772 2 2 2.44771 2 3V12C2 12.5523 2.44772 13 3 13H10C10.5523 13 11 12.5523 11 12V3C11 2.44772 10.5523 2 10 2H3zM4 11V4H9V11H4zM21 22C21.5523 22 22 21.5523 22 21V12C22 11.4477 21.5523 11 21 11H14C13.4477 11 13 11.4477 13 12V21C13 21.5523 13.4477 22 14 22H21zM20 13V20H15V13H20zM2 16C2 15.4477 2.44772 15 3 15H10C10.5523 15 11 15.4477 11 16V21C11 21.5523 10.5523 22 10 22H3C2.44772 22 2 21.5523 2 21V16zM4 20V17H9V20H4zM21 9C21.5523 9 22 8.55228 22 8V3C22 2.44772 21.5523 2 21 2H14C13.4477 2 13 2.44772 13 3V8C13 8.55228 13.4477 9 14 9H21zM20 4V7H15V4H20z"
></path>
</svg>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,24 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconCancel = ({ className, style }: Props) => (
<svg
className={className}
style={style}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.5 8C8.67157 8 8 8.67157 8 9.5V14.5C8 15.3284 8.67157 16 9.5 16H14.5C15.3284 16 16 15.3284 16 14.5V9.5C16 8.67157 15.3284 8 14.5 8H9.5Z"></path>
<path d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"></path>
</svg>
);

View File

@ -0,0 +1,24 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC } from 'react';
interface IconCommentProps {
style?: CSSProperties;
}
export const IconComment: FC<IconCommentProps> = ({ style }) => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style={style}
>
<path d="M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z"></path>
<path d="M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z"></path>
</svg>
);

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44" height="45" viewBox="0 0 44 45" fill="none" class="injected-svg" data-src="https://lf3-static.bytednsdoc.com/obj/eden-cn/uvpahtvabh_lm_zhhwh/ljhwZthlaukjlkulzlp/activity_icons/exclusive-split-0518.svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4705 14.0152C15.299 12.8436 15.299 10.944 16.4705 9.77244L20.7131 5.5297C21.8846 4.3581 23.784 4.3581 24.9556 5.5297L29.1981 9.77244C30.3697 10.944 30.3697 12.8436 29.1981 14.0152L25.1206 18.0929H32.6674C36.5334 18.0929 39.6674 21.2269 39.6674 25.0929V33.154V33.3271V37.154C39.6674 38.2585 38.7719 39.154 37.6674 39.154H33.6674C32.5628 39.154 31.6674 38.2585 31.6674 37.154V33.3271V33.154V26.0929H23.5948H15.6674V33.1327L17.2685 33.1244C18.8397 33.1163 19.6322 35.0156 18.5212 36.1266L12.7374 41.9103C12.0506 42.5971 10.9371 42.5971 10.2503 41.9103L4.52588 36.1859C3.42107 35.0811 4.19797 33.1917 5.76038 33.1837L7.66737 33.1739V25.0929C7.66737 21.227 10.8014 18.0929 14.6674 18.0929H20.5481L16.4705 14.0152Z" fill="url(#paint0_linear_2752_183702-7)"/>
<defs>
<linearGradient id="paint0_linear_2752_183702-7" x1="38.52" y1="43.3915" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20.006 4C22.145 4 23.9853 7.39855 24.7651 12.241H15.2469C16.0267 7.39855 17.867 4 20.006 4ZM15.021 20.9119C14.908 19.8023 14.8424 18.6486 14.8424 17.4436C14.8424 16.2421 14.908 15.0848 15.021 13.9752H24.9837C25.0966 15.0848 25.1623 16.2421 25.1623 17.4436C25.1623 18.645 25.0966 19.7987 24.9837 20.9119H15.021ZM23.8044 4.56199C27.6525 5.71199 30.7644 8.55942 32.3022 12.2409H26.4936C26.0199 9.15463 25.1162 6.39537 23.8044 4.56199ZM16.1971 4.56199C12.3563 5.71199 9.23701 8.55942 7.70652 12.2409H13.5151C13.9815 9.15463 14.8852 6.39537 16.1971 4.56199ZM26.7119 13.9752H32.8776C33.1691 15.0848 33.3368 16.2421 33.3368 17.4436C33.3368 18.645 33.1691 19.7987 32.874 20.9119H26.7119C26.8249 19.7766 26.8906 18.6083 26.8906 17.4436C26.8906 16.2789 26.8249 15.1142 26.7119 13.9752ZM13.122 17.4436C13.122 16.2789 13.1876 15.1105 13.3006 13.9752H7.13127C6.83975 15.0885 6.66848 16.2421 6.66848 17.4436C6.66848 18.645 6.83975 19.8023 7.13127 20.912H13.2933C13.1876 19.7767 13.122 18.6119 13.122 17.4436ZM4 25.3373C4 23.8005 5.24582 22.5547 6.78261 22.5547H33.2174C34.7542 22.5547 36 23.8005 36 25.3373V33.2174C36 34.7542 34.7542 36 33.2174 36H6.78261C5.24582 36 4 34.7542 4 33.2174V25.3373ZM10.9109 28.1569H8.48666V25.9161H6.66848V32.6388H8.48666V29.8376H10.9109V32.6388H12.7291V25.9161H10.9109V28.1569ZM13.9412 27.5968H15.7594V32.6388H17.5776V27.5968H19.3958V25.9161H13.9412V27.5968ZM20.6079 27.5968H22.426V32.6388H24.2442V27.5968H26.0625V25.9161H20.6079V27.5968ZM31.5169 25.9161H27.2746V32.6388H29.0927V30.3979H31.5169C32.5472 30.3979 33.3351 29.6696 33.3351 28.7172V27.5968C33.3351 26.6445 32.5472 25.9161 31.5169 25.9161ZM31.5169 28.7172H29.0927V27.5968H31.5169V28.7172Z"
fill="#3370FF" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,24 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconMinimap = () => (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="g1">
<path
id="path1"
fill="#000000"
stroke="none"
d="M 18.09091 6.883101 L 5.409091 6.883101 L 5.409091 16.746737 L 10.664648 16.746737 C 10.927091 17.116341 11.30353 17.422749 11.792977 17.611004 L 12.664289 17.946156 L 12.744959 18.155828 L 5.409091 18.155828 C 4.630871 18.155828 4 17.524979 4 16.746737 L 4 6.883101 C 4 6.104881 4.630871 5.47401 5.409091 5.47401 L 18.09091 5.47401 C 18.86915 5.47401 19.5 6.104881 19.5 6.883101 L 19.5 12.52348 C 19.247208 11.883823 18.730145 11.365912 18.09091 11.111994 L 18.09091 6.883101 Z M 18.09091 18.155828 L 17.881165 18.155828 L 19.469212 14.368896 C 19.479921 14.343321 19.490206 14.317817 19.5 14.292241 L 19.5 16.746737 C 19.5 17.524979 18.86915 18.155828 18.09091 18.155828 Z"
/>
<path
id="path2"
fill="#000000"
fillRule="evenodd"
stroke="none"
d="M 18.494614 13.960189 C 18.982441 12.796985 17.813459 11.628003 16.650255 12.11576 L 12.133272 14.01 C 10.962248 14.501069 10.987188 16.168798 12.172375 16.62464 L 13.482055 17.128389 L 13.985805 18.438068 C 14.441646 19.623184 16.109375 19.648125 16.600443 18.477171 L 18.494614 13.960189 Z M 17.19515 13.415224 L 15.30098 17.932205 L 14.79723 16.622526 C 14.654066 16.250385 14.359989 15.956307 13.987918 15.813213 L 12.678168 15.309464 L 17.19515 13.415224 Z"
/>
</g>
</svg>
);

View File

@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export function IconMouse(props: { width?: number; height?: number }) {
const { width, height } = props;
return (
<svg
width={width || 34}
height={height || 52}
viewBox="0 0 34 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M30.9998 16.6666V35.3333C30.9998 37.5748 30.9948 38.4695 30.9 39.1895C30.2108 44.4247 26.0912 48.5443 20.856 49.2335C20.1361 49.3283 19.2413 49.3333 16.9998 49.3333C14.7584 49.3333 13.8636 49.3283 13.1437 49.2335C7.90847 48.5443 3.78888 44.4247 3.09965 39.1895C3.00487 38.4695 2.99984 37.5748 2.99984 35.3333V16.6666C2.99984 14.4252 3.00487 13.5304 3.09965 12.8105C3.78888 7.57528 7.90847 3.45569 13.1437 2.76646C13.7232 2.69017 14.4159 2.67202 15.8332 2.66785V9.86573C14.4738 10.3462 13.4998 11.6426 13.4998 13.1666V17.8332C13.4998 19.3571 14.4738 20.6536 15.8332 21.1341V23.6666C15.8332 24.3109 16.3555 24.8333 16.9998 24.8333C17.6442 24.8333 18.1665 24.3109 18.1665 23.6666V21.1341C19.5259 20.6536 20.4998 19.3572 20.4998 17.8332V13.1666C20.4998 11.6426 19.5259 10.3462 18.1665 9.86571V2.66785C19.5837 2.67202 20.2765 2.69017 20.856 2.76646C26.0912 3.45569 30.2108 7.57528 30.9 12.8105C30.9948 13.5304 30.9998 14.4252 30.9998 16.6666ZM0.666504 16.6666C0.666504 14.4993 0.666504 13.4157 0.786276 12.5059C1.61335 6.22368 6.55687 1.28016 12.8391 0.453085C13.7489 0.333313 14.8325 0.333313 16.9998 0.333313C19.1671 0.333313 20.2508 0.333313 21.1605 0.453085C27.4428 1.28016 32.3863 6.22368 33.2134 12.5059C33.3332 13.4157 33.3332 14.4994 33.3332 16.6666V35.3333C33.3332 37.5006 33.3332 38.5843 33.2134 39.494C32.3863 45.7763 27.4428 50.7198 21.1605 51.5469C20.2508 51.6666 19.1671 51.6666 16.9998 51.6666C14.8325 51.6666 13.7489 51.6666 12.8391 51.5469C6.55687 50.7198 1.61335 45.7763 0.786276 39.494C0.666504 38.5843 0.666504 37.5006 0.666504 35.3333V16.6666ZM15.8332 13.1666C15.8332 13.0011 15.8676 12.8437 15.9297 12.7011C15.9886 12.566 16.0722 12.4443 16.1749 12.3416C16.386 12.1305 16.6777 11.9999 16.9998 11.9999C17.6435 11.9999 18.1654 12.5212 18.1665 13.1646L18.1665 13.1666V17.8332L18.1665 17.8353C18.1665 17.8364 18.1665 17.8376 18.1665 17.8387C18.1661 17.9132 18.1588 17.986 18.1452 18.0565C18.0853 18.3656 17.9033 18.6312 17.6515 18.8011C17.4655 18.9266 17.2412 18.9999 16.9998 18.9999C16.3555 18.9999 15.8332 18.4776 15.8332 17.8332V13.1666Z"
fill="currentColor"
fillOpacity="0.8"
/>
</svg>
);
}
export const IconMouseTool = () => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.5 8C4.5 4.13401 7.63401 1 11.5 1H12.5C16.366 1 19.5 4.13401 19.5 8V17C19.5 20.3137 16.8137 23 13.5 23H10.5C7.18629 23 4.5 20.3137 4.5 17V8ZM11.2517 3.00606C8.60561 3.13547 6.5 5.32184 6.5 8V17C6.5 19.2091 8.29086 21 10.5 21H13.5C15.7091 21 17.5 19.2091 17.5 17V8C17.5 5.32297 15.3962 3.13732 12.7517 3.00622V5.28013C13.2606 5.54331 13.6074 6.06549 13.6074 6.66669V8.75759C13.6074 9.35879 13.2606 9.88097 12.7517 10.1441V11.4091C12.7517 11.8233 12.4159 12.1591 12.0017 12.1591C11.5875 12.1591 11.2517 11.8233 11.2517 11.4091V10.1457C10.7411 9.88298 10.3931 9.35994 10.3931 8.75759V6.66669C10.3931 6.06433 10.7411 5.5413 11.2517 5.27862V3.00606ZM12.0017 6.14397C11.7059 6.14397 11.466 6.38381 11.466 6.67968V8.74462C11.466 9.03907 11.7036 9.27804 11.9975 9.28031L12.0002 9.28032C12.0456 9.28032 12.0896 9.27482 12.1316 9.26447C12.3401 9.21256 12.5002 9.0386 12.5318 8.82287C12.5345 8.80149 12.5359 8.7797 12.5359 8.75759V6.66669C12.5359 6.64463 12.5345 6.62288 12.5318 6.60154C12.4999 6.38354 12.3368 6.20817 12.1252 6.15826C12.0856 6.14891 12.0442 6.14397 12.0017 6.14397Z"
></path>
</svg>
);

View File

@ -0,0 +1,56 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export function IconPad(props: { width?: number; height?: number }) {
const { width, height } = props;
return (
<svg
width={width || 48}
height={height || 38}
viewBox="0 0 48 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="1.83317"
y="1.49998"
width="44.3333"
height="35"
rx="3.5"
stroke="currentColor"
strokeOpacity="0.8"
strokeWidth="2.33333"
/>
<path
d="M14.6665 30.6667H33.3332"
stroke="currentColor"
strokeOpacity="0.8"
strokeWidth="2.33333"
strokeLinecap="round"
/>
</svg>
);
}
export const IconPadTool = () => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z"
></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z"
></path>
</svg>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconSuccessFill = ({ className, style }: Props) => (
<svg
className={className}
style={style}
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<g clipPath="url(#icon-workflow-run-success_svg__a)">
<path
fill="#3EC254"
d="M.833 10A9.166 9.166 0 0 0 10 19.168a9.166 9.166 0 0 0 9.167-9.166A9.166 9.166 0 0 0 10 .834a9.166 9.166 0 0 0-9.167 9.167"
></path>
<path
fill="#fff"
d="M6.077 9.755a.833.833 0 0 0 0 1.179l2.357 2.357a.833.833 0 0 0 1.179 0l4.714-4.714a.833.833 0 1 0-1.178-1.179l-4.125 4.125-1.768-1.768a.833.833 0 0 0-1.179 0"
></path>
</g>
<defs>
<clipPath id="icon-workflow-run-success_svg__a">
<path fill="#fff" d="M0 0h20v20H0z"></path>
</clipPath>
</defs>
</svg>
);

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconSwitchLine = (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
id="switch-line"
fill="currentColor"
stroke="none"
d="M 12.728118 10.060962 C 13.064282 8.716098 14.272528 7.772551 15.65877 7.772343 L 17.689898 7.772343 C 18.0798 7.772343 18.39588 7.456264 18.39588 7.066362 C 18.39588 6.676458 18.0798 6.36038 17.689898 6.36038 L 15.659616 6.36038 C 13.62515 6.360315 11.851767 7.745007 11.358504 9.718771 C 11.02234 11.063635 9.814095 12.007183 8.427853 12.007389 L 7.101437 12.007389 C 6.711768 12.007389 6.395878 12.323277 6.395878 12.712947 C 6.395878 13.102616 6.711768 13.418506 7.101437 13.418506 L 8.426159 13.418506 C 9.812716 13.418323 11.021417 14.361954 11.357657 15.707124 C 11.850921 17.680887 13.624304 19.065578 15.65877 19.065516 L 17.689049 19.065516 C 18.078953 19.065516 18.395033 18.749435 18.395033 18.359533 C 18.395033 17.969631 18.078953 17.653551 17.689049 17.653551 L 15.65877 17.653551 C 14.272528 17.653345 13.064282 16.709797 12.728118 15.364932 C 12.454905 14.27114 11.774856 13.322707 10.826583 12.712947 C 11.774536 12.10303 12.454268 11.154617 12.727271 10.060962 Z"
/>
</svg>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconWarningFill = ({ className, style }: Props) => (
<svg
className={className}
style={style}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z"
></path>
</svg>
);

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Button } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { useAddNode } from './use-add-node';
export const AddNode = (props: { disabled: boolean }) => {
const addNode = useAddNode();
return (
<Button
data-testid="flow.add-node"
icon={<IconPlus />}
color="highlight"
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
disabled={props.disabled}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
addNode(rect);
}}
>
{I18n.t('Add Node')}
</Button>
);
};

View File

@ -0,0 +1,115 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
import {
useService,
WorkflowDocument,
usePlayground,
PositionSchema,
WorkflowNodeEntity,
WorkflowSelectService,
WorkflowNodeJSON,
getAntiOverlapPosition,
WorkflowNodeMeta,
FlowNodeBaseType,
} from '@flowgram.ai/free-layout-editor';
// hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook
const useGetPanelPosition = () => {
const playground = usePlayground();
return useCallback(
(targetBoundingRect: DOMRect): PositionSchema =>
// convert mouse position to canvas position - 将鼠标位置转换为画布位置
playground.config.getPosFromMouseEvent({
clientX: targetBoundingRect.left + 64,
clientY: targetBoundingRect.top - 7,
}),
[playground]
);
};
// hook to handle node selection - 处理节点选择的 hook
const useSelectNode = () => {
const selectService = useService(WorkflowSelectService);
return useCallback(
(node?: WorkflowNodeEntity) => {
if (!node) {
return;
}
// select the target node - 选择目标节点
selectService.selectNode(node);
},
[selectService]
);
};
const getContainerNode = (selectService: WorkflowSelectService) => {
const { activatedNode } = selectService;
if (!activatedNode) {
return;
}
const { isContainer } = activatedNode.getNodeMeta<WorkflowNodeMeta>();
if (isContainer) {
return activatedNode;
}
const parentNode = activatedNode.parent;
if (!parentNode || parentNode.flowNodeType === FlowNodeBaseType.ROOT) {
return;
}
return parentNode;
};
// main hook for adding new nodes - 添加新节点的主 hook
export const useAddNode = () => {
const workflowDocument = useService(WorkflowDocument);
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
const selectService = useService(WorkflowSelectService);
const playground = usePlayground();
const getPanelPosition = useGetPanelPosition();
const select = useSelectNode();
return useCallback(
async (targetBoundingRect: DOMRect): Promise<void> => {
// calculate panel position based on target element - 根据目标元素计算面板位置
const panelPosition = getPanelPosition(targetBoundingRect);
const containerNode = getContainerNode(selectService);
await new Promise<void>((resolve) => {
// call the node panel service to show the panel - 调用节点面板服务来显示面板
nodePanelService.callNodePanel({
position: panelPosition,
enableMultiAdd: true,
containerNode,
panelProps: {},
// handle node selection from panel - 处理从面板中选择节点
onSelect: async (panelParams?: NodePanelResult) => {
if (!panelParams) {
return;
}
const { nodeType, nodeJSON } = panelParams;
const position = Boolean(containerNode)
? getAntiOverlapPosition(workflowDocument, {
x: 0,
y: 200,
})
: undefined;
// create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
nodeType,
position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
select(node);
},
// handle panel close - 处理面板关闭
onClose: () => {
resolve();
},
});
});
},
[getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select]
);
};

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor';
import { ConfigProvider } from '@douyinfe/semi-ui';
import { NodeStatusBar } from '../testrun/node-status-bar';
import { NodeRenderContext } from '../../context';
import { ErrorIcon } from './styles';
import { NodeWrapper } from './node-wrapper';
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
/**
* Provides methods related to node rendering
* 提供节点渲染相关的方法
*/
const nodeRender = useNodeRender();
/**
* It can only be used when nodeEngine is enabled
* 只有在节点引擎开启时候才能使用表单
*/
const form = nodeRender.form;
/**
* Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library
* 用于让 Tooltip 跟随节点缩放, 这个可以根据不同的 ui 库自己实现
*/
const getPopupContainer = useCallback(() => node.renderData.node || document.body, []);
return (
<ConfigProvider getPopupContainer={getPopupContainer}>
<NodeRenderContext.Provider value={nodeRender}>
<NodeWrapper>
{form?.state.invalid && <ErrorIcon />}
{form?.render()}
</NodeWrapper>
<NodeStatusBar />
</NodeRenderContext.Provider>
</ConfigProvider>
);
};

View File

@ -0,0 +1,81 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState, useContext } from 'react';
import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { FlowNodeMeta } from '../../typings';
import { useNodeRenderContext, usePortClick } from '../../hooks';
import { SidebarContext } from '../../context';
import { scrollToView } from './utils';
import { NodeWrapperStyle } from './styles';
export interface NodeWrapperProps {
isScrollToView?: boolean;
children: React.ReactNode;
}
/**
* Used for drag-and-drop/click events and ports rendering of nodes
* 用于节点的拖拽/点击事件和点位渲染
*/
export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
const { children, isScrollToView = false } = props;
const nodeRender = useNodeRenderContext();
const { node, selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur, readonly } =
nodeRender;
const [isDragging, setIsDragging] = useState(false);
const sidebar = useContext(SidebarContext);
const form = nodeRender.form;
const ctx = useClientContext();
const onPortClick = usePortClick();
const meta = node.getNodeMeta<FlowNodeMeta>();
const portsRender = ports.map((p) => (
<WorkflowPortRender key={p.id} entity={p} onClick={!readonly ? onPortClick : undefined} />
));
return (
<>
<NodeWrapperStyle
className={selected ? 'selected' : ''}
ref={nodeRef}
draggable
onDragStart={(e) => {
startDrag(e);
setIsDragging(true);
}}
onTouchStart={(e) => {
startDrag(e as unknown as React.MouseEvent);
setIsDragging(true);
}}
onClick={(e) => {
selectNode(e);
if (!isDragging) {
sidebar.setNodeId(nodeRender.node.id);
// 可选:将 isScrollToView 设为 true可以让节点选中后滚动到画布中间
// Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected.
if (isScrollToView) {
scrollToView(ctx, nodeRender.node);
}
}
}}
onMouseUp={() => setIsDragging(false)}
onFocus={onFocus}
onBlur={onBlur}
data-node-selected={String(selected)}
style={{
...meta.wrapperStyle,
outline: form?.state.invalid ? '1px solid red' : 'none',
}}
>
{children}
</NodeWrapperStyle>
{portsRender}
</>
);
};

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
import { IconInfoCircle } from '@douyinfe/semi-icons';
export const NodeWrapperStyle = styled.div`
align-items: flex-start;
background-color: #fff;
border: 1px solid rgba(6, 7, 9, 0.15);
border-radius: 8px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
width: 360px;
height: auto;
&.selected {
border: 1px solid #4e40e5;
}
`;
export const ErrorIcon = () => (
<IconInfoCircle
style={{
position: 'absolute',
color: 'red',
left: -6,
top: -6,
zIndex: 1,
background: 'white',
borderRadius: 8,
}}
/>
);

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FreeLayoutPluginContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
export function scrollToView(
ctx: FreeLayoutPluginContext,
node: FlowNodeEntity,
sidebarWidth = 448
) {
const bounds = node.transform.bounds;
ctx.playground.scrollToView({
bounds,
scrollDelta: {
x: sidebarWidth / 2,
y: 0,
},
zoom: 1,
scrollToCenter: true,
});
}

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IBlankArea {
model: CommentEditorModel;
}
export const BlankArea: FC<IBlankArea> = (props) => {
const { model } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
return (
<div
className="workflow-comment-blank-area h-full w-full"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
model.setFocus(false);
selectNode(e);
playground.node.focus(); // 防止节点无法被删除
}}
onClick={(e) => {
model.setFocus(true);
model.selectEnd();
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};

View File

@ -0,0 +1,120 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC } from 'react';
import type { CommentEditorModel } from '../model';
import { ResizeArea } from './resize-area';
import { DragArea } from './drag-area';
interface IBorderArea {
model: CommentEditorModel;
overflow: boolean;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
}
export const BorderArea: FC<IBorderArea> = (props) => {
const { model, overflow, onResize } = props;
return (
<div style={{ zIndex: 999 }}>
{/* 左边 */}
<DragArea
style={{
position: 'absolute',
left: -10,
top: 10,
width: 20,
height: 'calc(100% - 20px)',
}}
model={model}
/>
{/* 右边 */}
<DragArea
style={{
position: 'absolute',
right: -10,
top: 10,
height: 'calc(100% - 20px)',
width: overflow ? 10 : 20, // 防止遮挡滚动条
}}
model={model}
/>
{/* 上边 */}
<DragArea
style={{
position: 'absolute',
top: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/* 下边 */}
<DragArea
style={{
position: 'absolute',
bottom: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/** 左上角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
top: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}
onResize={onResize}
/>
{/** 右上角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
top: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}
onResize={onResize}
/>
{/** 右下角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
bottom: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}
onResize={onResize}
/>
{/** 左下角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
bottom: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}
onResize={onResize}
/>
</div>
);
};

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { ReactNode, FC, CSSProperties } from 'react';
interface ICommentContainer {
focused: boolean;
children?: ReactNode;
style?: React.CSSProperties;
}
export const CommentContainer: FC<ICommentContainer> = (props) => {
const { focused, children, style } = props;
const scrollbarStyle = {
// 滚动条样式
scrollbarWidth: 'thin',
scrollbarColor: 'rgb(159 159 158 / 65%) transparent',
// 针对 WebKit 浏览器(如 Chrome、Safari的样式
'&:WebkitScrollbar': {
width: '4px',
},
'&::WebkitScrollbarTrack': {
background: 'transparent',
},
'&::WebkitScrollbarThumb': {
backgroundColor: 'rgb(159 159 158 / 65%)',
borderRadius: '20px',
border: '2px solid transparent',
},
} as unknown as CSSProperties;
return (
<div
className="workflow-comment-container"
data-flow-editor-selectable="false"
style={{
// tailwind 不支持 outline 的样式,所以这里需要使用 style 来设置
outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',
backgroundColor: focused ? '#FFF3EA' : '#FFFBED',
...scrollbarStyle,
...style,
}}
>
{children}
</div>
);
};

View File

@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC, useState, useEffect, type WheelEventHandler } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IContentDragArea {
model: CommentEditorModel;
focused: boolean;
overflow: boolean;
}
export const ContentDragArea: FC<IContentDragArea> = (props) => {
const { model, focused, overflow } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const [active, setActive] = useState(false);
useEffect(() => {
// 当编辑器失去焦点时,取消激活状态
if (!focused) {
setActive(false);
}
}, [focused]);
const handleWheel: WheelEventHandler<HTMLDivElement> = (e) => {
const editorElement = model.element;
if (active || !overflow || !editorElement) {
return;
}
e.stopPropagation();
const maxScroll = editorElement.scrollHeight - editorElement.clientHeight;
const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll);
editorElement.scroll(0, newScrollTop);
};
const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
if (active) {
return;
}
mouseDownEvent.preventDefault();
mouseDownEvent.stopPropagation();
model.setFocus(false);
selectNode(mouseDownEvent);
playground.node.focus(); // 防止节点无法被删除
const startX = mouseDownEvent.clientX;
const startY = mouseDownEvent.clientY;
const handleMouseUp = (mouseMoveEvent: MouseEvent) => {
const deltaX = mouseMoveEvent.clientX - startX;
const deltaY = mouseMoveEvent.clientY - startY;
// 判断是拖拽还是点击
const delta = 5;
if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {
// 点击后隐藏
setActive(true);
}
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('click', handleMouseUp);
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('click', handleMouseUp);
};
return (
<div
className="workflow-comment-content-drag-area"
onMouseDown={handleMouseDown}
onWheel={handleWheel}
style={{
display: active ? 'none' : undefined,
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, MouseEvent, TouchEvent, type FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import { type CommentEditorModel } from '../model';
interface IDragArea {
model: CommentEditorModel;
stopEvent?: boolean;
style?: CSSProperties;
}
export const DragArea: FC<IDragArea> = (props) => {
const { model, stopEvent = true, style } = props;
const playground = usePlayground();
const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();
const handleDrag = (e: MouseEvent | TouchEvent) => {
if (stopEvent) {
e.preventDefault();
e.stopPropagation();
}
model.setFocus(false);
onStartDrag(e as MouseEvent);
selectNode(e as MouseEvent);
playground.node.focus(); // 防止节点无法被删除
};
return (
<div
className="workflow-comment-drag-area"
data-flow-editor-selectable="false"
draggable={true}
style={style}
onMouseDown={handleDrag}
onTouchStart={handleDrag}
onFocus={onFocus}
onBlur={onBlur}
/>
);
};

View File

@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC, type CSSProperties, useEffect, useRef, useState, useMemo } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
interface ICommentEditor {
model: CommentEditorModel;
style?: CSSProperties;
value?: string;
onChange?: (value: string) => void;
}
export const CommentEditor: FC<ICommentEditor> = (props) => {
const { model, style, onChange } = props;
const playground = usePlayground();
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const placeholder = model.value || model.focused ? undefined : 'Enter a comment...';
// 同步编辑器内部值变化
useEffect(() => {
const disposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change) {
return;
}
onChange?.(model.value);
});
return () => disposer.dispose();
}, [model, onChange]);
useEffect(() => {
if (!editorRef.current) {
return;
}
model.element = editorRef.current;
}, [editorRef]);
return (
<div className="workflow-comment-editor">
<p className="workflow-comment-editor-placeholder">{placeholder}</p>
<textarea
className="workflow-comment-editor-textarea"
ref={editorRef}
style={style}
readOnly={playground.config.readonly}
onChange={(e) => {
const { value } = e.target;
model.setValue(value);
}}
onFocus={() => {
model.setFocus(true);
}}
onBlur={() => {
model.setFocus(false);
}}
/>
</div>
);
};

View File

@ -0,0 +1,108 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.workflow-comment {
width: auto;
height: auto;
min-width: 120px;
min-height: 80px;
}
.workflow-comment-container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
height: 100%;
border-radius: 8px;
outline: 1px solid;
padding: 6px 2px 6px 10px;
overflow: hidden;
}
.workflow-comment-drag-area {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
}
.workflow-comment-content-drag-area {
position: absolute;
height: 100%;
width: calc(100% - 22px);
}
.workflow-comment-resize-area {
position: absolute;
width: 10px;
height: 10px;
}
.workflow-comment-editor {
width: 100%;
height: 100%;
}
.workflow-comment-editor-placeholder {
margin: 0;
position: absolute;
pointer-events: none;
color: rgba(55, 67, 106, 0.38);
font-weight: 500;
}
.workflow-comment-editor-textarea {
width: 100%;
height: 100%;
box-sizing: border-box;
appearance: none;
border: none;
margin: 0;
padding: 0;
width: 100%;
background: none;
color: inherit;
font-family: inherit;
font-size: 16px;
resize: none;
outline: none;
}
.workflow-comment-more-button {
position: absolute;
right: 6px;
}
.workflow-comment-more-button > .semi-button {
color: rgba(255, 255, 255, 0);
background: none;
}
.workflow-comment-more-button > .semi-button:hover {
color: #ffa100;
background: #fbf2d2cc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button-focused > .semi-button:hover {
color: #ff811a;
background: #ffe3cecc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button > .semi-button:active {
color: #f2b600;
background: #ede5c7cc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button-focused > .semi-button:active {
color: #ff811a;
background: #eed5c1cc;
backdrop-filter: blur(1px);
}

View File

@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import './index.css';
export { CommentRender } from './render';

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { NodeMenu } from '../../node-menu';
interface IMoreButton {
node: WorkflowNodeEntity;
focused: boolean;
deleteNode: () => void;
}
export const MoreButton: FC<IMoreButton> = ({ node, focused, deleteNode }) => (
<div
className={`workflow-comment-more-button ${
focused ? 'workflow-comment-more-button-focused' : ''
}`}
>
<NodeMenu node={node} deleteNode={deleteNode} />
</div>
);

View File

@ -0,0 +1,83 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import {
Field,
FieldRenderProps,
FlowNodeFormData,
Form,
FormModelV2,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { useOverflow } from '../hooks/use-overflow';
import { useModel } from '../hooks/use-model';
import { useSize } from '../hooks';
import { CommentEditorFormField } from '../constant';
import { MoreButton } from './more-button';
import { CommentEditor } from './editor';
import { ContentDragArea } from './content-drag-area';
import { CommentContainer } from './container';
import { BorderArea } from './border-area';
export const CommentRender: FC<{
node: WorkflowNodeEntity;
}> = (props) => {
const { node } = props;
const model = useModel();
const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formControl = formModel?.formControl;
const { width, height, onResize } = useSize();
const { overflow, updateOverflow } = useOverflow({ model, height });
return (
<div
className="workflow-comment"
style={{
width,
height,
}}
ref={nodeRef}
data-node-selected={String(focused)}
onMouseEnter={updateOverflow}
onMouseDown={(e) => {
setTimeout(() => {
// 防止 selectNode 拦截事件,导致 slate 编辑器无法聚焦
selectNode(e);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay
}, 20);
}}
>
<Form control={formControl}>
<>
{/* 背景 */}
<CommentContainer focused={focused} style={{ height }}>
<Field name={CommentEditorFormField.Note}>
{({ field }: FieldRenderProps<string>) => (
<>
{/** 编辑器 */}
<CommentEditor model={model} value={field.value} onChange={field.onChange} />
{/* 内容拖拽区域(点击后隐藏) */}
<ContentDragArea model={model} focused={focused} overflow={overflow} />
{/* 更多按钮 */}
<MoreButton node={node} focused={focused} deleteNode={deleteNode} />
</>
)}
</Field>
</CommentContainer>
{/* 边框 */}
<BorderArea model={model} overflow={overflow} onResize={onResize} />
</>
</Form>
</div>
);
};

View File

@ -0,0 +1,89 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, type FC } from 'react';
import { MouseTouchEvent, useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
interface IResizeArea {
model: CommentEditorModel;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
getDelta?: (delta: { x: number; y: number }) => {
top: number;
right: number;
bottom: number;
left: number;
};
style?: CSSProperties;
}
export const ResizeArea: FC<IResizeArea> = (props) => {
const { model, onResize, getDelta, style } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const handleResizeStart = (
startResizeEvent: React.MouseEvent | React.TouchEvent | MouseEvent
) => {
MouseTouchEvent.preventDefault(startResizeEvent);
startResizeEvent.stopPropagation();
if (!onResize) {
return;
}
const { resizing, resizeEnd } = onResize();
model.setFocus(false);
selectNode(startResizeEvent as React.MouseEvent);
playground.node.focus(); // 防止节点无法被删除
const { clientX: startX, clientY: startY } = MouseTouchEvent.getEventCoord(
startResizeEvent as MouseEvent
);
const handleResizing = (mouseMoveEvent: MouseEvent | TouchEvent) => {
const { clientX: moveX, clientY: moveY } = MouseTouchEvent.getEventCoord(mouseMoveEvent);
const deltaX = moveX - startX;
const deltaY = moveY - startY;
const delta = getDelta?.({ x: deltaX, y: deltaY });
if (!delta || !resizing) {
return;
}
resizing(delta);
};
const handleResizeEnd = () => {
resizeEnd();
document.removeEventListener('mousemove', handleResizing);
document.removeEventListener('mouseup', handleResizeEnd);
document.removeEventListener('click', handleResizeEnd);
document.removeEventListener('touchmove', handleResizing);
document.removeEventListener('touchend', handleResizeEnd);
document.removeEventListener('touchcancel', handleResizeEnd);
};
document.addEventListener('mousemove', handleResizing);
document.addEventListener('mouseup', handleResizeEnd);
document.addEventListener('click', handleResizeEnd);
document.addEventListener('touchmove', handleResizing, { passive: false });
document.addEventListener('touchend', handleResizeEnd);
document.addEventListener('touchcancel', handleResizeEnd);
};
return (
<div
className="workflow-comment-resize-area"
style={style}
data-flow-editor-selectable="false"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
);
};

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable @typescript-eslint/naming-convention -- enum */
export enum CommentEditorFormField {
Size = 'size',
Note = 'note',
}
/** 编辑器事件 */
export enum CommentEditorEvent {
/** 内容变更事件 */
Change = 'change',
/** 多选事件 */
MultiSelect = 'multiSelect',
/** 单选事件 */
Select = 'select',
/** 失焦事件 */
Blur = 'blur',
}
export const CommentEditorDefaultValue = '';

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { useSize } from './use-size';

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useMemo } from 'react';
import {
FlowNodeFormData,
FormModelV2,
useEntityFromContext,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorFormField } from '../constant';
export const useModel = () => {
const node = useEntityFromContext<WorkflowNodeEntity>();
const { selected: focused } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const model = useMemo(() => new CommentEditorModel(), []);
// 同步失焦状态
useEffect(() => {
if (focused) {
return;
}
model.setFocus(focused);
}, [focused, model]);
// 同步表单值初始化
useEffect(() => {
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setValue(value); // 设置初始值
model.selectEnd(); // 设置初始化光标位置
}, [formModel, model]);
// 同步表单外部值变化undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Note) {
return;
}
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setValue(value);
});
return () => disposer.dispose();
}, [formModel, model]);
return model;
};

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useState, useEffect } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
export const useOverflow = (params: { model: CommentEditorModel; height: number }) => {
const { model, height } = params;
const playground = usePlayground();
const [overflow, setOverflow] = useState(false);
const isOverflow = useCallback((): boolean => {
if (!model.element) {
return false;
}
return model.element.scrollHeight > model.element.clientHeight;
}, [model, height, playground]);
// 更新 overflow
const updateOverflow = useCallback(() => {
setOverflow(isOverflow());
}, [isOverflow]);
// 监听高度变化
useEffect(() => {
updateOverflow();
}, [height, updateOverflow]);
// 监听 change 事件
useEffect(() => {
const changeDisposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change) {
return;
}
updateOverflow();
});
return () => {
changeDisposer.dispose();
};
}, [model, updateOverflow]);
return { overflow, updateOverflow };
};

View File

@ -0,0 +1,168 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useEffect, useState } from 'react';
import {
FlowNodeFormData,
FormModelV2,
FreeOperationType,
HistoryService,
TransformData,
useCurrentEntity,
usePlayground,
useService,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorFormField } from '../constant';
export const useSize = () => {
const node = useCurrentEntity();
const nodeMeta = node.getNodeMeta();
const playground = usePlayground();
const historyService = useService(HistoryService);
const { size = { width: 240, height: 150 } } = nodeMeta;
const transform = node.getData(TransformData);
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
const [width, setWidth] = useState(formSize?.width ?? size.width);
const [height, setHeight] = useState(formSize?.height ?? size.height);
// 初始化表单值
useEffect(() => {
const initSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!initSize) {
formModel.setValueIn(CommentEditorFormField.Size, {
width,
height,
});
}
}, [formModel, width, height]);
// 同步表单外部值变化:初始化/undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Size) {
return;
}
const newSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!newSize) {
return;
}
setWidth(newSize.width);
setHeight(newSize.height);
});
return () => disposer.dispose();
}, [formModel]);
const onResize = useCallback(() => {
const resizeState = {
width,
height,
originalWidth: width,
originalHeight: height,
positionX: transform.position.x,
positionY: transform.position.y,
offsetX: 0,
offsetY: 0,
};
const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {
if (!resizeState) {
return;
}
const { zoom } = playground.config;
const top = delta.top / zoom;
const right = delta.right / zoom;
const bottom = delta.bottom / zoom;
const left = delta.left / zoom;
const minWidth = 120;
const minHeight = 80;
const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);
const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);
// 如果宽度或高度小于最小值,则不更新偏移量
const newOffsetX =
(left > 0 || right < 0) && newWidth <= minWidth
? resizeState.offsetX
: left / 2 + right / 2;
const newOffsetY =
(top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;
const newPositionX = resizeState.positionX + newOffsetX;
const newPositionY = resizeState.positionY + newOffsetY;
resizeState.width = newWidth;
resizeState.height = newHeight;
resizeState.offsetX = newOffsetX;
resizeState.offsetY = newOffsetY;
// 更新状态
setWidth(newWidth);
setHeight(newHeight);
// 更新偏移量
transform.update({
position: {
x: newPositionX,
y: newPositionY,
},
});
};
const resizeEnd = () => {
historyService.transact(() => {
historyService.pushOperation(
{
type: FreeOperationType.dragNodes,
value: {
ids: [node.id],
value: [
{
x: resizeState.positionX + resizeState.offsetX,
y: resizeState.positionY + resizeState.offsetY,
},
],
oldValue: [
{
x: resizeState.positionX,
y: resizeState.positionY,
},
],
},
},
{
noApply: true,
}
);
formModel.setValueIn(CommentEditorFormField.Size, {
width: resizeState.width,
height: resizeState.height,
});
});
};
return {
resizing,
resizeEnd,
};
}, [node, width, height, transform, playground, formModel, historyService]);
return {
width,
height,
onResize,
};
};

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { CommentRender } from './components';

View File

@ -0,0 +1,111 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Emitter } from '@flowgram.ai/free-layout-editor';
import { CommentEditorEventParams } from './type';
import { CommentEditorDefaultValue, CommentEditorEvent } from './constant';
export class CommentEditorModel {
private innerValue: string = CommentEditorDefaultValue;
private emitter: Emitter<CommentEditorEventParams> = new Emitter();
private editor: HTMLTextAreaElement;
/** 注册事件 */
public on = this.emitter.event;
/** 获取当前值 */
public get value(): string {
return this.innerValue;
}
/** 外部设置模型值 */
public setValue(value: string = CommentEditorDefaultValue): void {
if (!this.initialized) {
return;
}
if (value === this.innerValue) {
return;
}
this.innerValue = value;
this.syncEditorValue();
this.emitter.fire({
type: CommentEditorEvent.Change,
value: this.innerValue,
});
}
public set element(el: HTMLTextAreaElement) {
if (this.initialized) {
return;
}
this.editor = el;
}
/** 获取编辑器 DOM 节点 */
public get element(): HTMLTextAreaElement | null {
return this.editor;
}
/** 编辑器聚焦/失焦 */
public setFocus(focused: boolean): void {
if (!this.initialized) {
return;
}
if (focused && !this.focused) {
this.editor.focus();
} else if (!focused && this.focused) {
this.editor.blur();
this.deselect();
this.emitter.fire({
type: CommentEditorEvent.Blur,
});
}
}
/** 选择末尾 */
public selectEnd(): void {
if (!this.initialized) {
return;
}
// 获取文本长度
const length = this.editor.value.length;
// 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)
this.editor.setSelectionRange(length, length);
}
/** 获取聚焦状态 */
public get focused(): boolean {
return document.activeElement === this.editor;
}
/** 取消选择文本 */
private deselect(): void {
const selection: Selection | null = window.getSelection();
// 清除所有选择区域
if (selection) {
selection.removeAllRanges();
}
}
/** 是否初始化 */
private get initialized(): boolean {
return Boolean(this.editor);
}
/**
* 同步编辑器实例内容
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
*/
private syncEditorValue(): void {
if (!this.initialized) {
return;
}
this.editor.value = this.innerValue;
}
}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { CommentEditorEvent } from './constant';
interface CommentEditorChangeEvent {
type: CommentEditorEvent.Change;
value: string;
}
interface CommentEditorMultiSelectEvent {
type: CommentEditorEvent.MultiSelect;
}
interface CommentEditorSelectEvent {
type: CommentEditorEvent.Select;
}
interface CommentEditorBlurEvent {
type: CommentEditorEvent.Blur;
}
export type CommentEditorEventParams =
| CommentEditorChangeEvent
| CommentEditorMultiSelectEvent
| CommentEditorSelectEvent
| CommentEditorBlurEvent;

View File

@ -0,0 +1,105 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
type GroupColor = {
'50': string;
'300': string;
'400': string;
};
export const defaultColor = 'Blue';
export const groupColors: Record<string, GroupColor> = {
Red: {
'50': '#fef2f2',
'300': '#fca5a5',
'400': '#f87171',
},
Orange: {
'50': '#fff7ed',
'300': '#fdba74',
'400': '#fb923c',
},
Amber: {
'50': '#fffbeb',
'300': '#fcd34d',
'400': '#fbbf24',
},
Yellow: {
'50': '#fef9c3',
'300': '#fde047',
'400': '#facc15',
},
Lime: {
'50': '#f7fee7',
'300': '#bef264',
'400': '#a3e635',
},
Green: {
'50': '#f0fdf4',
'300': '#86efac',
'400': '#4ade80',
},
Emerald: {
'50': '#ecfdf5',
'300': '#6ee7b7',
'400': '#34d399',
},
Teal: {
'50': '#f0fdfa',
'300': '#5eead4',
'400': '#2dd4bf',
},
Cyan: {
'50': '#ecfeff',
'300': '#67e8f9',
'400': '#22d3ee',
},
Sky: {
'50': '#ecfeff',
'300': '#7dd3fc',
'400': '#38bdf8',
},
Blue: {
'50': '#eff6ff',
'300': '#93c5fd',
'400': '#60a5fa',
},
Indigo: {
'50': '#eef2ff',
'300': '#a5b4fc',
'400': '#818cf8',
},
Violet: {
'50': '#f5f3ff',
'300': '#c4b5fd',
'400': '#a78bfa',
},
Purple: {
'50': '#faf5ff',
'300': '#d8b4fe',
'400': '#c084fc',
},
Fuchsia: {
'50': '#fdf4ff',
'300': '#f0abfc',
'400': '#e879f9',
},
Pink: {
'50': '#fdf2f8',
'300': '#f9a8d4',
'400': '#f472b6',
},
Rose: {
'50': '#fff1f2',
'300': '#fda4af',
'400': '#fb7185',
},
Gray: {
'50': '#f9fafb',
'300': '#d1d5db',
'400': '#9ca3af',
},
};

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC, useEffect } from 'react';
import { useWatch, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
interface GroupBackgroundProps {
node: WorkflowNodeEntity;
style?: CSSProperties;
selected: boolean;
}
export const GroupBackground: FC<GroupBackgroundProps> = ({ node, style, selected }) => {
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
const color = groupColors[colorName];
useEffect(() => {
const styleElement = document.createElement('style');
// 使用独特的选择器
const styleContent = `
.workflow-group-render[data-group-id="${node.id}"] .workflow-group-background {
border: 1px solid ${color['300']};
}
.workflow-group-render.selected[data-group-id="${node.id}"] .workflow-group-background {
border: 1px solid #4e40e5;
}
`;
styleElement.textContent = styleContent;
document.head.appendChild(styleElement);
return () => {
styleElement.remove();
};
}, [color]);
return (
<div
className="workflow-group-background"
data-flow-editor-selectable="true"
style={{
...style,
backgroundColor: `${color['300']}${selected ? '40' : '29'}`,
}}
/>
);
};

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { Popover, Tooltip } from '@douyinfe/semi-ui';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
export const GroupColor: FC = () => (
<Field<string> name={GroupField.Color}>
{({ field }) => {
const colorName = field.value ?? defaultColor;
return (
<Popover
position="top"
mouseLeaveDelay={300}
content={
<div className="workflow-group-color-palette">
{Object.entries(groupColors).map(([name, color]) => (
<Tooltip content={name} key={name} mouseEnterDelay={300}>
<span
className="workflow-group-color-item"
key={name}
style={{
backgroundColor: color['300'],
borderColor: name === colorName ? color['400'] : '#fff',
}}
onClick={() => field.onChange(name)}
/>
</Tooltip>
))}
</div>
}
>
<span
className="workflow-group-color"
style={{
backgroundColor: groupColors[colorName]['300'],
}}
/>
</Popover>
);
}}
</Field>
);

View File

@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { FC, ReactNode, MouseEvent, CSSProperties, TouchEvent } from 'react';
import { useWatch } from '@flowgram.ai/free-layout-editor';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
interface GroupHeaderProps {
onDrag: (e: MouseEvent | TouchEvent) => void;
onFocus: () => void;
onBlur: () => void;
children: ReactNode;
style?: CSSProperties;
}
export const GroupHeader: FC<GroupHeaderProps> = ({ onDrag, onFocus, onBlur, children, style }) => {
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
const color = groupColors[colorName];
return (
<div
className="workflow-group-header"
data-flow-editor-selectable="false"
onMouseDown={onDrag}
onTouchStart={onDrag}
onFocus={onFocus}
onBlur={onBlur}
style={{
...style,
backgroundColor: color['50'],
borderColor: color['300'],
}}
>
{children}
</div>
);
};

View File

@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
interface IconGroupProps {
size?: number;
}
export const IconGroup: FC<IconGroupProps> = ({ size }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
style={{
width: size,
height: size,
}}
>
<path
id="group"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 0.009766 10 L 0.009766 9.990234 L 0 9.990234 L 0 7.5 L 1 7.5 L 1 9 L 2.5 9 L 2.5 10 L 0.009766 10 Z M 3.710938 10 L 3.710938 9 L 6.199219 9 L 6.199219 10 L 3.710938 10 Z M 7.5 10 L 7.5 9 L 9 9 L 9 7.5 L 10 7.5 L 10 9.990234 L 9.990234 9.990234 L 9.990234 10 L 7.5 10 Z M 0 6.289063 L 0 3.800781 L 1 3.800781 L 1 6.289063 L 0 6.289063 Z M 9 6.289063 L 9 3.800781 L 10 3.800781 L 10 6.289063 L 9 6.289063 Z M 0 2.5 L 0 0.009766 L 0.009766 0.009766 L 0.009766 0 L 2.5 0 L 2.5 1 L 1 1 L 1 2.5 L 0 2.5 Z M 9 2.5 L 9 1 L 7.5 1 L 7.5 0 L 9.990234 0 L 9.990234 0.009766 L 10 0.009766 L 10 2.5 L 9 2.5 Z M 3.710938 1 L 3.710938 0 L 6.199219 0 L 6.199219 1 L 3.710938 1 Z"
/>
</svg>
);
export const IconUngroup: FC<IconGroupProps> = ({ size }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
style={{
width: size,
height: size,
}}
>
<path
id="ungroup"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 9.654297 10.345703 L 8.808594 9.5 L 7.175781 9.5 L 7.175781 8.609375 L 7.917969 8.609375 L 1.390625 2.082031 L 1.390625 2.824219 L 0.5 2.824219 L 0.5 1.191406 L -0.345703 0.345703 L 0.283203 -0.283203 L 1.166016 0.599609 L 2.724609 0.599609 L 2.724609 1.490234 L 2.056641 1.490234 L 8.509766 7.943359 L 8.509766 7.275391 L 9.400391 7.275391 L 9.400391 8.833984 L 10.283203 9.716797 L 9.654297 10.345703 Z M 0.509766 9.5 L 0.509766 9.490234 L 0.5 9.490234 L 0.5 7.275391 L 1.390625 7.275391 L 1.390625 8.609375 L 2.724609 8.609375 L 2.724609 9.5 L 0.509766 9.5 Z M 3.802734 9.5 L 3.802734 8.609375 L 6.017578 8.609375 L 6.017578 9.5 L 3.802734 9.5 Z M 0.5 6.197266 L 0.5 3.982422 L 1.390625 3.982422 L 1.390625 6.197266 L 0.5 6.197266 Z M 8.509766 6.197266 L 8.509766 3.982422 L 9.400391 3.982422 L 9.400391 6.197266 L 8.509766 6.197266 Z M 8.509766 2.824219 L 8.509766 1.490234 L 7.175781 1.490234 L 7.175781 0.599609 L 9.390625 0.599609 L 9.390625 0.609375 L 9.400391 0.609375 L 9.400391 2.824219 L 8.509766 2.824219 Z M 3.802734 1.490234 L 3.802734 0.599609 L 6.017578 0.599609 L 6.017578 1.490234 L 3.802734 1.490234 Z"
/>
</svg>
);

View File

@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { GroupNodeRender } from './node-render';
export { IconGroup } from './icon-group';

View File

@ -0,0 +1,81 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MouseEvent, useEffect } from 'react';
import {
FlowNodeFormData,
Form,
FormModelV2,
useNodeRender,
} from '@flowgram.ai/free-layout-editor';
import { useNodeSize } from '@flowgram.ai/free-container-plugin';
import { HEADER_HEIGHT, HEADER_PADDING } from '../constant';
import { UngroupButton } from './ungroup';
import { GroupTools } from './tools';
import { GroupTips } from './tips';
import { GroupHeader } from './header';
import { GroupBackground } from './background';
export const GroupNodeRender = () => {
const { node, selected, selectNode, nodeRef, startDrag, onFocus, onBlur } = useNodeRender();
const nodeSize = useNodeSize();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formControl = formModel?.formControl;
const { height, width } = nodeSize ?? {};
const nodeHeight = height ?? 0;
useEffect(() => {
// prevent lines in outside cannot be selected - 防止外层线条不可选中
const element = node.renderData.node;
element.style.pointerEvents = 'none';
}, [node]);
return (
<div
className={`workflow-group-render ${selected ? 'selected' : ''}`}
ref={nodeRef}
data-group-id={node.id}
data-node-selected={String(selected)}
onMouseDown={selectNode}
onClick={(e) => {
selectNode(e);
}}
style={{
width,
height,
}}
>
<Form control={formControl}>
<>
<GroupHeader
onDrag={(e) => {
startDrag(e as MouseEvent);
}}
onFocus={onFocus}
onBlur={onBlur}
style={{
height: HEADER_HEIGHT,
}}
>
<GroupTools />
</GroupHeader>
<GroupTips />
<UngroupButton node={node} />
<GroupBackground
node={node}
selected={selected}
style={{
top: HEADER_HEIGHT + HEADER_PADDING,
height: nodeHeight - HEADER_HEIGHT - HEADER_PADDING,
}}
/>
</>
</Form>
</div>
);
};

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable @typescript-eslint/naming-convention -- no need */
const STORAGE_KEY = 'workflow-move-into-group-tip-visible';
const STORAGE_VALUE = 'false';
export class TipsGlobalStore {
private static _instance?: TipsGlobalStore;
public static get instance(): TipsGlobalStore {
if (!this._instance) {
this._instance = new TipsGlobalStore();
}
return this._instance;
}
private closed = false;
public isClosed(): boolean {
return this.isCloseForever() || this.closed;
}
public close(): void {
this.closed = true;
}
public isCloseForever(): boolean {
return localStorage.getItem(STORAGE_KEY) === STORAGE_VALUE;
}
public closeForever(): void {
localStorage.setItem(STORAGE_KEY, STORAGE_VALUE);
}
}

View File

@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconClose = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#060709"
fillOpacity="0.5"
d="M12.13 12.128a.5.5 0 0 0 .001-.706L8.71 8l3.422-3.423a.5.5 0 0 0-.001-.705.5.5 0 0 0-.706-.002L8.002 7.293 4.579 3.87a.5.5 0 0 0-.705.002.5.5 0 0 0-.002.705L7.295 8l-3.423 3.422a.5.5 0 0 0 .002.706c.195.195.51.197.705.001l3.423-3.422 3.422 3.422c.196.196.51.194.706-.001"
></path>
</svg>
);

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useControlTips } from './use-control';
import { GroupTipsStyle } from './style';
import { isMacOS } from './is-mac-os';
import { IconClose } from './icon-close';
import { I18n } from '@flowgram.ai/free-layout-editor';
export const GroupTips = () => {
const { visible, close, closeForever } = useControlTips();
if (!visible) {
return null;
}
return (
<GroupTipsStyle className={'workflow-group-tips'}>
<div className="container">
<div className="content">
<p className="text">{I18n.t('Hold {{key}} to drag node out', { key: isMacOS ? 'Cmd ⌘' : 'Ctrl' })}</p>
<div
className="space"
style={{
width: 0,
}}
/>
</div>
<div className="actions">
<p className="close-forever" onClick={closeForever}>
{I18n.t('Never Remind')}
</p>
<div className="close" onClick={close}>
<IconClose />
</div>
</div>
</div>
</GroupTipsStyle>
);
};

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);

View File

@ -0,0 +1,79 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
export const GroupTipsStyle = styled.div`
position: absolute;
top: 35px;
width: 100%;
height: 28px;
white-space: nowrap;
pointer-events: auto;
.container {
display: inline-flex;
justify-content: center;
height: 100%;
width: 100%;
background-color: rgb(255 255 255);
border-radius: 8px 8px 0 0;
.content {
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: flex-start;
width: fit-content;
height: 100%;
padding: 0 12px;
.text {
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: rgba(15, 21, 40, 82%);
text-overflow: ellipsis;
margin: 0;
}
.space {
width: 128px;
}
}
.actions {
display: flex;
gap: 8px;
align-items: center;
height: 28px;
padding: 0 12px;
.close-forever {
cursor: pointer;
padding: 0 3px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 12px;
color: rgba(32, 41, 69, 62%);
margin: 0;
}
.close {
display: flex;
cursor: pointer;
height: 100%;
align-items: center;
}
}
}
`;

View File

@ -0,0 +1,71 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useEffect, useState } from 'react';
import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
import {
NodeIntoContainerService,
NodeIntoContainerType,
} from '@flowgram.ai/free-container-plugin';
import { TipsGlobalStore } from './global-store';
export const useControlTips = () => {
const node = useCurrentEntity();
const [visible, setVisible] = useState(false);
const globalStore = TipsGlobalStore.instance;
const nodeIntoContainerService = useService<NodeIntoContainerService>(NodeIntoContainerService);
const show = useCallback(() => {
if (globalStore.isClosed()) {
return;
}
setVisible(true);
}, [globalStore]);
const close = useCallback(() => {
globalStore.close();
setVisible(false);
}, [globalStore]);
const closeForever = useCallback(() => {
globalStore.closeForever();
close();
}, [close, globalStore]);
useEffect(() => {
// 监听移入
const inDisposer = nodeIntoContainerService.on((e) => {
if (e.type !== NodeIntoContainerType.In) {
return;
}
if (e.targetContainer === node) {
show();
}
});
// 监听移出事件
const outDisposer = nodeIntoContainerService.on((e) => {
if (e.type !== NodeIntoContainerType.Out) {
return;
}
if (e.sourceContainer === node && !node.blocks.length) {
setVisible(false);
}
});
return () => {
inDisposer.dispose();
outDisposer.dispose();
};
}, [nodeIntoContainerService, node, show, close, visible]);
return {
visible,
close,
closeForever,
};
};

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useState } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { Input } from '@douyinfe/semi-ui';
import { GroupField } from '../constant';
export const GroupTitle: FC = () => {
const [inputting, setInputting] = useState(false);
return (
<Field<string> name={GroupField.Title}>
{({ field }) =>
inputting ? (
<Input
autoFocus
className="workflow-group-title-input"
size="small"
value={field.value}
onChange={field.onChange}
onMouseDown={(e) => e.stopPropagation()}
onBlur={() => setInputting(false)}
draggable={false}
onEnterPress={() => setInputting(false)}
/>
) : (
<p className="workflow-group-title" onDoubleClick={() => setInputting(true)}>
{field.value ?? 'Group'}
</p>
)
}
</Field>
);
};

View File

@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { IconHandle } from '@douyinfe/semi-icons';
import { GroupTitle } from './title';
import { GroupColor } from './color';
export const GroupTools: FC = () => (
<div className="workflow-group-tools">
<IconHandle className="workflow-group-tools-drag" />
<GroupTitle />
<GroupColor />
</div>
);

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC } from 'react';
import { CommandRegistry, useService, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconUngroup } from './icon-group';
interface UngroupButtonProps {
node: WorkflowNodeEntity;
style?: CSSProperties;
}
export const UngroupButton: FC<UngroupButtonProps> = ({ node, style }) => {
const commandRegistry = useService(CommandRegistry);
return (
<Tooltip content="Ungroup">
<div className="workflow-group-ungroup" style={style}>
<Button
icon={<IconUngroup size={14} />}
style={{ height: 30, width: 30 }}
theme="borderless"
type="tertiary"
onClick={() => {
commandRegistry.executeCommand(WorkflowGroupCommand.Ungroup, node);
}}
/>
</div>
</Tooltip>
);
};

View File

@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const HEADER_HEIGHT = 30;
export const HEADER_PADDING = 5;
export enum GroupField {
Title = 'title',
Color = 'color',
}

View File

@ -0,0 +1,117 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.workflow-group-render {
border-radius: 8px;
pointer-events: none;
}
.workflow-group-background {
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
}
.workflow-group-header {
height: 30px;
width: fit-content;
background-color: #fefce8;
border: 1px solid #facc15;
border-radius: 8px;
padding-right: 8px;
pointer-events: auto;
}
.workflow-group-ungroup {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
width: 30px;
position: absolute;
top: 35px;
right: 0;
border-radius: 8px;
cursor: pointer;
pointer-events: auto;
}
.workflow-group-ungroup .semi-button {
color: #9ca3af;
}
.workflow-group-ungroup:hover .semi-button {
color: #374151;
}
.workflow-group-background {
position: absolute;
pointer-events: none;
top: 0;
background-color: #fddf4729;
border: 1px solid #fde047;
border-radius: 8px;
width: 100%;
}
.workflow-group-render.selected .workflow-group-background {
border: 1px solid #facc15;
}
.workflow-group-tools {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
height: 100%;
cursor: move;
color: oklch(44.6% 0.043 257.281);
font-size: 14px;
}
.workflow-group-title {
margin: 0;
max-width: 242px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.workflow-group-tools-drag {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding-left: 4px;
}
.workflow-group-color {
width: 16px;
height: 16px;
border-radius: 8px;
background-color: #fde047;
margin-left: 4px;
cursor: pointer;
}
.workflow-group-title-input {
width: 242px;
border: none;
color: #374151;
}
.workflow-group-color-palette {
display: grid;
grid-template-columns: repeat(6, 24px);
gap: 12px;
margin: 8px;
padding: 8px;
}
.workflow-group-color-item {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #fde047;
cursor: pointer;
border: 3px solid;
}

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import './index.css';
export { GroupNodeRender } from './components';
export { IconGroup } from './components';

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './base-node';
export * from './line-add-button';
export * from './node-panel';
export * from './comment';
export * from './group';

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconPlusCircle = () => (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="add">
<path
id="background"
fill="#ffffff"
fillRule="evenodd"
stroke="none"
d="M 24 12 C 24 5.372583 18.627417 0 12 0 C 5.372583 0 -0 5.372583 -0 12 C -0 18.627417 5.372583 24 12 24 C 18.627417 24 24 18.627417 24 12 Z"
/>
<path
id="content"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 22 12.005 C 22 6.482153 17.522848 2.004999 12 2.004999 C 6.477152 2.004999 2 6.482153 2 12.005 C 2 17.527847 6.477152 22.004999 12 22.004999 C 17.522848 22.004999 22 17.527847 22 12.005 Z"
/>
<path
id="cross"
fill="#ffffff"
stroke="none"
d="M 11.411996 16.411797 C 11.411996 16.736704 11.675362 17 12.00023 17 C 12.325109 17 12.588474 16.736704 12.588474 16.411797 L 12.588474 12.58826 L 16.41201 12.58826 C 16.736919 12.58826 17.000216 12.324894 17.000216 12.000015 C 17.000216 11.675147 16.736919 11.411781 16.41201 11.411781 L 12.588474 11.411781 L 12.588474 7.588234 C 12.588474 7.263367 12.325109 7 12.00023 7 C 11.675362 7 11.411996 7.263367 11.411996 7.588234 L 11.411996 11.411781 L 7.588449 11.411781 C 7.263581 11.411781 7.000215 11.675147 7.000215 12.000015 C 7.000215 12.324894 7.263581 12.58826 7.588449 12.58826 L 11.411996 12.58826 L 11.411996 16.411797 Z"
/>
</g>
</svg>
);

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.line-add-button {
position: absolute;
width: 24px;
height: 24px;
cursor: pointer;
color: inherit;
pointer-events: all;
}

View File

@ -0,0 +1,128 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import {
WorkflowNodePanelService,
WorkflowNodePanelUtils,
} from '@flowgram.ai/free-node-panel-plugin';
import { LineRenderProps } from '@flowgram.ai/free-lines-plugin';
import {
delay,
HistoryService,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowNodeJSON,
} from '@flowgram.ai/free-layout-editor';
import './index.less';
import { useVisible } from './use-visible';
import { IconPlusCircle } from './button';
export const LineAddButton = (props: LineRenderProps) => {
const { line, selected, hovered, color } = props;
const visible = useVisible({ line, selected, hovered });
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
const document = useService(WorkflowDocument);
const dragService = useService(WorkflowDragService);
const linesManager = useService(WorkflowLinesManager);
const historyService = useService(HistoryService);
const { fromPort, toPort } = line;
const onClick = useCallback(async () => {
// calculate the middle point of the line - 计算线条的中点位置
const position = {
x: (line.position.from.x + line.position.to.x) / 2,
y: (line.position.from.y + line.position.to.y) / 2,
};
// get container node for the new node - 获取新节点的容器节点
const containerNode = fromPort.node.parent;
// show node selection panel - 显示节点选择面板
const result = await nodePanelService.singleSelectNodePanel({
position,
containerNode,
panelProps: {
enableScrollClose: true,
},
});
if (!result) {
return;
}
const { nodeType, nodeJSON } = result;
// adjust position for the new node - 调整新节点的位置
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
nodeType,
position,
fromPort,
toPort,
containerNode,
document,
dragService,
});
// create new workflow node - 创建新的工作流节点
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
nodeType,
nodePosition,
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
// auto offset subsequent nodes - 自动偏移后续节点
if (fromPort && toPort) {
WorkflowNodePanelUtils.subNodesAutoOffset({
node,
fromPort,
toPort,
containerNode,
historyService,
dragService,
linesManager,
});
}
// wait for node render - 等待节点渲染
await delay(20);
// build connection lines - 构建连接线
WorkflowNodePanelUtils.buildLine({
fromPort,
node,
toPort,
linesManager,
});
// remove original line - 移除原始线条
line.dispose();
}, []);
if (!visible) {
return <></>;
}
return (
<div
className="line-add-button"
style={{
transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,
color,
}}
data-testid="sdk.workflow.canvas.line.add"
data-line-id={line.id}
onClick={onClick}
>
<IconPlusCircle />
</div>
);
};

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { usePlayground, WorkflowLineEntity } from '@flowgram.ai/free-layout-editor';
import './index.less';
export const useVisible = (params: {
line: WorkflowLineEntity;
selected?: boolean;
hovered?: boolean;
}): boolean => {
const playground = usePlayground();
const { line, selected = false, hovered } = params;
if (line.disposed) {
// 在 dispose 后,再去获取 line.to | line.from 会导致错误创建端口
return false;
}
if (playground.config.readonly) {
return false;
}
if (!selected && !hovered) {
return false;
}
return true;
};

View File

@ -0,0 +1,116 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useCallback, useState, type MouseEvent } from 'react';
import {
delay,
useClientContext,
useService,
WorkflowDragService,
WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-editor';
import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
import { IconButton, Dropdown } from '@douyinfe/semi-ui';
import { IconMore } from '@douyinfe/semi-icons';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import { PasteShortcut } from '../../shortcuts/paste';
import { CopyShortcut } from '../../shortcuts/copy';
interface NodeMenuProps {
node: WorkflowNodeEntity;
updateTitleEdit: (setEditing: boolean) => void;
deleteNode: () => void;
}
export const NodeMenu: FC<NodeMenuProps> = ({ node, deleteNode, updateTitleEdit }) => {
const [visible, setVisible] = useState(true);
const clientContext = useClientContext();
const registry = node.getNodeRegistry<FlowNodeRegistry>();
const nodeIntoContainerService = useService(NodeIntoContainerService);
const selectService = useService(WorkflowSelectService);
const dragService = useService(WorkflowDragService);
const canMoveOut = nodeIntoContainerService.canMoveOutContainer(node);
const rerenderMenu = useCallback(() => {
// force destroy component - 强制销毁组件触发重新渲染
setVisible(false);
requestAnimationFrame(() => {
setVisible(true);
});
}, []);
const handleMoveOut = useCallback(
async (e: MouseEvent) => {
e.stopPropagation();
const sourceParent = node.parent;
// move out of container - 移出容器
nodeIntoContainerService.moveOutContainer({ node });
await delay(16);
// clear invalid lines - 清除非法线条
await nodeIntoContainerService.clearInvalidLines({
dragNode: node,
sourceParent,
});
rerenderMenu();
// select node - 选中节点
selectService.selectNode(node);
// start drag node - 开始拖拽
dragService.startDragSelectedNodes(e);
},
[nodeIntoContainerService, node, rerenderMenu]
);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
const copyShortcut = new CopyShortcut(clientContext);
const pasteShortcut = new PasteShortcut(clientContext);
const data = copyShortcut.toClipboardData([node]);
pasteShortcut.apply(data);
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
},
[clientContext, node]
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
deleteNode();
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
},
[clientContext, node]
);
const handleEditTitle = useCallback(() => {
updateTitleEdit(true);
}, [updateTitleEdit]);
if (!visible) {
return <></>;
}
return (
<Dropdown
trigger="hover"
position="bottomRight"
render={
<Dropdown.Menu>
<Dropdown.Item onClick={handleEditTitle}>{I18n.t('Edit Title')}</Dropdown.Item>
{canMoveOut && <Dropdown.Item onClick={handleMoveOut}>{I18n.t('Move out')}</Dropdown.Item>}
<Dropdown.Item onClick={handleCopy}>{I18n.t('Create Copy')}</Dropdown.Item>
<Dropdown.Item type="danger" onClick={handleDelete}>{I18n.t('Delete')}</Dropdown.Item>
</Dropdown.Menu>
}
>
<IconButton
color="secondary"
size="small"
theme="borderless"
icon={<IconMore />}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
);
};

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.node-placeholder {
width: 360px;
background-color: rgba(252, 252, 255, 1);
border: 1px solid rgba(68, 83, 130, 0.25);
border-radius: 8px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 2%), 0 2px 6px 0 rgba(0, 0, 0, 4%);
}
.node-placeholder-skeleton {
width: 100%;
padding: 12px;
background-color: rgba(252, 252, 255, 1);
border-radius: 8px;
.semi-skeleton-avatar {
background-color: rgba(68, 83, 130, 0.25);
}
.semi-skeleton-title {
height: 16px;
background-color: rgba(82, 100, 154, 0.13);
border-radius: 4px;
}
}
.node-placeholder-hd {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.node-placeholder-avatar {
width: 24px;
height: 24px;
margin-right: 8px;
border-radius: 6px;
}
.node-placeholder-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.node-placeholder-footer {
display: flex;
flex-direction: row;
align-items: center;
gap: 2.5px;
}

View File

@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { Popover } from '@douyinfe/semi-ui';
import { NodePlaceholder } from './node-placeholder';
import { NodeList } from './node-list';
import './index.less';
export const NodePanel: FC<NodePanelRenderProps> = (props) => {
const { onSelect, position, onClose, containerNode, panelProps = {} } = props;
const { enableNodePlaceholder } = panelProps;
return (
<Popover
trigger="click"
visible={true}
onVisibleChange={(v) => (v ? null : onClose())}
content={<NodeList onSelect={onSelect} containerNode={containerNode} />}
placement="right"
popupAlign={{ offset: [30, 0] }}
overlayStyle={{
padding: 0,
}}
>
<div
style={
enableNodePlaceholder
? {
position: 'absolute',
top: position.y - 61.5,
left: position.x,
width: 360,
height: 100,
}
: {
position: 'absolute',
top: position.y,
left: position.x,
width: 0,
height: 0,
}
}
>
{enableNodePlaceholder && <NodePlaceholder />}
</div>
</Popover>
);
};

View File

@ -0,0 +1,102 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { FC } from 'react';
import styled from 'styled-components';
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { useClientContext, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import { nodeRegistries } from '../../nodes';
const NodeWrap = styled.div`
width: 100%;
height: 32px;
border-radius: 5px;
display: flex;
align-items: center;
cursor: pointer;
font-size: 19px;
padding: 0 15px;
&:hover {
background-color: hsl(252deg 62% 55% / 9%);
color: hsl(252 62% 54.9%);
}
`;
const NodeLabel = styled.div`
font-size: 12px;
margin-left: 10px;
`;
interface NodeProps {
label: string;
icon: JSX.Element;
onClick: React.MouseEventHandler<HTMLDivElement>;
disabled: boolean;
}
function Node(props: NodeProps) {
return (
<NodeWrap
data-testid={`flow-node-list-${props.label}`}
onClick={props.disabled ? undefined : props.onClick}
style={props.disabled ? { opacity: 0.3 } : {}}
>
<div style={{ fontSize: 14 }}>{props.icon}</div>
<NodeLabel>{props.label}</NodeLabel>
</NodeWrap>
);
}
const NodesWrap = styled.div`
max-height: 500px;
overflow: auto;
&::-webkit-scrollbar {
display: none;
}
`;
interface NodeListProps {
onSelect: NodePanelRenderProps['onSelect'];
containerNode?: WorkflowNodeEntity;
}
export const NodeList: FC<NodeListProps> = (props) => {
const { onSelect, containerNode } = props;
const context = useClientContext();
const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
const json = registry.onAdd?.(context);
onSelect({
nodeType: registry.type as string,
selectEvent: e,
nodeJSON: json,
});
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{nodeRegistries
.filter((register) => register.meta.nodePanelVisible !== false)
.filter((register) => {
if (register.meta.onlyInContainer) {
return register.meta.onlyInContainer === containerNode?.flowNodeType;
}
return true;
})
.map((registry) => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context) ?? true)}
icon={
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
}
label={registry.type as string}
onClick={(e) => handleClick(e, registry)}
/>
))}
</NodesWrap>
);
};

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Skeleton } from '@douyinfe/semi-ui';
export const NodePlaceholder = () => (
<div className="node-placeholder" data-testid="workflow.detail.node-panel.placeholder">
<Skeleton
className="node-placeholder-skeleton"
loading={true}
active={true}
placeholder={
<div className="">
<div className="node-placeholder-hd">
<Skeleton.Avatar shape="square" className="node-placeholder-avatar" />
<Skeleton.Title style={{ width: 141 }} />
</div>
<div className="node-placeholder-content">
<div className="node-placeholder-footer">
<Skeleton.Title style={{ width: 85 }} />
<Skeleton.Title style={{ width: 241 }} />
</div>
<Skeleton.Title style={{ width: 220 }} />
</div>
</div>
}
/>
</div>
);

View File

@ -0,0 +1,104 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FunctionComponent } from 'react';
import { SelectorBoxPopoverProps } from '@flowgram.ai/free-layout-editor';
import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
import { Button, ButtonGroup, Tooltip } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { IconCopy, IconDeleteStroked, IconExpand, IconShrink } from '@douyinfe/semi-icons';
import { IconGroup } from '../group';
import { FlowCommandId } from '../../shortcuts/constants';
const BUTTON_HEIGHT = 24;
export const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({
bounds,
children,
flowSelectConfig,
commandRegistry,
}) => (
<>
<div
style={{
position: 'absolute',
left: bounds.right,
top: bounds.top,
transform: 'translate(-100%, -100%)',
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<ButtonGroup
size="small"
style={{ display: 'flex', flexWrap: 'nowrap', height: BUTTON_HEIGHT }}
>
<Tooltip content={I18n.t('Collapse')}>
<Button
icon={<IconShrink />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onMouseDown={(e) => {
commandRegistry.executeCommand(FlowCommandId.COLLAPSE);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Expand')}>
<Button
icon={<IconExpand />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onMouseDown={(e) => {
commandRegistry.executeCommand(FlowCommandId.EXPAND);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Create Group')}>
<Button
icon={<IconGroup size={14} />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onClick={() => {
commandRegistry.executeCommand(WorkflowGroupCommand.Group);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Copy')}>
<Button
icon={<IconCopy />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.COPY);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Delete')}>
<Button
type="primary"
theme="solid"
icon={<IconDeleteStroked />}
style={{ height: BUTTON_HEIGHT }}
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.DELETE);
}}
/>
</Tooltip>
</ButtonGroup>
</div>
<div>{children}</div>
</>
);

View File

@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { SidebarProvider } from './sidebar-provider';
export { SidebarRenderer } from './sidebar-renderer';

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useNodeRender, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { NodeRenderContext } from '../../context';
export function SidebarNodeRenderer(props: { node: FlowNodeEntity }) {
const { node } = props;
const nodeRender = useNodeRender(node);
return (
<NodeRenderContext.Provider value={nodeRender}>
<div
style={{
background: 'rgb(251, 251, 251)',
height: 'calc(100vh - 40px)',
margin: '8px 8px 8px 0',
borderRadius: 8,
border: '1px solid rgba(82,100,154, 0.13)',
}}
>
{nodeRender.form?.render()}
</div>
</NodeRenderContext.Provider>
);
}

View File

@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState } from 'react';
import { SidebarContext } from '../../context';
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [nodeId, setNodeId] = useState<string | undefined>();
return (
<SidebarContext.Provider value={{ visible: !!nodeId, nodeId, setNodeId }}>
{children}
</SidebarContext.Provider>
);
}

View File

@ -0,0 +1,113 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useContext, useEffect, useMemo, startTransition } from 'react';
import {
PlaygroundEntityContext,
useRefresh,
useClientContext,
} from '@flowgram.ai/free-layout-editor';
import { SideSheet } from '@douyinfe/semi-ui';
import { FlowNodeMeta } from '../../typings';
import { SidebarContext, IsSidebarContext } from '../../context';
import { SidebarNodeRenderer } from './sidebar-node-renderer';
export const SidebarRenderer = () => {
const { nodeId, setNodeId } = useContext(SidebarContext);
const { selection, playground, document } = useClientContext();
const refresh = useRefresh();
const handleClose = useCallback(() => {
// Sidebar delayed closing
startTransition(() => {
setNodeId(undefined);
});
}, []);
const node = nodeId ? document.getNode(nodeId) : undefined;
/**
* Listen readonly
*/
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => {
handleClose();
refresh();
});
return () => disposable.dispose();
}, [playground]);
/**
* Listen selection
*/
useEffect(() => {
const toDispose = selection.onSelectionChanged(() => {
/**
* 如果没有选中任何节点,则自动关闭侧边栏
* If no node is selected, the sidebar is automatically closed
*/
if (selection.selection.length === 0) {
handleClose();
} else if (selection.selection.length === 1 && selection.selection[0] !== node) {
handleClose();
}
});
return () => toDispose.dispose();
}, [selection, handleClose, node]);
/**
* Close when node disposed
*/
useEffect(() => {
if (node) {
const toDispose = node.onDispose(() => {
setNodeId(undefined);
});
return () => toDispose.dispose();
}
return () => {};
}, [node]);
const visible = useMemo(() => {
if (!node) {
return false;
}
const { sidebarDisabled = false } = node.getNodeMeta<FlowNodeMeta>();
return !sidebarDisabled;
}, [node]);
if (playground.config.readonly) {
return null;
}
/**
* Add "key" to rerender the sidebar when the node changes
*/
const content =
node && visible ? (
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<SidebarNodeRenderer node={node} />
</PlaygroundEntityContext.Provider>
) : null;
return (
<SideSheet
mask={false}
visible={visible}
onCancel={handleClose}
closable={false}
motion={false}
width={500}
headerStyle={{
display: 'none',
}}
bodyStyle={{
padding: 0,
}}
style={{
background: 'none',
boxShadow: 'none',
}}
>
<IsSidebarContext.Provider value={true}>{content}</IsSidebarContext.Provider>
</SideSheet>
);
};

View File

@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { useFields } from './use-fields';
export { useFormMeta } from './use-form-meta';
export { useSyncDefault } from './use-sync-default';

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { TestRunFormField, TestRunFormMeta } from '../testrun-form/type';
export const useFields = (params: {
formMeta: TestRunFormMeta;
values: Record<string, unknown>;
setValues: (values: Record<string, unknown>) => void;
}): TestRunFormField[] => {
const { formMeta, values, setValues } = params;
// Convert each meta item to a form field with value and onChange handler
const fields: TestRunFormField[] = formMeta.map((meta) => {
// Handle object type specially - serialize object to JSON string for display
const getCurrentValue = (): unknown => {
const rawValue = values[meta.name] ?? meta.defaultValue;
if ((meta.type === 'object' || meta.type === 'array') && rawValue !== null) {
return JSON.stringify(rawValue, null, 2);
}
return rawValue;
};
const currentValue = getCurrentValue();
const handleChange = (newValue: unknown): void => {
if (meta.type === 'object' || meta.type === 'array') {
setValues({
...values,
[meta.name]: JSON.parse((newValue ?? '{}') as string),
});
} else {
setValues({
...values,
[meta.name]: newValue,
});
}
};
return {
...meta,
value: currentValue,
onChange: handleChange,
};
});
return fields;
};

View File

@ -0,0 +1,62 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useMemo } from 'react';
import {
FlowNodeFormData,
FormModelV2,
useService,
WorkflowDocument,
} from '@flowgram.ai/free-layout-editor';
import { IJsonSchema, JsonSchemaBasicType } from '@flowgram.ai/form-materials';
import { TestRunFormMetaItem } from '../testrun-form/type';
import { WorkflowNodeType } from '../../../nodes';
const getWorkflowInputsDeclare = (document: WorkflowDocument): IJsonSchema => {
const defaultDeclare = {
type: 'object',
properties: {},
};
const startNode = document.root.blocks.find(
(node) => node.flowNodeType === WorkflowNodeType.Start
);
if (!startNode) {
return defaultDeclare;
}
const startFormModel = startNode.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const declare = startFormModel.getValueIn<IJsonSchema>('outputs');
if (!declare) {
return defaultDeclare;
}
return declare;
};
export const useFormMeta = (): TestRunFormMetaItem[] => {
const document = useService(WorkflowDocument);
// Add state for form values
const formMeta = useMemo(() => {
const formFields: TestRunFormMetaItem[] = [];
const workflowInputs = getWorkflowInputsDeclare(document);
Object.entries(workflowInputs.properties!).forEach(([name, property]) => {
formFields.push({
type: property.type as JsonSchemaBasicType,
name,
defaultValue: property.default,
required: workflowInputs.required?.includes(name) ?? false,
itemsType: property.items?.type as JsonSchemaBasicType,
});
});
return formFields;
}, [document]);
return formMeta;
};

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { TestRunFormMeta } from '../testrun-form/type';
export const useSyncDefault = (params: {
formMeta: TestRunFormMeta;
values: Record<string, unknown>;
setValues: (values: Record<string, unknown>) => void;
}) => {
const { formMeta, values, setValues } = params;
useEffect(() => {
let formMetaValues: Record<string, unknown> = {};
formMeta.map((meta) => {
// If there is no value in values but there is a default value, trigger onChange once
if (!(meta.name in values) && meta.defaultValue !== undefined) {
formMetaValues = { ...formMetaValues, [meta.name]: meta.defaultValue };
}
});
setValues({
...values,
...formMetaValues,
});
}, [formMeta]);
};

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.node-status-group {
padding: 6px;
font-weight: 500;
color: #333;
font-size: 15px;
display: flex;
align-items: center;
&-icon {
transform: rotate(-90deg);
transition: transform 0.2s;
cursor: pointer;
margin-right: 4px;
opacity: 0;
&-expanded {
transform: rotate(0deg);
opacity: 1;
}
}
&-tag {
margin-left: 4px;
}
}

View File

@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useState } from 'react';
import classNames from 'classnames';
import { Tag } from '@douyinfe/semi-ui';
import { IconSmallTriangleDown } from '@douyinfe/semi-icons';
import { DataStructureViewer } from '../viewer';
import styles from './index.module.less';
interface NodeStatusGroupProps {
title: string;
data: unknown;
optional?: boolean;
disableCollapse?: boolean;
}
const isObjectHasContent = (obj: any = {}): boolean => obj && Object.keys(obj).length > 0;
export const NodeStatusGroup: FC<NodeStatusGroupProps> = ({
title,
data,
optional = false,
disableCollapse = false,
}) => {
const hasContent = isObjectHasContent(data);
const [isExpanded, setIsExpanded] = useState(true);
if (optional && !hasContent) {
return null;
}
return (
<>
<div
className={styles['node-status-group']}
onClick={() => hasContent && !disableCollapse && setIsExpanded(!isExpanded)}
>
{!disableCollapse && (
<IconSmallTriangleDown
className={classNames(styles['node-status-group-icon'], {
[styles['node-status-group-icon-expanded']]: isExpanded && hasContent,
})}
/>
)}
<span>{title}:</span>
{!hasContent && (
<Tag size="small" className={styles['node-status-group-tag']}>
null
</Tag>
)}
</div>
{hasContent && isExpanded ? <DataStructureViewer data={data} /> : null}
</>
);
};

View File

@ -0,0 +1,57 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.node-status-header {
border: 1px solid rgba(68, 83, 130, 0.25);
border-radius: 8px;
background-color: #fff;
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 100%;
min-width: 360px;
&-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px;
&-opened {
padding-bottom: 0;
}
.status-title {
height: 24px;
display: flex;
align-items: center;
column-gap: 8px;
min-width: 0;
:global(.coz-tag) {
height: 20px;
}
:global(.semi-tag-content) {
font-weight: 500;
line-height: 16px;
font-size: 12px;
}
:global(.semi-tag-suffix-icon > div) {
font-size: 14px;
}
}
.status-btns {
height: 24px;
display: flex;
align-items: center;
column-gap: 4px;
.is-show-detail {
transform: rotate(180deg);
}
}
}
}

View File

@ -0,0 +1,67 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState } from 'react';
import classNames from 'classnames';
import { IconChevronDown } from '@douyinfe/semi-icons';
import { useNodeRenderContext } from '../../../../hooks';
import styles from './index.module.less';
interface NodeStatusBarProps {
header?: React.ReactNode;
defaultShowDetail?: boolean;
extraBtns?: React.ReactNode[];
}
export const NodeStatusHeader: React.FC<React.PropsWithChildren<NodeStatusBarProps>> = ({
header,
defaultShowDetail,
children,
extraBtns = [],
}) => {
const [showDetail, setShowDetail] = useState(defaultShowDetail);
const { selectNode } = useNodeRenderContext();
const handleToggleShowDetail = (e: React.MouseEvent) => {
e.stopPropagation();
selectNode(e);
setShowDetail(!showDetail);
};
return (
<div
className={styles['node-status-header']}
// 必须要禁止 down 冒泡,防止判定圈选和 node hover不支持多边形
onMouseDown={(e) => e.stopPropagation()}
>
<div
className={classNames(
styles['node-status-header-content'],
showDetail && styles['node-status-header-content-opened']
)}
// 必须要禁止 down 冒泡,防止判定圈选和 node hover不支持多边形
onMouseDown={(e) => e.stopPropagation()}
// 其他事件统一走点击事件,且也需要阻止冒泡
onClick={handleToggleShowDetail}
>
<div className={styles['status-title']}>
{header}
{extraBtns.length > 0 ? extraBtns : null}
</div>
<div className={styles['status-btns']}>
<IconChevronDown
className={classNames({
[styles['is-show-detail']]: showDetail,
})}
/>
</div>
</div>
{showDetail ? children : null}
</div>
);
};

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useState } from 'react';
import { NodeReport } from '@flowgram.ai/runtime-interface';
import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
import { NodeStatusRender } from './render';
const useNodeReport = () => {
const node = useCurrentEntity();
const [report, setReport] = useState<NodeReport>();
const runtimeService = useService(WorkflowRuntimeService);
useEffect(() => {
const reportDisposer = runtimeService.onNodeReportChange((nodeReport) => {
if (nodeReport.id !== node.id) {
return;
}
setReport(nodeReport);
});
const resetDisposer = runtimeService.onReset(() => {
setReport(undefined);
});
return () => {
reportDisposer.dispose();
resetDisposer.dispose();
};
}, []);
return report;
};
export const NodeStatusBar = () => {
const report = useNodeReport();
if (!report) {
return null;
}
return <NodeStatusRender report={report} />;
};

View File

@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.nodeStatus {
&Succeed {
background-color: rgba(105, 209, 140, 0.3);
color: #00b42a;
}
&Processing {
background-color: rgba(153, 187, 255, 0.3);
color: #4d53e8;
}
&Failed {
background-color: rgba(255, 163, 171, 0.3);
color: #f53f3f;
}
}
.icon {
&.processing {
color: rgba(77, 83, 232, 1);
}
}
.round {
border-radius: 50%;
}
.desc {
margin: 0;
}
.count {
font-weight: 500;
color: #333;
font-size: 15px;
margin-left: 12px;
}
.snapshotNavigation {
margin: 12px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.snapshotButton {
min-width: 32px;
height: 32px;
padding: 0;
border-radius: 4px;
font-size: 12px;
border: 1px solid;
font-weight: 500;
&.active {
border-color: #4d53e8;
font-weight: 800;
}
&.inactive {
border-color: rgba(29, 28, 35, 0.08);
}
}
.snapshotSelect {
width: 90px;
height: 32px;
border: 1px solid;
&.active {
border-color: #4d53e8;
}
&.inactive {
border-color: rgba(29, 28, 35, 0.08);
}
}
.container {
width: 100%;
height: 100%;
padding: 4px 2px 4px 2px;
}
.error {
padding: 12px;
color: red;
}

View File

@ -0,0 +1,194 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useMemo, useState } from 'react';
import classnames from 'classnames';
import { NodeReport, WorkflowStatus } from '@flowgram.ai/runtime-interface';
import { Tag, Button, Select } from '@douyinfe/semi-ui';
import { IconSpin } from '@douyinfe/semi-icons';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { NodeStatusHeader } from '../header';
import { NodeStatusGroup } from '../group';
import { IconWarningFill } from '../../../../assets/icon-warning';
import { IconSuccessFill } from '../../../../assets/icon-success';
import styles from './index.module.less';
interface NodeStatusRenderProps {
report: NodeReport;
}
const msToSeconds = (ms: number): string => (ms / 1000).toFixed(2) + 's';
const displayCount = 6;
export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
const { status: nodeStatus } = report;
const [currentSnapshotIndex, setCurrentSnapshotIndex] = useState(0);
const snapshots = report.snapshots || [];
const currentSnapshot = snapshots[currentSnapshotIndex] || snapshots[0];
// 节点 5 个状态
const isNodePending = nodeStatus === WorkflowStatus.Pending;
const isNodeProcessing = nodeStatus === WorkflowStatus.Processing;
const isNodeFailed = nodeStatus === WorkflowStatus.Failed;
const isNodeSucceed = nodeStatus === WorkflowStatus.Succeeded;
const isNodeCanceled = nodeStatus === WorkflowStatus.Canceled;
const tagColor = useMemo(() => {
if (isNodeSucceed) {
return styles.nodeStatusSucceed;
}
if (isNodeFailed) {
return styles.nodeStatusFailed;
}
if (isNodeProcessing) {
return styles.nodeStatusProcessing;
}
}, [isNodeSucceed, isNodeFailed, isNodeProcessing]);
const renderIcon = () => {
if (isNodeProcessing) {
return <IconSpin spin className={classnames(styles.icon, styles.processing)} />;
}
if (isNodeSucceed) {
return <IconSuccessFill />;
}
return <IconWarningFill className={classnames(tagColor, styles.round)} />;
};
const renderDesc = () => {
const getDesc = () => {
if (isNodeProcessing) {
return I18n.t('Running');
} else if (isNodePending) {
return I18n.t('Run terminated');
} else if (isNodeSucceed) {
return I18n.t('Succeed');
} else if (isNodeFailed) {
return I18n.t('Failed');
} else if (isNodeCanceled) {
return I18n.t('Canceled');
}
};
const desc = getDesc();
return desc ? <p className={styles.desc}>{desc}</p> : null;
};
const renderCost = () => (
<Tag size="small" className={tagColor}>
{msToSeconds(report.timeCost)}
</Tag>
);
const renderSnapshotNavigation = () => {
if (snapshots.length <= 1) {
return null;
}
const count = (
<p className={styles.count}>
{I18n.t('Total')}: {snapshots.length}
</p>
);
if (snapshots.length <= displayCount) {
return (
<>
{count}
<div className={styles.snapshotNavigation}>
{snapshots.map((_, index) => (
<Button
key={index}
size="small"
type={currentSnapshotIndex === index ? 'primary' : 'tertiary'}
onClick={() => setCurrentSnapshotIndex(index)}
className={classnames(styles.snapshotButton, {
[styles.active]: currentSnapshotIndex === index,
[styles.inactive]: currentSnapshotIndex !== index,
})}
>
{index + 1}
</Button>
))}
</div>
</>
);
}
// 超过5个时前5个显示为按钮剩余的放在下拉选择中
return (
<>
{count}
<div className={styles.snapshotNavigation}>
{snapshots.slice(0, displayCount).map((_, index) => (
<Button
key={index}
size="small"
type="tertiary"
onClick={() => setCurrentSnapshotIndex(index)}
className={classnames(styles.snapshotButton, {
[styles.active]: currentSnapshotIndex === index,
[styles.inactive]: currentSnapshotIndex !== index,
})}
>
{index + 1}
</Button>
))}
<Select
value={currentSnapshotIndex >= displayCount ? currentSnapshotIndex : undefined}
onChange={(value) => setCurrentSnapshotIndex(value as number)}
className={classnames(styles.snapshotSelect, {
[styles.active]: currentSnapshotIndex >= displayCount,
[styles.inactive]: currentSnapshotIndex < displayCount,
})}
size="small"
placeholder={I18n.t('Select')}
>
{snapshots.slice(displayCount).map((_, index) => {
const actualIndex = index + displayCount;
return (
<Select.Option key={actualIndex} value={actualIndex}>
{actualIndex + 1}
</Select.Option>
);
})}
</Select>
</div>
</>
);
};
if (!report) {
return null;
}
return (
<NodeStatusHeader
header={
<>
{renderIcon()}
{renderDesc()}
{renderCost()}
</>
}
>
<div className={styles.container}>
{isNodeFailed && currentSnapshot?.error && (
<div className={styles.error}>{currentSnapshot.error}</div>
)}
{renderSnapshotNavigation()}
<NodeStatusGroup title={I18n.t('Inputs')} data={currentSnapshot?.inputs} />
<NodeStatusGroup title={I18n.t('Outputs')} data={currentSnapshot?.outputs} />
<NodeStatusGroup title={I18n.t('Branch')} data={currentSnapshot?.branch} optional />
<NodeStatusGroup title={I18n.t('Data')} data={currentSnapshot?.data} optional />
{/* i18n titles */}
{/* Titles replaced above with I18n.t */}
</div>
</NodeStatusHeader>
);
};

View File

@ -0,0 +1,142 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.dataStructureViewer {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
color: #333;
background: #fafafa;
border-radius: 6px;
padding: 12px 12px 12px 0;
margin: 12px;
border: 1px solid #e1e4e8;
overflow: hidden;
.treeNode {
margin: 2px 0;
&Header {
display: flex;
align-items: flex-start;
gap: 4px;
min-height: 20px;
padding: 2px 0;
border-radius: 3px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
&Children {
margin-left: 8px;
padding-left: 8px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: #e1e4e8;
}
}
}
.expandButton {
background: none;
border: none;
cursor: pointer;
font-size: 10px;
color: #666;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
transition: all 0.15s ease;
padding: 0;
margin: 0;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
color: #333;
}
&.expanded {
transform: rotate(90deg);
}
&.collapsed {
transform: rotate(0deg);
}
}
.expandPlaceholder {
width: 16px;
height: 16px;
display: inline-block;
flex-shrink: 0;
}
.nodeLabel {
color: #0969da;
font-weight: 500;
cursor: pointer;
user-select: auto;
margin-right: 4px;
&:hover {
text-decoration: underline;
}
}
.nodeValue {
margin-left: 4px;
}
.primitiveValue {
cursor: pointer;
user-select: all;
padding: 1px 3px;
border-radius: 3px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
&Quote {
color: #8f8f8f;
}
&.string {
color: #032f62;
background-color: rgba(3, 47, 98, 0.05);
}
&.number {
color: #005cc5;
background-color: rgba(0, 92, 197, 0.05);
}
&.boolean {
color: #e36209;
background-color: rgba(227, 98, 9, 0.05);
}
&.null,
&.undefined {
color: #6a737d;
font-style: italic;
background-color: rgba(106, 115, 125, 0.05);
}
}
}

View File

@ -0,0 +1,190 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState } from 'react';
import classnames from 'classnames';
import { Toast } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import styles from './index.module.less';
interface DataStructureViewerProps {
data: any;
level?: number;
}
interface TreeNodeProps {
label: string;
value: any;
level: number;
isLast?: boolean;
}
const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false }) => {
const [isExpanded, setIsExpanded] = useState(true);
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
Toast.success(I18n.t('Copied'));
};
const isExpandable = (val: any) =>
val !== null &&
typeof val === 'object' &&
((Array.isArray(val) && val.length > 0) ||
(!Array.isArray(val) && Object.keys(val).length > 0));
const renderPrimitiveValue = (val: any) => {
if (val === null)
return <span className={classnames(styles.primitiveValue, styles.null)}>null</span>;
if (val === undefined)
return <span className={classnames(styles.primitiveValue, styles.undefined)}>undefined</span>;
switch (typeof val) {
case 'string':
return (
<span>
<span className={styles.primitiveValueQuote}>{'"'}</span>
<span
className={classnames(styles.primitiveValue, styles.string)}
onDoubleClick={() => handleCopy(val)}
>
{val}
</span>
<span className={styles.primitiveValueQuote}>{'"'}</span>
</span>
);
case 'number':
return (
<span
className={classnames(styles.primitiveValue, styles.number)}
onDoubleClick={() => handleCopy(String(val))}
>
{val}
</span>
);
case 'boolean':
return (
<span
className={classnames(styles.primitiveValue, styles.boolean)}
onDoubleClick={() => handleCopy(val.toString())}
>
{val.toString()}
</span>
);
case 'object':
// Handle empty objects and arrays
if (Array.isArray(val)) {
return (
<span className={styles.primitiveValue} onDoubleClick={() => handleCopy('[]')}>
[]
</span>
);
} else {
return (
<span className={styles.primitiveValue} onDoubleClick={() => handleCopy('{}')}>
{'{}'}
</span>
);
}
default:
return (
<span className={styles.primitiveValue} onDoubleClick={() => handleCopy(String(val))}>
{String(val)}
</span>
);
}
};
const renderChildren = () => {
if (Array.isArray(value)) {
return value.map((item, index) => (
<TreeNode
key={index}
label={`${index + 1}.`}
value={item}
level={level + 1}
isLast={index === value.length - 1}
/>
));
} else {
const entries = Object.entries(value);
return entries.map(([key, val], index) => (
<TreeNode
key={key}
label={`${key}:`}
value={val}
level={level + 1}
isLast={index === entries.length - 1}
/>
));
}
};
return (
<div className={styles.treeNode}>
<div className={styles.treeNodeHeader}>
{isExpandable(value) ? (
<button
className={classnames(
styles.expandButton,
isExpanded ? styles.expanded : styles.collapsed
)}
onClick={() => setIsExpanded(!isExpanded)}
>
</button>
) : (
<span className={styles.expandPlaceholder}></span>
)}
<span
className={styles.nodeLabel}
onClick={() =>
handleCopy(
JSON.stringify({
[label]: value,
})
)
}
>
{label}
</span>
{!isExpandable(value) && (
<span className={styles.nodeValue}>{renderPrimitiveValue(value)}</span>
)}
</div>
{isExpandable(value) && isExpanded && (
<div className={styles.treeNodeChildren}>{renderChildren()}</div>
)}
</div>
);
};
export const DataStructureViewer: React.FC<DataStructureViewerProps> = ({ data, level = 0 }) => {
if (data === null || data === undefined || typeof data !== 'object') {
return (
<div className={styles.dataStructureViewer}>
<TreeNode label="value" value={data} level={0} />
</div>
);
}
const entries = Object.entries(data);
return (
<div className={styles.dataStructureViewer}>
{entries.map(([key, value], index) => (
<TreeNode
key={key}
label={`${key}:`}
value={value}
level={0}
isLast={index === entries.length - 1}
/>
))}
</div>
);
};

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.testrun-success-button {
background-color: rgba(0, 178, 60, 1) !important; // override semi style
border-radius: 8px;
color: #fff !important; // override semi style
}
.testrun-error-button {
background-color: rgba(255, 115, 0, 1) !important; // override semi style
border-radius: 8px;
color: #fff !important; // override semi style
}

View File

@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState, useEffect, useCallback } from 'react';
import { useClientContext, getNodeForm, FlowNodeEntity, I18n } from '@flowgram.ai/free-layout-editor';
import { Button, Badge } from '@douyinfe/semi-ui';
import { IconPlay } from '@douyinfe/semi-icons';
import { TestRunSidePanel } from '../testrun-panel';
import styles from './index.module.less';
export function TestRunButton(props: { disabled: boolean }) {
const [errorCount, setErrorCount] = useState(0);
const clientContext = useClientContext();
const [visible, setVisible] = useState(false);
const updateValidateData = useCallback(() => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
const count = allForms.filter((form) => form?.state.invalid).length;
setErrorCount(count);
}, [clientContext]);
/**
* Validate all node and Save
*/
const onTestRun = useCallback(async () => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
await Promise.all(allForms.map(async (form) => form?.validate()));
console.log('>>>>> save data: ', clientContext.document.toJSON());
setVisible(true);
}, [clientContext]);
/**
* Listen single node validate
*/
useEffect(() => {
const listenSingleNodeValidate = (node: FlowNodeEntity) => {
const form = getNodeForm(node);
if (form) {
const formValidateDispose = form.onValidate(() => updateValidateData());
node.onDispose(() => formValidateDispose.dispose());
}
};
clientContext.document.getAllNodes().map((node) => listenSingleNodeValidate(node));
const dispose = clientContext.document.onNodeCreate(({ node }) =>
listenSingleNodeValidate(node)
);
return () => dispose.dispose();
}, [clientContext]);
const button =
errorCount === 0 ? (
<Button
disabled={props.disabled}
onClick={onTestRun}
icon={<IconPlay size="small" />}
className={styles.testrunSuccessButton}
>
{I18n.t('Test Run')}
</Button>
) : (
<Badge count={errorCount} position="rightTop" type="danger">
<Button
type="danger"
disabled={props.disabled}
onClick={onTestRun}
icon={<IconPlay size="small" />}
className={styles.testrunErrorButton}
>
  {I18n.t('Test Run')}
</Button>
</Badge>
);
return (
<>
{button}
<TestRunSidePanel visible={visible} onCancel={() => setVisible(false)} />
</>
);
}

Some files were not shown because too many files have changed in this diff Show More