新版本流程编辑器
@@ -12,10 +12,12 @@ dist/
|
||||
__debug_bin*
|
||||
node_modules/
|
||||
.next/
|
||||
.task/
|
||||
*.tsbuildinfo
|
||||
|
||||
.superpowers
|
||||
|
||||
agent-desk
|
||||
|
||||
test-reports
|
||||
test-reports
|
||||
web/public/flowgram-editor/
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:24-alpine AS flowgram-editor-builder
|
||||
WORKDIR /src/flowgram-editor
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.30.2 --activate
|
||||
COPY flowgram-editor/package.json flowgram-editor/pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY flowgram-editor/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:24-alpine AS web-builder
|
||||
WORKDIR /src/web
|
||||
|
||||
@@ -9,6 +20,7 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY web/ ./
|
||||
COPY --from=flowgram-editor-builder /src/web/public/flowgram-editor ./public/flowgram-editor
|
||||
RUN pnpm build:sdk && pnpm build
|
||||
|
||||
FROM golang:1.26-alpine AS server-builder
|
||||
|
||||
@@ -185,6 +185,8 @@ tasks:
|
||||
|
||||
dev:frontend:
|
||||
internal: true
|
||||
deps:
|
||||
- task: build:flowgram-editor
|
||||
cmds:
|
||||
- |
|
||||
echo "Starting frontend on http://localhost:3000 ..."
|
||||
@@ -192,9 +194,24 @@ tasks:
|
||||
|
||||
build:web:
|
||||
internal: true
|
||||
deps:
|
||||
- task: build:flowgram-editor
|
||||
cmds:
|
||||
- 'cd {{.WEB_DIR}} && {{.PNPM}} build:sdk && {{.PNPM}} build'
|
||||
|
||||
build:flowgram-editor:
|
||||
internal: true
|
||||
sources:
|
||||
- flowgram-editor/src/**/*
|
||||
- flowgram-editor/package.json
|
||||
- flowgram-editor/pnpm-lock.yaml
|
||||
- flowgram-editor/rsbuild.config.ts
|
||||
generates:
|
||||
- web/public/flowgram-editor/index.html
|
||||
cmds:
|
||||
- 'cd flowgram-editor && {{.PNPM}} install --frozen-lockfile'
|
||||
- 'cd flowgram-editor && {{.PNPM}} build'
|
||||
|
||||
build:dist:
|
||||
internal: true
|
||||
cmds:
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# FlowGram.AI - Demo Free Layout
|
||||
|
||||
Best-practice demo for free layout
|
||||
|
||||
## Installation
|
||||
|
||||
```shell
|
||||
npx @flowgram.ai/create-app@latest free-layout
|
||||
```
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Core Tech Stack
|
||||
- **Frontend framework**: React 18 + TypeScript
|
||||
- **Build tool**: Rsbuild (a modern build tool based on Rspack)
|
||||
- **Styling**: Less + Styled Components + CSS Variables
|
||||
- **UI library**: Semi Design (@douyinfe/semi-ui)
|
||||
- **State management**: Flowgram’s in-house editor framework
|
||||
- **Dependency injection**: Inversify
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
- **@flowgram.ai/free-layout-editor**: Core dependency for the free layout editor
|
||||
- **@flowgram.ai/free-snap-plugin**: Auto-alignment and guide-lines plugin
|
||||
- **@flowgram.ai/free-lines-plugin**: Connection line rendering plugin
|
||||
- **@flowgram.ai/free-node-panel-plugin**: Node add-panel rendering plugin
|
||||
- **@flowgram.ai/minimap-plugin**: Minimap plugin
|
||||
- **@flowgram.ai/export-plugin**: Download/export plugin
|
||||
- **@flowgram.ai/free-container-plugin**: Sub-canvas plugin
|
||||
- **@flowgram.ai/free-group-plugin**: Grouping plugin
|
||||
- **@flowgram.ai/form-materials**: Form materials
|
||||
- **@flowgram.ai/runtime-interface**: Runtime interfaces
|
||||
- **@flowgram.ai/runtime-js**: JS runtime module
|
||||
- **@flowgram.ai/panel-manager-plugin**: Sidebar panel management
|
||||
|
||||
## Code Guide
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
src/
|
||||
├── app.tsx # Application entry file
|
||||
├── editor.tsx # Main editor component
|
||||
├── initial-data.ts # Initial data configuration
|
||||
├── assets/ # Static assets
|
||||
├── components/ # Component library
|
||||
│ ├── index.ts
|
||||
│ ├── add-node/ # Add-node component
|
||||
│ ├── base-node/ # Base node components
|
||||
│ ├── comment/ # Comment components
|
||||
│ ├── group/ # Group components
|
||||
│ ├── line-add-button/ # Connection add button
|
||||
│ ├── node-menu/ # Node menu
|
||||
│ ├── node-panel/ # Node add panel
|
||||
│ ├── selector-box-popover/ # Selection box popover
|
||||
│ ├── sidebar/ # Sidebar
|
||||
│ ├── testrun/ # Test-run module
|
||||
│ │ ├── hooks/ # Test-run hooks
|
||||
│ │ ├── node-status-bar/ # Node status bar
|
||||
│ │ ├── testrun-button/ # Test-run button
|
||||
│ │ ├── testrun-form/ # Test-run form
|
||||
│ │ ├── testrun-json-input/ # JSON input component
|
||||
│ │ └── testrun-panel/ # Test-run panel
|
||||
│ └── tools/ # Utility components
|
||||
├── context/ # React Context
|
||||
│ ├── node-render-context.ts # Current rendering node context
|
||||
│ ├── sidebar-context # Sidebar context
|
||||
├── form-components/ # Form component library
|
||||
│ ├── form-content/ # Form content
|
||||
│ ├── form-header/ # Form header
|
||||
│ ├── form-inputs/ # Form inputs
|
||||
│ └── form-item/ # Form item
|
||||
│ └── feedback.tsx # Validation error rendering
|
||||
├── hooks/
|
||||
│ ├── index.ts
|
||||
│ ├── use-editor-props.tsx # Editor props hook
|
||||
│ ├── use-is-sidebar.ts # Sidebar state hook
|
||||
│ ├── use-node-render-context.ts # Node render context hook
|
||||
│ └── use-port-click.ts # Port click hook
|
||||
├── nodes/ # Node definitions
|
||||
│ ├── index.ts
|
||||
│ ├── constants.ts # Node constants
|
||||
│ ├── default-form-meta.ts # Default form metadata
|
||||
│ ├── block-end/ # Block end node
|
||||
│ ├── block-start/ # Block start node
|
||||
│ ├── break/ # Break node
|
||||
│ ├── code/ # Code node
|
||||
│ ├── comment/ # Comment node
|
||||
│ ├── condition/ # Condition node
|
||||
│ ├── continue/ # Continue node
|
||||
│ ├── end/ # End node
|
||||
│ ├── group/ # Group node
|
||||
│ ├── http/ # HTTP node
|
||||
│ ├── llm/ # LLM node
|
||||
│ ├── loop/ # Loop node
|
||||
│ ├── start/ # Start node
|
||||
│ └── variable/ # Variable node
|
||||
├── plugins/ # Plugin system
|
||||
│ ├── index.ts
|
||||
│ ├── context-menu-plugin/ # Right-click context menu plugin
|
||||
│ ├── runtime-plugin/ # Runtime plugin
|
||||
│ │ ├── client/ # Client
|
||||
│ │ │ ├── browser-client/ # Browser client
|
||||
│ │ │ └── server-client/ # Server client
|
||||
│ │ └── runtime-service/ # Runtime service
|
||||
│ └── variable-panel-plugin/ # Variable panel plugin
|
||||
│ └── components/ # Variable panel components
|
||||
├── services/ # Service layer
|
||||
│ ├── index.ts
|
||||
│ └── custom-service.ts # Custom service
|
||||
├── shortcuts/ # Shortcuts system
|
||||
│ ├── index.ts
|
||||
│ ├── constants.ts # Shortcut constants
|
||||
│ ├── shortcuts.ts # Shortcut definitions
|
||||
│ ├── type.ts # Type definitions
|
||||
│ ├── collapse/ # Collapse shortcut
|
||||
│ ├── copy/ # Copy shortcut
|
||||
│ ├── delete/ # Delete shortcut
|
||||
│ ├── expand/ # Expand shortcut
|
||||
│ ├── paste/ # Paste shortcut
|
||||
│ ├── select-all/ # Select-all shortcut
|
||||
│ ├── zoom-in/ # Zoom-in shortcut
|
||||
│ └── zoom-out/ # Zoom-out shortcut
|
||||
├── styles/ # Styles
|
||||
├── typings/ # Type definitions
|
||||
│ ├── index.ts
|
||||
│ ├── json-schema.ts # JSON Schema types
|
||||
│ └── node.ts # Node type definitions
|
||||
└── utils/ # Utility functions
|
||||
├── index.ts
|
||||
└── on-drag-line-end.ts # Handle end of drag line
|
||||
```
|
||||
|
||||
### Key Directory Functions
|
||||
|
||||
#### 1. `/components` - Component Library
|
||||
- **base-node**: Base rendering components for all nodes
|
||||
- **testrun**: Complete test-run module, including status bar, form, and panel
|
||||
- **sidebar**: Sidebar components providing tools and property panels
|
||||
- **node-panel**: Node add panel with drag-to-add capability
|
||||
|
||||
#### 2. `/nodes` - Node System
|
||||
Each node type has its own directory, including:
|
||||
- Node registration (`index.ts`)
|
||||
- Form metadata (`form-meta.ts`)
|
||||
- Node-specific components and logic
|
||||
|
||||
#### 3. `/plugins` - Plugin System
|
||||
- **runtime-plugin**: Supports both browser and server modes
|
||||
- **context-menu-plugin**: Right-click context menu
|
||||
- **variable-panel-plugin**: Variable management panel
|
||||
|
||||
#### 4. `/shortcuts` - Shortcuts System
|
||||
Complete keyboard shortcut support, including:
|
||||
- Basic actions: copy, paste, delete, select-all
|
||||
- View actions: zoom-in, zoom-out, collapse, expand
|
||||
- Each shortcut has its own implementation module
|
||||
|
||||
## Application Architecture
|
||||
|
||||
### Core Design Patterns
|
||||
|
||||
#### 1. Plugin Architecture
|
||||
Highly modular plugin system; each feature is an independent plugin:
|
||||
|
||||
```typescript
|
||||
plugins: () => [
|
||||
createFreeLinesPlugin({ renderInsideLine: LineAddButton }),
|
||||
createMinimapPlugin({ /* config */ }),
|
||||
createFreeSnapPlugin({ /* alignment config */ }),
|
||||
createFreeNodePanelPlugin({ renderer: NodePanel }),
|
||||
createContainerNodePlugin({}),
|
||||
createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),
|
||||
createContextMenuPlugin({}),
|
||||
createRuntimePlugin({ mode: 'browser' }),
|
||||
createVariablePanelPlugin({})
|
||||
]
|
||||
```
|
||||
|
||||
#### 2. Node Registry Pattern
|
||||
Manage different workflow node types via a registry:
|
||||
|
||||
```typescript
|
||||
export const nodeRegistries: FlowNodeRegistry[] = [
|
||||
ConditionNodeRegistry, // Condition node
|
||||
StartNodeRegistry, // Start node
|
||||
EndNodeRegistry, // End node
|
||||
LLMNodeRegistry, // LLM node
|
||||
LoopNodeRegistry, // Loop node
|
||||
CommentNodeRegistry, // Comment node
|
||||
HTTPNodeRegistry, // HTTP node
|
||||
CodeNodeRegistry, // Code node
|
||||
// ... more node types
|
||||
];
|
||||
```
|
||||
|
||||
#### 3. Dependency Injection
|
||||
Use Inversify for service DI:
|
||||
|
||||
```typescript
|
||||
onBind: ({ bind }) => {
|
||||
bind(CustomService).toSelf().inSingletonScope();
|
||||
}
|
||||
```
|
||||
## Core Features
|
||||
|
||||
### 1. Editor Configuration System
|
||||
|
||||
`useEditorProps` is the configuration center of the editor:
|
||||
|
||||
```typescript
|
||||
export function useEditorProps(
|
||||
initialData: FlowDocumentJSON,
|
||||
nodeRegistries: FlowNodeRegistry[]
|
||||
): FreeLayoutProps {
|
||||
return useMemo<FreeLayoutProps>(() => ({
|
||||
background: true, // Background grid
|
||||
readonly: false, // Readonly mode
|
||||
initialData, // Initial data
|
||||
nodeRegistries, // Node registries
|
||||
|
||||
// Core feature configs
|
||||
playground: { preventGlobalGesture: true /* Prevent Mac browser swipe gestures */ },
|
||||
nodeEngine: { enable: true },
|
||||
variableEngine: { enable: true },
|
||||
history: { enable: true, enableChangeNode: true },
|
||||
|
||||
// Business rules
|
||||
canAddLine: (ctx, fromPort, toPort) => { /* Connection rules */ },
|
||||
canDeleteLine: (ctx, line) => { /* Line deletion rules */ },
|
||||
canDeleteNode: (ctx, node) => { /* Node deletion rules */ },
|
||||
canDropToNode: (ctx, params) => { /* Drag-and-drop rules */ },
|
||||
|
||||
// Plugins
|
||||
plugins: () => [/* Plugin list */],
|
||||
|
||||
// Events
|
||||
onContentChange: debounce((ctx, event) => { /* Auto save */ }, 1000),
|
||||
onInit: (ctx) => { /* Initialization */ },
|
||||
onAllLayersRendered: (ctx) => { /* After render */ }
|
||||
}), []);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Node Type System
|
||||
|
||||
The app supports multiple workflow node types:
|
||||
|
||||
```typescript
|
||||
export enum WorkflowNodeType {
|
||||
Start = 'start', // Start node
|
||||
End = 'end', // End node
|
||||
LLM = 'llm', // Large language model node
|
||||
HTTP = 'http', // HTTP request node
|
||||
Code = 'code', // Code execution node
|
||||
Variable = 'variable', // Variable node
|
||||
Condition = 'condition', // Conditional node
|
||||
Loop = 'loop', // Loop node
|
||||
BlockStart = 'block-start', // Sub-canvas start node
|
||||
BlockEnd = 'block-end', // Sub-canvas end node
|
||||
Comment = 'comment', // Comment node
|
||||
Continue = 'continue', // Continue node
|
||||
Break = 'break', // Break node
|
||||
}
|
||||
```
|
||||
|
||||
Each node follows a unified registration pattern:
|
||||
|
||||
```typescript
|
||||
export const StartNodeRegistry: FlowNodeRegistry = {
|
||||
type: WorkflowNodeType.Start,
|
||||
meta: {
|
||||
isStart: true,
|
||||
deleteDisable: true, // Not deletable
|
||||
copyDisable: true, // Not copyable
|
||||
nodePanelVisible: false, // Hidden in node panel
|
||||
defaultPorts: [{ type: 'output' }],
|
||||
size: { width: 360, height: 211 }
|
||||
},
|
||||
info: {
|
||||
icon: iconStart,
|
||||
description: 'The starting node of the workflow, used to set up information needed to launch the workflow.'
|
||||
},
|
||||
formMeta, // Form configuration
|
||||
canAdd() { return false; } // Disallow multiple start nodes
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Plugin Architecture
|
||||
|
||||
App features are modularized via the plugin system:
|
||||
|
||||
#### Core Plugin List
|
||||
1. **FreeLinesPlugin** - Connection rendering and interaction
|
||||
2. **MinimapPlugin** - Minimap navigation
|
||||
3. **FreeSnapPlugin** - Auto-alignment and guide-lines
|
||||
4. **FreeNodePanelPlugin** - Node add panel
|
||||
5. **ContainerNodePlugin** - Container nodes (e.g., loop nodes)
|
||||
6. **FreeGroupPlugin** - Node grouping
|
||||
7. **ContextMenuPlugin** - Right-click context menu
|
||||
8. **RuntimePlugin** - Workflow runtime
|
||||
9. **VariablePanelPlugin** - Variable management panel
|
||||
|
||||
### 4. Runtime System
|
||||
|
||||
Two run modes are supported:
|
||||
|
||||
```typescript
|
||||
createRuntimePlugin({
|
||||
mode: 'browser', // Browser mode
|
||||
// mode: 'server', // Server mode
|
||||
// serverConfig: {
|
||||
// domain: 'localhost',
|
||||
// port: 4000,
|
||||
// protocol: 'http',
|
||||
// },
|
||||
})
|
||||
```
|
||||
|
||||
## Design Philosophy and Advantages
|
||||
|
||||
### 1. Highly Modular
|
||||
- **Plugin architecture**: Each feature is an independent plugin, easy to extend and maintain
|
||||
- **Node registry system**: Add new node types without changing core code
|
||||
- **Componentized UI**: Highly reusable components with clear responsibilities
|
||||
|
||||
### 2. Type Safety
|
||||
- **Full TypeScript support**: End-to-end type safety from configuration to runtime
|
||||
- **JSON Schema integration**: Node data validated by schemas
|
||||
- **Strongly typed plugin interfaces**: Clear type constraints for plugin development
|
||||
|
||||
### 3. User Experience
|
||||
- **Real-time preview**: Run and debug workflows live
|
||||
- **Rich interactions**: Dragging, zooming, snapping, shortcuts for a complete editing experience
|
||||
- **Visual feedback**: Minimap, status indicators, line animations
|
||||
|
||||
### 4. Extensibility
|
||||
- **Open plugin system**: Third parties can easily develop custom plugins
|
||||
- **Flexible node system**: Custom node types and form configurations supported
|
||||
- **Multiple runtimes**: Both browser and server modes
|
||||
|
||||
### 5. Performance
|
||||
- **On-demand loading**: Components and plugins support lazy loading
|
||||
- **Debounce**: Performance optimizations for high-frequency operations like auto-save
|
||||
|
||||
## Technical Highlights
|
||||
|
||||
### 1. In-house Editor Framework
|
||||
Based on `@flowgram.ai/free-layout-editor`, providing:
|
||||
- Free-layout canvas system
|
||||
- Full undo/redo functionality
|
||||
- Lifecycle management for nodes and connections
|
||||
- Variable engine and expression system
|
||||
|
||||
### 2. Advanced Build Configuration
|
||||
Using Rsbuild as the build tool:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
plugins: [pluginReact(), pluginLess()],
|
||||
source: {
|
||||
entry: { index: './src/app.tsx' },
|
||||
decorators: { version: 'legacy' } // Enable decorators
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
ignoreWarnings: [/Critical dependency/] // Ignore specific warnings
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Internationalization
|
||||
Built-in multilingual support:
|
||||
|
||||
```typescript
|
||||
i18n: {
|
||||
locale: navigator.language,
|
||||
languages: {
|
||||
'zh-CN': {
|
||||
'Never Remind': '不再提示',
|
||||
'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',
|
||||
},
|
||||
'en-US': {},
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,386 @@
|
||||
# FlowGram.AI - Demo Free Layout
|
||||
|
||||
自由布局最佳实践 demo
|
||||
|
||||
## 安装
|
||||
|
||||
```shell
|
||||
npx @flowgram.ai/create-app@latest 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/export-plugin**: 下载导出插件
|
||||
- **@flowgram.ai/free-container-plugin**: 子画布插件
|
||||
- **@flowgram.ai/free-group-plugin**: 分组插件
|
||||
- **@flowgram.ai/form-materials**: 表单物料
|
||||
- **@flowgram.ai/runtime-interface**: 运行时接口
|
||||
- **@flowgram.ai/runtime-js**: js 运行时模块
|
||||
- **@flowgram.ai/panel-manager-plugin**: 侧边栏面板管理
|
||||
|
||||
## 代码说明
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
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': {},
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bundler="rspack">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flow FreeLayoutEditor Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@agent-desk/flowgram-editor",
|
||||
"version": "1.0.4",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"files": [
|
||||
"src/",
|
||||
"eslint.config.js",
|
||||
".gitignore",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"rsbuild.config.ts",
|
||||
"tsconfig.json",
|
||||
"README.md",
|
||||
"README.zh_CN.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.80.0",
|
||||
"@douyinfe/semi-ui": "^2.80.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5",
|
||||
"classnames": "^2.5.1",
|
||||
"@flowgram.ai/runtime-interface": "1.0.12",
|
||||
"@flowgram.ai/free-snap-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-node-panel-plugin": "1.0.12",
|
||||
"@flowgram.ai/minimap-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-lines-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-layout-editor": "1.0.12",
|
||||
"@flowgram.ai/export-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-container-plugin": "1.0.12",
|
||||
"@flowgram.ai/free-group-plugin": "1.0.12",
|
||||
"@flowgram.ai/panel-manager-plugin": "1.0.12",
|
||||
"@flowgram.ai/form-materials": "1.0.12",
|
||||
"@flowgram.ai/free-stack-plugin": "1.0.12",
|
||||
"@flowgram.ai/runtime-js": "1.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^1.2.16",
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-less": "^1.1.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"typescript": "^5.8.3",
|
||||
"eslint": "^9.0.0",
|
||||
"cross-env": "~7.0.3",
|
||||
"@flowgram.ai/eslint-config": "1.0.12",
|
||||
"@flowgram.ai/ts-config": "1.0.12"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env MODE=app NODE_ENV=production rsbuild build",
|
||||
"build:fast": "exit 0",
|
||||
"build:watch": "exit 0",
|
||||
"build:prod": "cross-env MODE=app NODE_ENV=production rsbuild build",
|
||||
"build:analyze": "BUNDLE_ANALYZE=true rsbuild build",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
|
||||
"lint": "eslint ./src --cache",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"start": "cross-env NODE_ENV=development rsbuild dev --open",
|
||||
"test": "exit",
|
||||
"test:cov": "exit",
|
||||
"watch": "exit 0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginLess } from '@rsbuild/plugin-less';
|
||||
import { defineConfig } from '@rsbuild/core';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [pluginReact(), pluginLess()],
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/app.tsx',
|
||||
},
|
||||
/**
|
||||
* support inversify @injectable() and @inject decorators
|
||||
*/
|
||||
decorators: {
|
||||
version: 'legacy',
|
||||
},
|
||||
},
|
||||
html: {
|
||||
title: 'FlowGram Workflow Editor',
|
||||
},
|
||||
output: {
|
||||
assetPrefix: '/flowgram-editor/',
|
||||
distPath: {
|
||||
root: '../web/public/flowgram-editor',
|
||||
},
|
||||
cleanDistPath: true,
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
/**
|
||||
* ignore warnings from @coze-editor/editor/language-typescript
|
||||
*/
|
||||
ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { unstableSetCreateRoot } from '@flowgram.ai/form-materials';
|
||||
|
||||
import { Editor } from './editor';
|
||||
|
||||
/**
|
||||
* React 18/19 polyfill for form-materials
|
||||
*/
|
||||
unstableSetCreateRoot(createRoot);
|
||||
|
||||
const app = createRoot(document.getElementById('root')!);
|
||||
|
||||
app.render(<Editor />);
|
||||
@@ -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>
|
||||
);
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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 |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { useAddNode } from './use-add-node';
|
||||
|
||||
export const AddNode = (props: { disabled: boolean }) => {
|
||||
const addNode = useAddNode();
|
||||
return (
|
||||
<Button
|
||||
data-testid="demo.free-layout.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);
|
||||
}}
|
||||
>
|
||||
Add Node
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -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]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FlowNodeEntity, useClientContext, 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();
|
||||
const ctx = useClientContext();
|
||||
/**
|
||||
* 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(
|
||||
() => ctx.playground.node.querySelector('.gedit-flow-render-layer') as HTMLDivElement,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider getPopupContainer={getPopupContainer}>
|
||||
<NodeRenderContext.Provider value={nodeRender}>
|
||||
<NodeWrapper>
|
||||
{form?.state.invalid && <ErrorIcon />}
|
||||
{form?.render()}
|
||||
</NodeWrapper>
|
||||
<NodeStatusBar />
|
||||
</NodeRenderContext.Provider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
|
||||
import { useClientContext } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowNodeMeta } from '../../typings';
|
||||
import { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
|
||||
import { useNodeRenderContext, usePortClick } from '../../hooks';
|
||||
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 form = nodeRender.form;
|
||||
const ctx = useClientContext();
|
||||
const onPortClick = usePortClick();
|
||||
const meta = node.getNodeMeta<FlowNodeMeta>();
|
||||
|
||||
const { open } = useNodeFormPanel();
|
||||
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) {
|
||||
open({
|
||||
nodeId: 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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type FC, type CSSProperties, useEffect, useRef } from 'react';
|
||||
|
||||
import { usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorModel } from '../model';
|
||||
import { usePlaceholder } from '../hooks';
|
||||
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 placeholder = usePlaceholder({ model });
|
||||
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// 同步编辑器内部值变化
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
|
||||
export { CommentRender } from './render';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 {
|
||||
/** 初始化事件 */
|
||||
Init = 'init',
|
||||
/** 内容变更事件 */
|
||||
Change = 'change',
|
||||
/** 多选事件 */
|
||||
MultiSelect = 'multiSelect',
|
||||
/** 单选事件 */
|
||||
Select = 'select',
|
||||
/** 失焦事件 */
|
||||
Blur = 'blur',
|
||||
}
|
||||
|
||||
export const CommentEditorDefaultValue = '';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { useSize } from './use-size';
|
||||
export { usePlaceholder } from './use-placeholder';
|
||||
@@ -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.setInitValue(value); // 设置初始值
|
||||
model.selectEnd(); // 设置初始化光标位置
|
||||
}, [formModel, model]);
|
||||
|
||||
// 同步表单外部值变化:undo/redo/协同
|
||||
useEffect(() => {
|
||||
const disposer = formModel.onFormValuesChange(({ name }) => {
|
||||
if (name !== CommentEditorFormField.Note && name !== '') {
|
||||
return;
|
||||
}
|
||||
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
|
||||
model.setValue(value);
|
||||
});
|
||||
return () => disposer.dispose();
|
||||
}, [formModel, model]);
|
||||
|
||||
return model;
|
||||
};
|
||||
@@ -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 && params.type !== CommentEditorEvent.Init) {
|
||||
return;
|
||||
}
|
||||
updateOverflow();
|
||||
});
|
||||
return () => {
|
||||
changeDisposer.dispose();
|
||||
};
|
||||
}, [model, updateOverflow]);
|
||||
|
||||
return { overflow, updateOverflow };
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { CommentEditorModel } from '../model';
|
||||
import { CommentEditorEvent } from '../constant';
|
||||
|
||||
export const usePlaceholder = (params: { model: CommentEditorModel }): string | undefined => {
|
||||
const { model } = params;
|
||||
|
||||
const [placeholder, setPlaceholder] = useState<string | undefined>('Enter a comment...');
|
||||
|
||||
// 监听 change 事件
|
||||
useEffect(() => {
|
||||
const changeDisposer = model.on((params) => {
|
||||
if (params.type !== CommentEditorEvent.Change && params.type !== CommentEditorEvent.Init) {
|
||||
return;
|
||||
}
|
||||
if (params.value) {
|
||||
setPlaceholder(undefined);
|
||||
} else {
|
||||
setPlaceholder('Enter a comment...');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
changeDisposer.dispose();
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
return placeholder;
|
||||
};
|
||||
@@ -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 && name !== '') {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { CommentRender } from './components';
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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 setInitValue(value: string = CommentEditorDefaultValue): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (value === this.innerValue) {
|
||||
return;
|
||||
}
|
||||
this.innerValue = value;
|
||||
this.syncEditorValue();
|
||||
this.emitter.fire({
|
||||
type: CommentEditorEvent.Init,
|
||||
value: this.innerValue,
|
||||
});
|
||||
}
|
||||
|
||||
public set element(el: HTMLTextAreaElement) {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.editor = el;
|
||||
}
|
||||
|
||||
/** 获取编辑器 DOM 节点 */
|
||||
public get element(): HTMLTextAreaElement {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
interface CommentEditorInitEvent {
|
||||
type: CommentEditorEvent.Init;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type CommentEditorEventParams =
|
||||
| CommentEditorChangeEvent
|
||||
| CommentEditorMultiSelectEvent
|
||||
| CommentEditorSelectEvent
|
||||
| CommentEditorBlurEvent
|
||||
| CommentEditorInitEvent;
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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'}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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';
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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">{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}</p>
|
||||
<div
|
||||
className="space"
|
||||
style={{
|
||||
width: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<p className="close-forever" onClick={closeForever}>
|
||||
Never Remind
|
||||
</p>
|
||||
<div className="close" onClick={close}>
|
||||
<IconClose />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GroupTipsStyle>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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,
|
||||
fromPort,
|
||||
},
|
||||
});
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FC, useCallback, useState, type MouseEvent } from 'react';
|
||||
|
||||
import {
|
||||
delay,
|
||||
useClientContext,
|
||||
usePlaygroundTools,
|
||||
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 { 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 tools = usePlaygroundTools();
|
||||
|
||||
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(
|
||||
(e: React.MouseEvent) => {
|
||||
updateTitleEdit?.(true);
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
},
|
||||
[updateTitleEdit]
|
||||
);
|
||||
|
||||
const handleAutoLayout = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
tools.autoLayout({
|
||||
containerNode: node,
|
||||
enableAnimation: true,
|
||||
animationDuration: 1000,
|
||||
disableFitView: true,
|
||||
});
|
||||
},
|
||||
[tools]
|
||||
);
|
||||
|
||||
if (!visible) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger="hover"
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={handleEditTitle}>Edit Title</Dropdown.Item>
|
||||
{canMoveOut && <Dropdown.Item onClick={handleMoveOut}>Move out</Dropdown.Item>}
|
||||
<Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>
|
||||
Create Copy
|
||||
</Dropdown.Item>
|
||||
{registry.meta.isContainer && (
|
||||
<Dropdown.Item onClick={handleAutoLayout}>Auto Layout</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
onClick={handleDelete}
|
||||
disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}
|
||||
>
|
||||
Delete
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconMore />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { NodePanelRenderProps as NodePanelRenderPropsDefault } from '@flowgram.ai/free-node-panel-plugin';
|
||||
import { WorkflowPortEntity } from '@flowgram.ai/free-layout-editor';
|
||||
import { Popover } from '@douyinfe/semi-ui';
|
||||
|
||||
import { NodePlaceholder } from './node-placeholder';
|
||||
import { NodeList } from './node-list';
|
||||
import './index.less';
|
||||
|
||||
interface NodePanelRenderProps extends NodePanelRenderPropsDefault {
|
||||
panelProps?: {
|
||||
fromPort?: WorkflowPortEntity; // 从哪个端口添加 From which port to add
|
||||
enableNodePlaceholder?: boolean;
|
||||
};
|
||||
}
|
||||
export const NodePanel: React.FC<NodePanelRenderProps> = (props) => {
|
||||
const { onSelect, position, onClose, containerNode, panelProps = {} } = props;
|
||||
const { enableNodePlaceholder, fromPort } = panelProps;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
visible={true}
|
||||
onVisibleChange={(v) => (v ? null : onClose())}
|
||||
content={<NodeList onSelect={onSelect} containerNode={containerNode} fromPort={fromPort} />}
|
||||
getPopupContainer={containerNode ? () => ref.current || document.body : undefined}
|
||||
placement="right"
|
||||
popupAlign={{ offset: [30, 0] }}
|
||||
overlayStyle={{
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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,
|
||||
WorkflowPortEntity,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { canContainNode } from '../../utils';
|
||||
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={`demo-free-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'];
|
||||
fromPort?: WorkflowPortEntity; // 从哪个端口添加 From which port to add
|
||||
containerNode?: WorkflowNodeEntity;
|
||||
}
|
||||
|
||||
export const NodeList: FC<NodeListProps> = (props) => {
|
||||
const { onSelect, containerNode, fromPort } = 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,
|
||||
});
|
||||
};
|
||||
console.log('>>> fromNode', fromPort?.node);
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* 循环节点无法嵌套循环节点
|
||||
* Loop node cannot nest loop node
|
||||
*/
|
||||
if (containerNode && !canContainNode(register.type, containerNode.flowNodeType)) {
|
||||
return false;
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { ProblemButton } from './problem-panel';
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useService, WorkflowSelectService } from '@flowgram.ai/free-layout-editor';
|
||||
import { IconButton, Spin, Typography, Avatar, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconUploadError, IconClose } from '@douyinfe/semi-icons';
|
||||
|
||||
import { useProblemPanel, useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
|
||||
import { useWatchValidate } from './use-watch-validate';
|
||||
|
||||
export const ProblemPanel = () => {
|
||||
const { results, loading } = useWatchValidate();
|
||||
|
||||
const selectService = useService(WorkflowSelectService);
|
||||
|
||||
const { close: closePanel } = useProblemPanel();
|
||||
const { open: openNodeFormPanel } = useNodeFormPanel();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
background: 'rgb(251, 251, 251)',
|
||||
border: '1px solid rgba(82,100,154, 0.13)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '50px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', columnGap: '4px', height: '100%' }}>
|
||||
<Typography.Text strong>Problem</Typography.Text>
|
||||
{loading && <Spin size="small" style={{ lineHeight: '0' }} />}
|
||||
</div>
|
||||
<IconButton
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
icon={<IconClose />}
|
||||
onClick={() => closePanel()}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', rowGap: '4px' }}>
|
||||
{results.map((i) => (
|
||||
<div
|
||||
key={i.node.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
border: '1px solid #999',
|
||||
borderRadius: '4px',
|
||||
padding: '0 4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
selectService.selectNodeAndScrollToView(i.node);
|
||||
openNodeFormPanel({ nodeId: i.node.id });
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
style={{ flexShrink: '0' }}
|
||||
src={i.node.getNodeRegistry().info.icon}
|
||||
size="24px"
|
||||
shape="square"
|
||||
/>
|
||||
<div style={{ marginLeft: '8px' }}>
|
||||
<Typography.Text>{i.node.form?.values.title}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="danger">
|
||||
{i.feedbacks.map((i) => i.feedbackText).join(', ')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProblemButton = () => {
|
||||
const { open } = useProblemPanel();
|
||||
return (
|
||||
<Tooltip content="Problem">
|
||||
<IconButton
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
icon={<IconUploadError />}
|
||||
onClick={() => open()}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { debounce } from 'lodash-es';
|
||||
import { useService, WorkflowDocument } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { ValidateService, type ValidateResult } from '../../services/validate-service';
|
||||
|
||||
const DEBOUNCE_TIME = 1000;
|
||||
|
||||
export const useWatchValidate = () => {
|
||||
const [results, setResults] = useState<ValidateResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validateService = useService(ValidateService);
|
||||
const workflowDocument = useService(WorkflowDocument);
|
||||
|
||||
const debounceValidate = useCallback(
|
||||
debounce(async () => {
|
||||
const res = await validateService.validateNodes();
|
||||
validateService.validateLines();
|
||||
setResults(res);
|
||||
setLoading(false);
|
||||
}, DEBOUNCE_TIME),
|
||||
[validateService]
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
setLoading(true);
|
||||
debounceValidate();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
const disposable = workflowDocument.onContentChange(() => {
|
||||
validate();
|
||||
});
|
||||
return () => disposable.dispose();
|
||||
}, []);
|
||||
|
||||
return { results, loading };
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 { 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={'Collapse'}>
|
||||
<Button
|
||||
icon={<IconShrink />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onMouseDown={(e) => {
|
||||
commandRegistry.executeCommand(FlowCommandId.COLLAPSE);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={'Expand'}>
|
||||
<Button
|
||||
icon={<IconExpand />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onMouseDown={(e) => {
|
||||
commandRegistry.executeCommand(FlowCommandId.EXPAND);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={'Create Group'}>
|
||||
<Button
|
||||
icon={<IconGroup size={14} />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(WorkflowGroupCommand.Group);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={'Copy'}>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(FlowCommandId.COPY);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={'Delete'}>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
icon={<IconDeleteStroked />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(FlowCommandId.DELETE);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, startTransition } from 'react';
|
||||
|
||||
import {
|
||||
PlaygroundEntityContext,
|
||||
useRefresh,
|
||||
useClientContext,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowNodeMeta } from '../../typings';
|
||||
import { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
|
||||
import { IsSidebarContext } from '../../context';
|
||||
import { SidebarNodeRenderer } from './sidebar-node-renderer';
|
||||
|
||||
export interface NodeFormPanelProps {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
export const NodeFormPanel: React.FC<NodeFormPanelProps> = ({ nodeId }) => {
|
||||
const { selection, playground, document } = useClientContext();
|
||||
const refresh = useRefresh();
|
||||
const { close: closePanel } = useNodeFormPanel();
|
||||
const handleClose = useCallback(() => {
|
||||
// Sidebar delayed closing
|
||||
startTransition(() => {
|
||||
closePanel();
|
||||
});
|
||||
}, []);
|
||||
const node = document.getNode(nodeId);
|
||||
const sidebarDisabled = node?.getNodeMeta<FlowNodeMeta>()?.sidebarDisabled === true;
|
||||
/**
|
||||
* 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, node, handleClose]);
|
||||
/**
|
||||
* Close when node disposed
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
const toDispose = node.onDispose(() => {
|
||||
closePanel();
|
||||
});
|
||||
return () => toDispose.dispose();
|
||||
}
|
||||
return () => {};
|
||||
}, [node, sidebarDisabled, handleClose]);
|
||||
/**
|
||||
* Cloze when sidebar disabled
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!node || sidebarDisabled || playground.config.readonly) {
|
||||
handleClose();
|
||||
}
|
||||
}, [node, sidebarDisabled, playground.config.readonly]);
|
||||
|
||||
if (!node || sidebarDisabled || playground.config.readonly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IsSidebarContext.Provider value={true}>
|
||||
<PlaygroundEntityContext.Provider key={node.id} value={node}>
|
||||
<SidebarNodeRenderer node={node} />
|
||||
</PlaygroundEntityContext.Provider>
|
||||
</IsSidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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: '100%',
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(82,100,154, 0.13)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{nodeRender.form?.render()}
|
||||
</div>
|
||||
</NodeRenderContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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) => {
|
||||
const currentValue = values[meta.name] ?? meta.defaultValue;
|
||||
|
||||
const handleChange = (newValue: unknown): void => {
|
||||
setValues({
|
||||
...values,
|
||||
[meta.name]: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...meta,
|
||||
value: currentValue,
|
||||
onChange: handleChange,
|
||||
};
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { 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 DEFAULT_DECLARE: IJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
};
|
||||
|
||||
export const useFormMeta = (): TestRunFormMetaItem[] => {
|
||||
const document = useService(WorkflowDocument);
|
||||
|
||||
const startNode = useMemo(
|
||||
() => document.root.blocks.find((node) => node.flowNodeType === WorkflowNodeType.Start),
|
||||
[document]
|
||||
);
|
||||
|
||||
const workflowInputs = startNode?.form?.getValueIn<IJsonSchema>('outputs') || DEFAULT_DECLARE;
|
||||
|
||||
// Add state for form values
|
||||
const formMeta = useMemo(() => {
|
||||
const formFields: TestRunFormMetaItem[] = [];
|
||||
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;
|
||||
}, [workflowInputs]);
|
||||
|
||||
return formMeta;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { TestRunFormMeta, TestRunFormMetaItem } from '../testrun-form/type';
|
||||
|
||||
const getDefaultValue = (meta: TestRunFormMetaItem) => {
|
||||
if (['object', 'array', 'map'].includes(meta.type) && typeof meta.defaultValue === 'string') {
|
||||
return JSON.parse(meta.defaultValue);
|
||||
}
|
||||
return meta.defaultValue;
|
||||
};
|
||||
|
||||
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]: getDefaultValue(meta) };
|
||||
}
|
||||
});
|
||||
setValues({
|
||||
...values,
|
||||
...formMetaValues,
|
||||
});
|
||||
}, [formMeta]);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { JsonCodeEditor } from '@flowgram.ai/form-materials';
|
||||
|
||||
export function JsonValueEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, unknown>;
|
||||
onChange: (value: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const defaultJsonText = useMemo(() => JSON.stringify(value, null, 2), [value]);
|
||||
|
||||
const [jsonText, setJsonText] = useState(defaultJsonText);
|
||||
|
||||
const effectVersion = useRef(0);
|
||||
const changeVersion = useRef(0);
|
||||
|
||||
const handleJsonTextChange = (text: string) => {
|
||||
setJsonText(text);
|
||||
try {
|
||||
const jsonValue = JSON.parse(text);
|
||||
onChange(jsonValue);
|
||||
changeVersion.current++;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// more effect compared with change
|
||||
effectVersion.current = effectVersion.current + 1;
|
||||
if (effectVersion.current === changeVersion.current) {
|
||||
return;
|
||||
}
|
||||
effectVersion.current = changeVersion.current;
|
||||
|
||||
setJsonText(JSON.stringify(value, null, 2));
|
||||
}, [value]);
|
||||
|
||||
return <JsonCodeEditor value={jsonText} onChange={handleJsonTextChange} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
&-expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
&-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||