feat: init flowgram.ai

Co-Authored-By: xiamidaxia <xiamidaxia@icloud.com>
This commit is contained in:
dragooncjw 2025-02-17 16:10:48 +08:00
commit d7bdf8a078
1696 changed files with 138786 additions and 0 deletions

14
.gitattributes vendored Normal file
View File

@ -0,0 +1,14 @@
# Don't allow people to merge changes to these generated files, because the result
# may be invalid. You need to run "rush update" again.
pnpm-lock.yaml merge=ours
shrinkwrap.yaml merge=binary
npm-shrinkwrap.json merge=binary
yarn.lock merge=binary
# Rush's JSON config files use JavaScript-style code comments. The rule below prevents pedantic
# syntax highlighters such as GitHub's from highlighting these comments as errors. Your text editor
# may also require a special configuration to allow comments in JSON.
#
# For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088
#
*.json linguist-language=JSON-with-Comments

7
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,7 @@
# 文件路径与代码负责人分配
# 对整个仓库设置代码负责人
* @xukai.luics @wencheng.lwc
# 对特定目录设置代码负责人
/common/ @wencheng.lwc @chenjiawei.inizio
/config/ @wencheng.lwc @chenjiawei.inizio

32
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Config Git User
run: |
git config --local user.name "chenjiawei.inizio"
git config --local user.email "chenjiawei.inizio@bytedance.com"
- name: For Debug
run: |
echo "Listing files in the root directory:"
ls -alh
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Verify Change Logs
run: node common/scripts/install-run-rush.js change --verify
- name: Rush Install
run: node common/scripts/install-run-rush.js install
- name: Rush build
run: node common/scripts/install-run-rush.js build
# - name: Test (coverage)
# run: node common/scripts/install-run-rush.js test:cov -v

63
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,63 @@
name: Publish
on:
workflow_dispatch
concurrency:
group: "main-branch-workflow" # 唯一标识符,确保只运行一个实例
cancel-in-progress: false # 不取消正在运行的实例,后续触发需要等待当前实例完成
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Set up npm token
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
run: echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
- name: Debug Auth
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
run: |
npm whoami
- name: Config Git User
run: |
git config --local user.name "chenjiawei.inizio"
git config --local user.email "chenjiawei.inizio@bytedance.com"
- name: Get latest npm version
id: get_version
run: |
LATEST_VERSION=$(npm view @flowgram.ai/core version --tag=latest latest)
echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_ENV
echo "::set-output name=version::$LATEST_VERSION"
- name: Echo version
run: |
echo "The package version is : $LATEST_VERSION"
echo "The package output version is ${{ steps.get_version.outputs.version }}"
- uses: actions/setup-node@v3
with:
node-version: 18
registry-url: 'https://registry.npmjs.org/'
- name: Rush Install
run: node common/scripts/install-run-rush.js install
- name: Rush build
run: node common/scripts/install-run-rush.js build
# version bump 之前保证是远端最新的,这样无需 commit package.json version
- name: Sync versions
run: |
echo "[
{
\"policyName\": \"publishPolicy\",
\"definitionName\": \"lockStepVersion\",
\"version\": \"$LATEST_VERSION\",
\"nextBump\": \"patch\"
}
]" > common/config/rush/version-policies.json
- name: Version Bump
run: node common/scripts/install-run-rush.js version --bump
- name: Publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
run: node common/scripts/install-run-rush.js publish --include-all -p --tag latest

125
.gitignore vendored Normal file
View File

@ -0,0 +1,125 @@
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov/
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output/
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt/
# Bower dependency directory (https://bower.io/)
bower_components/
# node-waf configuration
.lock-wscript/
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release/
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm/
# Optional eslint cache
.eslintcache/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# next.js build output
.next/
# Docusaurus cache and generated files
.docusaurus/
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# yarn v2
.yarn/cache/
.yarn/unplugged/
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# OS X temporary files
.DS_Store
# IntelliJ IDEA project files; if you want to commit IntelliJ settings, this recipe may be helpful:
# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
.idea/
*.iml
# Visual Studio Code
.vscode/
!.vscode/tasks.json
!.vscode/launch.json
# Rush temporary files
common/deploy/
common/temp/
common/autoinstallers/*/.npmrc
**/.rush/temp/
*.lock
# Common toolchain intermediate files
temp/
lib/
lib-amd/
lib-es6/
lib-esnext/
lib-commonjs/
lib-shim/
dist/
dist-storybook/
*.tsbuildinfo
# Heft temporary files
.cache/
.heft/
# rush standard files
.eslintcache

108
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,108 @@
# Contributing to flowgram.ai
## Quick Start
### Prerequisites
- Node.js 18+ (LTS/Hydrogen recommended)
- pnpm 9.12.0
- Rush 5.14.0
### Installation
1. **Install Node.js 18+**
``` bash
nvm install lts/hydrogen
nvm alias default lts/hydrogen # set default node version
nvm use lts/hydrogen
```
2. **Clone the repository**
``` bash
git clone git@github.com:coze-dev/flowgram.ai.git
```
3. **Install required global dependencies**
``` bash
npm i -g pnpm@9.12.0 @microsoft/rush@5.14.0
```
4. **Install project dependencies**
``` bash
rush update
```
5. **Build the project**
``` bash
rush build
```
6. **Run docs or demo**
``` bash
rush dev:docs # docs
rush dev:demo-fixed-layout
rush dev:demo-free-layout
```
After that, you can start to develop projects inside this repository.
## Submitting Changes
1. Create a new branch from `main` using the format:
- `feat/description` for features
- `fix/description` for bug fixes
- `docs/description` for documentation
- `chore/description` for maintenance
2. Write code and tests
- Follow our coding standards
- Add/update tests for changes
- Update documentation if needed
3. Ensure quality
- Run `cd path/to/packageName && npm test` for all tests
- Run `rush lint` for code style
- Run `rush build` to verify build
4. Create Pull Request
- Use the PR template
- Link related issues
- Provide clear description of changes
5. Review Process
- Maintainers will review your PR
- Address review feedback if any
- Changes must pass CI checks
6. Commit Message Format
```
type(scope): subject
body
```
Types: feat, fix, docs, style, refactor, test, chore
## Reporting Bugs
Report bugs via [GitHub Issues](https://github.com/coze-dev/flowgram.ai/issues/new/choose). Please include:
- Issue description
- Steps to reproduce
- Expected behavior
- Actual behavior
- Code examples (if applicable)
## Documentation
- Update API documentation for interface changes
- Update README.md if usage is affected
## License
This project is under the [MIT License](http://choosealicense.com/licenses/mit/). By submitting code, you agree to these terms.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Spring (SG) Pte. Ltd.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

124
README.md Normal file
View File

@ -0,0 +1,124 @@
# FlowGram.AI
FlowGram is a node-based flow building engine that helps developers quickly create workflows in either fixed layout or
free connection layout modes. It provides a set of interaction best practices and is particularly suitable for visual
workflows with clear inputs and outputs.
In the current AI boom, we are also focusing on how to empower workflows with AI capabilities, hence the AI suffix in
our name.
<div align="center">
[![License](https://img.shields.io/github/license/coze-dev/flowgram.ai)](https://github.com/coze-dev/flowgram.ai/blob/main/LICENSE)
[![@flowgram.ai/editor](https://img.shields.io/npm/dw/%40flowgram.ai%2Feditor
)](https://www.npmjs.com/package/@flowgram.ai/editor)
</div>
## 📖 Documentation
- [Official Documentation](https://coze-dev.github.io/flowgram.ai/)
- [Contributing Guidelines](https://github.com/coze-dev/flowgram.ai/blob/main/CONTRIBUTING.md)
## 📦 Packages
| Package | Description | Version |
|---------------------------------------------------------------------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| [@flowgram.ai/create-app](./apps/create-app) | App Creator | [![npm](https://img.shields.io/npm/v/@flowgram.ai/create-app.svg)](https://www.npmjs.com/package/@flowgram.ai/create-app) |
| [@flowgram.ai/fixed-layout-editor](./packages/client/fixed-layout-editor) | Fixed Layout Editor | [![npm](https://img.shields.io/npm/v/@flowgram.ai/fixed-layout-editor.svg)](https://www.npmjs.com/package/@flowgram.ai/fixed-layout-editor) |
| [@flowgram.ai/free-layout-editor](./packages/client/free-layout-editor) | Free Layout Editor | [![npm](https://img.shields.io/npm/v/@flowgram.ai/free-layout-editor.svg)](https://www.npmjs.com/package/@flowgram.ai/free-layout-editor) |
## 🎮 Examples
<div>
<p>
<a href="https://coze-dev.github.io/flowgram.ai/examples/fixed-layout/fixed-feature-overview.html">
Fixed Layout
</a>
</p>
<p>
Fixed layout where nodes can be dragged to specified positions, with support for compound nodes like branches and loops.
</p>
<p>
<img src="./apps/docs/src/public/fixed-layout/fixed-layout-demo.gif"/>
</p>
</div>
<div>
<p>
<a href="https://coze-dev.github.io/flowgram.ai/examples/free-layout/free-feature-overview.html">
Free Layout
</a>
</p>
<p>
Free layout where nodes can be moved anywhere and connected through free-form lines.
</p>
<p>
<img src="./apps/docs/src/public/free-layout/free-layout-demo.gif"/>
</p>
</div>
## 🚀 Getting Started
```sh
# create demo
npx @flowgram.ai/create-app@latest
# select demo
- fixed-layout # full-feature overview
- free-layout # full-feature overview
- fixed-layout-simple # basic usage
- free-layout-simple # basic usage
```
## 🔨 Development
1. **Install Node.js 18+**
``` bash
nvm install lts/hydrogen
nvm alias default lts/hydrogen # set default node version
nvm use lts/hydrogen
```
2. **Clone the repository**
``` bash
git clone git@github.com:coze-dev/flowgram.ai.git
```
3. **Install required global dependencies**
``` bash
npm i -g pnpm@9.12.0 @microsoft/rush@5.140.0
```
4. **Install project dependencies**
``` bash
rush update
```
5. **Build the project**
``` bash
rush build
```
6. **Run docs or demo**
``` bash
rush dev:docs # docs
rush dev:demo-fixed-layout
rush dev:demo-free-layout
```
After that, you can start to develop projects inside this repository.
Enjoy it!
## 🌟 Contributors
[![FlowGram.AI Contributors](https://contrib.rocks/image?repo=coze-dev/flowgram.ai)](https://github.com/coze-dev/flowgram.ai/graphs/contributors)
<!-- ## Star History
[![Star History](https://api.star-history.com/svg?repos=coze-dev/flowgram.ai&type=Timeline)](https://api.star-history.com/svg?repos=coze-dev/flowgram.ai&type=Timeline) -->

View File

@ -0,0 +1,74 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
requireConfigFile: false,
babelOptions: {
babelrc: false,
configFile: false,
cwd: __dirname,
},
},
ignorePatterns: [
'**/*.d.ts',
'**/__mocks__',
'**/node_modules',
'**/build',
'**/dist',
'**/es',
'**/lib',
'**/.codebase',
'**/.changeset',
'**/config',
'**/common/scripts',
'**/output',
'error-log-str.js',
'*.bundle.js',
'*.min.js',
'*.js.map',
'**/output',
'**/*.log',
'**/tsconfig.tsbuildinfo',
'**/vitest.config.ts',
'package.json',
'*.json',
],
rules: {
'no-console': 'off',
'react/no-deprecated': 'off',
'import/prefer-default-export': 'off',
'lines-between-class-members': 'warn',
'react/jsx-no-useless-fragment': 'off',
'no-unused-vars': 'off',
'no-redeclare': 'off',
'no-empty-fuNction': 'off',
'prefer-destructurin': 'off',
'no-underscore-dangle': 'off',
'no-empty-function': 'off',
'no-multi-assign': 'off',
'arrow-body-style': 'warn',
'no-useless-constructor': 'off',
'no-param-reassign': 'off',
'max-classes-per-file': 'off',
'grouped-accessor-pairs': 'off',
'no-plusplus': 'off',
'no-restricted-syntax': 'off',
'react/destructuring-assignment': 'off',
'import/extensions': 'off',
'consistent-return': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'no-use-before-define': 'off',
'no-bitwise': 'off',
'no-case-declarations': 'off',
'react/no-array-index-key': 'off',
'react/require-default-props': 'off',
'no-dupe-class-members': 'off',
'react/jsx-props-no-spreading': 'off',
'no-console': 'off',
'no-shadow': 'off',
'class-methods-use-this': 'off',
'default-param-last': 'off',
'no-unused-vars': 'off',
'import/prefer-default-export': 'off',
'import/extensions': 'off',
},
}

2
apps/create-app/bin/index.js Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js';

View File

@ -0,0 +1,40 @@
{
"name": "@flowgram.ai/create-app",
"version": "0.1.1",
"description": "A CLI tool to create demo projects",
"bin": {
"create-app": "./bin/index.js"
},
"type": "module",
"files": [
"bin",
"src",
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --out-dir dist",
"start": "node bin/index.js"
},
"dependencies": {
"fs-extra": "^9.1.0",
"commander": "^11.0.0",
"chalk": "^5.3.0",
"download": "8.0.0",
"tar": "7.4.3",
"inquirer": "^9.2.7"
},
"devDependencies": {
"@types/download": "8.0.5",
"@types/fs-extra": "11.0.4",
"@types/node": "^18",
"@types/inquirer": "9.0.7",
"tsup": "^8.0.1",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.10.0",
"typescript": "^5.0.4"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

View File

@ -0,0 +1,103 @@
import path from 'path';
import { execSync } from 'child_process';
import inquirer from 'inquirer';
import fs from 'fs-extra';
import { Command } from 'commander';
import chalk from 'chalk';
import download from 'download';
import * as tar from 'tar';
const program = new Command();
const args = process.argv.slice(2);
program
.version('1.0.0')
.description('Create a demo project')
.action(async () => {
console.log(chalk.green('Welcome to @flowgram.ai/create-app CLI!'));
const latest = execSync('npm view @flowgram.ai/core version --tag=latest latest').toString().trim();
let folderName = ''
if (!args?.length) {
// 询问用户选择 demo 项目
const { repo } = await inquirer.prompt([
{
type: 'list',
name: 'repo',
message: 'Select a demo to create:',
choices: [
{ name: 'Fixed Layout Demo', value: 'demo-fixed-layout' },
{ name: 'Free Layout Demo', value: 'demo-free-layout' },
{ name: 'Fixed Layout Demo Simple', value: 'demo-fixed-layout-simple' },
{ name: 'Free Layout Demo Simple', value: 'demo-free-layout-simple' }
],
},
]);
folderName = repo;
} else {
if (['demo-fixed-layout', 'demo-free-layout', 'demo-fixed-layout-simple', 'demo-free-layout-simple'].includes(args[0])) {
folderName = args[0];
} else {
console.error('Invalid argument. Please run "npx create-app" to choose demo.');
return;
}
}
try {
const targetDir = path.join(process.cwd());
// 下载 npm 包的 tarball
const downloadPackage = async () => {
try {
// 从 npm registry 下载 tarball 文件
const tarballBuffer = await download(`https://registry.npmjs.org/@flowgram.ai/${folderName}/-/${folderName}-${latest}.tgz`);
// 确保目标文件夹存在
fs.ensureDirSync(targetDir);
// 创建一个临时文件名来保存 tarball 数据
const tempTarballPath = path.join(process.cwd(), `${folderName}.tgz`);
// 将下载的 tarball 写入临时文件
fs.writeFileSync(tempTarballPath, tarballBuffer);
// 解压 tarball 文件到目标文件夹
await tar.x({
file: tempTarballPath,
C: targetDir,
});
fs.renameSync(path.join(targetDir, 'package'), path.join(targetDir, folderName))
// 删除下载的 tarball 文件
fs.unlinkSync(tempTarballPath);
return true;
} catch (error) {
console.error(`Error downloading or extracting package: ${error}`);
return false;
}
};
const res = await downloadPackage();
if (res) {
// 克隆项目
console.log(chalk.green(`${folderName} Demo project created successfully!`));
console.log(chalk.yellow('Run the following commands to start:'));
console.log(chalk.cyan(` cd ${folderName}`));
console.log(chalk.cyan(' npm install'));
console.log(chalk.cyan(' npm start'));
} else {
console.log(chalk.red('Download failed'))
}
} catch (error) {
console.error('Error downloading repo:', error);
return;
}
});
program.parse(process.argv);

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"target": "es2020",
"module": "esnext",
"strictPropertyInitialization": false,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"allowJs": true,
"resolveJsonModule": true,
"types": ["node"],
"jsx": "react-jsx",
"lib": ["es6", "dom", "es2020", "es2019.Array"]
},
"include": ["./src"],
}

View File

@ -0,0 +1,74 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
requireConfigFile: false,
babelOptions: {
babelrc: false,
configFile: false,
cwd: __dirname,
},
},
ignorePatterns: [
'**/*.d.ts',
'**/__mocks__',
'**/node_modules',
'**/build',
'**/dist',
'**/es',
'**/lib',
'**/.codebase',
'**/.changeset',
'**/config',
'**/common/scripts',
'**/output',
'error-log-str.js',
'*.bundle.js',
'*.min.js',
'*.js.map',
'**/output',
'**/*.log',
'**/tsconfig.tsbuildinfo',
'**/vitest.config.ts',
'package.json',
'*.json',
],
rules: {
'no-console': 'off',
'react/no-deprecated': 'off',
'import/prefer-default-export': 'off',
'lines-between-class-members': 'warn',
'react/jsx-no-useless-fragment': 'off',
'no-unused-vars': 'off',
'no-redeclare': 'off',
'no-empty-fuNction': 'off',
'prefer-destructurin': 'off',
'no-underscore-dangle': 'off',
'no-empty-function': 'off',
'no-multi-assign': 'off',
'arrow-body-style': 'warn',
'no-useless-constructor': 'off',
'no-param-reassign': 'off',
'max-classes-per-file': 'off',
'grouped-accessor-pairs': 'off',
'no-plusplus': 'off',
'no-restricted-syntax': 'off',
'react/destructuring-assignment': 'off',
'import/extensions': 'off',
'consistent-return': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'no-use-before-define': 'off',
'no-bitwise': 'off',
'no-case-declarations': 'off',
'react/no-array-index-key': 'off',
'react/require-default-props': 'off',
'no-dupe-class-members': 'off',
'react/jsx-props-no-spreading': 'off',
'no-console': 'off',
'no-shadow': 'off',
'class-methods-use-this': 'off',
'default-param-last': 'off',
'no-unused-vars': 'off',
'import/prefer-default-export': 'off',
'import/extensions': 'off',
},
}

View File

@ -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 FixedLayoutEditor Demo</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,54 @@
{
"name": "@flowgram.ai/demo-fixed-layout-simple",
"version": "0.1.0",
"description": "",
"keywords": [],
"license": "MIT",
"main": "./src/index.ts",
"files": [
"src/",
".eslintrc.js",
".gitignore",
"index.html",
"package.json",
"rspack.config.js",
"tsconfig.json"
],
"scripts": {
"build": "exit 0",
"build:fast": "exit 0",
"build:watch": "exit 0",
"clean": "rimraf dist",
"dev": "MODE=app NODE_ENV=development rspack serve",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix",
"start": "NODE_ENV=development rspack serve",
"test": "exit",
"test:cov": "exit",
"watch": "exit 0"
},
"dependencies": {
"@douyinfe/semi-icons": "^2.72.3",
"@douyinfe/semi-ui": "^2.72.3",
"@flowgram.ai/fixed-layout-editor": "workspace:*",
"@flowgram.ai/fixed-semi-materials": "workspace:*",
"@flowgram.ai/minimap-plugin": "workspace:*",
"nanoid": "^4.0.2",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@rspack/cli": "0.2.1",
"@types/node": "^18",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.54.0",
"less": "^4.1.2",
"less-loader": "^6"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

View File

@ -0,0 +1,64 @@
const path = require('path');
const isCI = process.env.CI === 'true';
const isSCM = !!process.env.BUILD_BRANCH;
const isProd = process.env.NODE_ENV === 'production';
/**
* @type {import('@rspack/cli').Configuration}
*/
module.exports = {
mode: process.env.NODE_ENV,
context: __dirname,
target: ['web'],
entry: {
main: './src/app.tsx',
},
resolve: {
alias: {
react: require.resolve('react'),
'react-dom': require.resolve('react-dom'),
},
},
builtins: {
// https://www.rspack.dev/config/builtins.html#builtinshtml
html: [
{
template: './index.html',
},
],
progress: !isSCM ? {} : false,
treeShaking: isProd,
},
module: {
// https://www.rspack.dev/config/module.html#rule
rules: [
{
test: /\.(png|gif|jpg|jpeg|svg|woff2)$/,
type: 'asset',
},
{
test: /\.(less|css)$/,
use: [
{
loader: 'less-loader',
options: {
// ...
},
},
],
type: 'css',
},
],
},
plugins: [],
/** module is too large now, we may need better way to tackle this in the future */
stats: isCI
? { all: false, modules: true, assets: true, chunks: true, warnings: true, errors: true }
: {
modules: false,
all: false,
warnings: false,
errors: true,
timings: true,
},
};

View File

@ -0,0 +1,5 @@
import ReactDOM from 'react-dom';
import { Editor } from './editor'
ReactDOM.render(<Editor />, document.getElementById('root'));

View File

@ -0,0 +1,32 @@
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/fixed-layout-editor';
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
/**
* Provides methods related to node rendering
*
*/
const nodeRender = useNodeRender();
/**
* It can only be used when nodeEngine is enabled
* 使
*/
const form = nodeRender.form;
return (
<div
className="demo-fixed-node"
onMouseEnter={nodeRender.onMouseEnter}
onMouseLeave={nodeRender.onMouseLeave}
onMouseDown={e => {
// trigger drag node
nodeRender.startDrag(e);
e.stopPropagation();
}}
style={{
...(nodeRender.isBlockOrderIcon || nodeRender.isBlockIcon ? { width: 260 } : {}),
}}
>
{form?.render()}
</div>
);
};

View File

@ -0,0 +1,61 @@
import { type FlowNodeEntity, useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { IconPlus } from '@douyinfe/semi-icons';
import { nanoid } from 'nanoid';
interface PropsType {
activated?: boolean;
node: FlowNodeEntity;
}
export function BranchAdder(props: PropsType) {
const { activated, node } = props;
const nodeData = node.firstChild!.renderData;
const ctx = useClientContext();
const { operation, playground } = ctx;
const { isVertical } = node;
function addBranch() {
const block = operation.addBlock(node, {
id: `branch_${nanoid(5)}`,
type: 'block',
data: {
title: 'New Branch',
content: ''
}
});
setTimeout(() => {
playground.scrollToView({
bounds: block.bounds,
scrollToCenter: true,
});
}, 10);
}
if (playground.config.readonlyOrDisabled) return null;
const className = [
'demo-fixed-adder',
isVertical ? '' : 'isHorizontal',
activated ? 'activated' : ''
].join(' ');
return (
<div
className={className}
onMouseEnter={() => nodeData?.toggleMouseEnter()}
onMouseLeave={() => nodeData?.toggleMouseLeave()}
>
<div
onClick={() => {
addBranch();
}}
aria-hidden="true"
style={{ flexGrow: 1, textAlign: 'center' }}
>
<IconPlus />
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { FlowMinimapService, MinimapRender } from '@flowgram.ai/minimap-plugin';
import { useService } from '@flowgram.ai/fixed-layout-editor';
export const Minimap = () => {
const minimapService = useService(FlowMinimapService);
return (
<div
style={{
position: 'absolute',
left: 16,
bottom: 51,
zIndex: 100,
width: 182,
}}
>
<MinimapRender
service={minimapService}
containerStyles={{
pointerEvents: 'auto',
position: 'relative',
top: 'unset',
right: 'unset',
bottom: 'unset',
left: 'unset',
}}
inactiveStyle={{
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
}}
/>
</div>
);
};

View File

@ -0,0 +1,67 @@
import { FlowNodeEntity, FlowOperationService, useClientContext, usePlayground, useService } from "@flowgram.ai/fixed-layout-editor"
import { Dropdown } from '@douyinfe/semi-ui'
import { IconPlusCircle } from "@douyinfe/semi-icons";
import { nodeRegistries } from '../node-registries';
export const NodeAdder = (props: {
from: FlowNodeEntity;
to?: FlowNodeEntity;
hoverActivated: boolean;
}) => {
const { from, hoverActivated } = props;
const playground = usePlayground();
const context = useClientContext();
const flowOperationService = useService(FlowOperationService) as FlowOperationService;
const add = (addProps: any) => {
const blocks = addProps.blocks ? addProps.blocks : undefined;
const block = flowOperationService.addFromNode(from, {
...addProps,
blocks,
});
setTimeout(() => {
playground.scrollToView({
bounds: block.bounds,
scrollToCenter: true,
});
}, 10);
};
if (playground.config.readonlyOrDisabled) return null;
return (
<Dropdown
render={
<Dropdown.Menu>
{nodeRegistries.map(registry => <Dropdown.Item onClick={() => {
const props = registry?.onAdd(context, from);
add(props);
}}>{registry.type}</Dropdown.Item>)}
</Dropdown.Menu>
}
>
<div
style={{
width: hoverActivated ? 15 : 6,
height: hoverActivated ? 15 : 6,
backgroundColor: 'rgb(143, 149, 158)',
color: '#fff',
borderRadius: '50%',
cursor: 'pointer'
}}
>
{hoverActivated ?
<IconPlusCircle
style={{
color: '#3370ff',
backgroundColor: '#fff',
borderRadius: 15
}}
/> : null
}
</div>
</Dropdown>
);
}

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import { usePlaygroundTools, useClientContext } from '@flowgram.ai/fixed-layout-editor';
export function Tools() {
const { history } = useClientContext();
const tools = usePlaygroundTools();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
useEffect(() => {
const disposable = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
});
return () => disposable.dispose();
}, [history]);
return <div style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 16, display: 'flex', gap: 8 }}>
<button onClick={() => tools.zoomin()}>ZoomIn</button>
<button onClick={() => tools.zoomout()}>ZoomOut</button>
<button onClick={() => tools.fitView()}>Fitview</button>
<button onClick={() => tools.changeLayout()}>ChangeLayout</button>
<button onClick={() => history.undo()} disabled={!canUndo}>Undo</button>
<button onClick={() => history.redo()} disabled={!canRedo}>Redo</button>
<span>{Math.floor(tools.zoom * 100)}%</span>
</div>
}

View File

@ -0,0 +1,23 @@
import { FixedLayoutEditorProvider, EditorRenderer } from '@flowgram.ai/fixed-layout-editor';
import '@flowgram.ai/fixed-layout-editor/index.css';
import './index.css'
import { useEditorProps } from './hooks/use-editor-props';
import { initialData } from './initial-data'
import { nodeRegistries } from './node-registries'
import { Tools } from './components/tools'
import { Minimap } from './components/minimap'
export const Editor = () => {
const editorProps = useEditorProps(initialData, nodeRegistries);
return (
<FixedLayoutEditorProvider {...editorProps}>
<div className="demo-fixed-container">
<EditorRenderer>{/* add child panel here */}</EditorRenderer>
</div>
<Tools />
<Minimap />
</FixedLayoutEditorProvider>
);
};

View File

@ -0,0 +1,154 @@
import { useMemo } from 'react';
import { defaultFixedSemiMaterials } from '@flowgram.ai/fixed-semi-materials';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import {
type FixedLayoutProps,
FlowDocumentJSON,
FlowNodeRegistry,
FlowTextKey,
Field,
FlowRendererKey,
} from '@flowgram.ai/fixed-layout-editor';
import { BaseNode } from '../components/base-node'
import { BranchAdder } from '../components/branch-adder'
import { NodeAdder } from '../components/node-adder';
/** semi materials */
export function useEditorProps(
initialData: FlowDocumentJSON, // 初始化数据
nodeRegistries: FlowNodeRegistry[], // 节点定义
): FixedLayoutProps {
return useMemo<FixedLayoutProps>(
() => ({
/**
* Whether to enable the background
*/
background: true,
/**
* Whether it is read-only or not, the node cannot be dragged in read-only mode
*/
readonly: false,
/**
* Initial data
*
*/
initialData,
/**
*
*/
nodeRegistries,
/**
* Get the default node registry, which will be merged with the 'nodeRegistries'
* nodeRegistries
*/
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
formMeta: {
/**
* Render form
*/
render: () => <>
<Field<string> name="title">
{({ field }) => <div className="demo-fixed-node-title">{field.value}</div>}
</Field>
<div className="demo-fixed-node-content">
<Field<string> name="content">
<input />
</Field>
</div>
</>
}
};
},
/**
* Materials, components can be customized based on the key
* @see https://github.com/coze-dev/flowgram.ai/blob/main/packages/materials/fixed-semi-materials/src/components/index.tsx
* key UI
*/
materials: {
renderNodes: {
...defaultFixedSemiMaterials,
/**
* Components can be customized based on key business-side requirements.
* key
*/
[FlowRendererKey.ADDER]: NodeAdder,
[FlowRendererKey.BRANCH_ADDER]: BranchAdder,
// [FlowRendererKey.DRAG_NODE]: DragNode,
},
renderDefaultNode: BaseNode, // 节点渲染
renderTexts: {
[FlowTextKey.LOOP_END_TEXT]: 'loop end',
[FlowTextKey.LOOP_TRAVERSE_TEXT]: 'looping',
},
},
/**
* Node engine enable, you can configure formMeta in the FlowNodeRegistry
*/
nodeEngine: {
enable: true,
},
history: {
enable: true,
enableChangeNode: true, // Listen Node engine data change
onApply(ctx, opt) {
// Listen change to trigger auto save
// console.log('auto save: ', ctx.document.toJSON(), opt);
},
},
/**
*
*/
onInit: ctx => {
/**
* Data can also be dynamically loaded via fromJSON
* fromJSON
*/
// ctx.document.fromJSON(initialData)
console.log('---- Playground Init ----');
},
/**
*
*/
onDispose: () => {
console.log('---- Playground Dispose ----');
},
plugins: () => [
/**
* Minimap plugin
*
*/
createMinimapPlugin({
disableLayer: true,
enableDisplayAllNodes: true,
canvasStyle: {
canvasWidth: 182,
canvasHeight: 102,
canvasPadding: 50,
canvasBackground: 'rgba(245, 245, 245, 1)',
canvasBorderRadius: 10,
viewportBackground: 'rgba(235, 235, 235, 1)',
viewportBorderRadius: 4,
viewportBorderColor: 'rgba(201, 201, 201, 1)',
viewportBorderWidth: 1,
viewportBorderDashLength: 2,
nodeColor: 'rgba(255, 255, 255, 1)',
nodeBorderRadius: 2,
nodeBorderWidth: 0.145,
nodeBorderColor: 'rgba(6, 7, 9, 0.10)',
overlayColor: 'rgba(255, 255, 255, 0)',
},
inactiveDebounceTime: 1,
}),
],
}),
[],
);
}

View File

@ -0,0 +1,60 @@
.demo-fixed-node {
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;
transition: all 0.3s ease;
}
.demo-fixed-node-title {
background-color: #93bfe2;
width: 100%;
border-radius: 8px 8px 0 0;
padding: 4px 12px;
}
.demo-fixed-node-content {
padding: 16px;
flex-grow: 1;
width: 100%;
}
.demo-fixed-adder {
width: 28px;
height: 18px;
background: rgb(187, 191, 196);
display: flex;
border-radius: 9px;
justify-content: space-evenly;
align-items: center;
color: #fff;
font-size: 10px;
font-weight: bold;
div {
display: flex;
justify-content: center;
align-items: center;
svg {
width: 12px;
height: 12px;
}
}
}
.demo-fixed-adder.activated {
background: #82A7FC
}
.demo-fixed-adder.isHorizontal {
transform: rotate(90deg);
}
.gedit-playground * {
box-sizing: border-box;
}

View File

@ -0,0 +1 @@
export { Editor as DemoFixedLayout } from './editor';

View File

@ -0,0 +1,66 @@
import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
/**
* blocks
*/
export const initialData: FlowDocumentJSON = {
nodes: [
// 开始节点
{
id: 'start_0',
type: 'start',
data: {
title: 'Start',
content: 'start content'
},
blocks: [],
},
// 分支节点
{
id: 'condition_0',
type: 'condition',
data: {
title: 'Condition'
},
blocks: [
{
id: 'branch_0',
type: 'block',
data: {
title: 'Branch 0',
content: 'branch 1 content'
},
blocks: [
{
id: 'custom_0',
type: 'custom',
data: {
title: 'Custom',
content: 'custrom content'
},
},
],
},
{
id: 'branch_1',
type: 'block',
data: {
title: 'Branch 1',
content: 'branch 1 content'
},
blocks: [],
},
],
},
// 结束节点
{
id: 'end_0',
type: 'end',
data: {
title: 'End',
content: 'end content'
},
},
],
};

View File

@ -0,0 +1,75 @@
import { FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
import { nanoid } from 'nanoid';
/**
*
*/
export const nodeRegistries: FlowNodeRegistry[] = [
{
/**
*
*/
type: 'condition',
/**
* :
* - loop: 扩展为循环节点
* - start: 扩展为开始节点
* - dynamicSplit: 扩展为分支节点
* - end: 扩展为结束节点
* - tryCatch: 扩展为 tryCatch
* - default: ()
*/
extend: 'dynamicSplit',
/**
*
*/
meta: {
// isStart: false, // 是否为开始节点
// isNodeEnd: false, // 是否为结束节点,结束节点后边无法再添加节点
// draggable: false, // 是否可拖拽,如开始节点和结束节点无法拖拽
// selectable: false, // 触发器等开始节点不能被框选
// deleteDisable: true, // 禁止删除
// copyDisable: true, // 禁止copy
// addDisable: true, // 禁止添加
},
onAdd() {
return {
id: `condition_${nanoid(5)}`,
type: 'condition',
data: {
title: 'Condition',
},
blocks: [
{
id: nanoid(5),
type: 'block',
data: {
title: 'If_0',
},
},
{
id: nanoid(5),
type: 'block',
data: {
title: 'If_1',
},
},
],
};
},
},
{
type: 'custom',
meta: {},
onAdd() {
return {
id: `custom_${nanoid(5)}`,
type: 'custom',
data: {
title: 'Custom',
content: 'this is custom content'
}
}
}
}
];

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"rootDir": "./src",
"experimentalDecorators": true,
"target": "es2020",
"module": "esnext",
"strictPropertyInitialization": false,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"allowJs": true,
"resolveJsonModule": true,
"types": ["node"],
"jsx": "react-jsx",
"lib": ["es6", "dom", "es2020", "es2019.Array"]
},
"include": ["./src"],
}

View File

@ -0,0 +1,74 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
requireConfigFile: false,
babelOptions: {
babelrc: false,
configFile: false,
cwd: __dirname,
},
},
ignorePatterns: [
'**/*.d.ts',
'**/__mocks__',
'**/node_modules',
'**/build',
'**/dist',
'**/es',
'**/lib',
'**/.codebase',
'**/.changeset',
'**/config',
'**/common/scripts',
'**/output',
'error-log-str.js',
'*.bundle.js',
'*.min.js',
'*.js.map',
'**/output',
'**/*.log',
'**/tsconfig.tsbuildinfo',
'**/vitest.config.ts',
'package.json',
'*.json',
],
rules: {
'no-console': 'off',
'react/no-deprecated': 'off',
'import/prefer-default-export': 'off',
'lines-between-class-members': 'warn',
'react/jsx-no-useless-fragment': 'off',
'no-unused-vars': 'off',
'no-redeclare': 'off',
'no-empty-fuNction': 'off',
'prefer-destructurin': 'off',
'no-underscore-dangle': 'off',
'no-empty-function': 'off',
'no-multi-assign': 'off',
'arrow-body-style': 'warn',
'no-useless-constructor': 'off',
'no-param-reassign': 'off',
'max-classes-per-file': 'off',
'grouped-accessor-pairs': 'off',
'no-plusplus': 'off',
'no-restricted-syntax': 'off',
'react/destructuring-assignment': 'off',
'import/extensions': 'off',
'consistent-return': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'no-use-before-define': 'off',
'no-bitwise': 'off',
'no-case-declarations': 'off',
'react/no-array-index-key': 'off',
'react/require-default-props': 'off',
'no-dupe-class-members': 'off',
'react/jsx-props-no-spreading': 'off',
'no-console': 'off',
'no-shadow': 'off',
'class-methods-use-this': 'off',
'default-param-last': 'off',
'no-unused-vars': 'off',
'import/prefer-default-export': 'off',
'import/extensions': 'off',
},
}

View File

@ -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 FixedLayoutEditor Demo</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,59 @@
{
"name": "@flowgram.ai/demo-fixed-layout",
"version": "0.1.0",
"description": "",
"keywords": [],
"license": "MIT",
"main": "./src/index.ts",
"files": [
"src/",
".eslintrc.js",
".gitignore",
"index.html",
"package.json",
"rspack.config.js",
"tsconfig.json"
],
"scripts": {
"build": "exit 0",
"build:fast": "exit 0",
"build:watch": "exit 0",
"clean": "rimraf dist",
"dev": "MODE=app NODE_ENV=development rspack serve",
"lint": "eslint ./src --cache",
"lint:fix": "eslint ./src --fix",
"start": "NODE_ENV=development rspack serve",
"test": "exit",
"test:cov": "exit",
"watch": "exit 0"
},
"dependencies": {
"@douyinfe/semi-icons": "^2.72.3",
"@douyinfe/semi-ui": "^2.72.3",
"@flowgram.ai/fixed-layout-editor": "workspace:*",
"@flowgram.ai/fixed-semi-materials": "workspace:*",
"@flowgram.ai/group-plugin": "workspace:*",
"@flowgram.ai/minimap-plugin": "workspace:*",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2",
"react": "^18",
"react-dom": "^18",
"styled-components": "^5"
},
"devDependencies": {
"@rspack/cli": "0.2.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/styled-components": "^5",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.54.0",
"less": "^4.1.2",
"less-loader": "^6"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

View File

@ -0,0 +1,64 @@
const path = require('path');
const isCI = process.env.CI === 'true';
const isSCM = !!process.env.BUILD_BRANCH;
const isProd = process.env.NODE_ENV === 'production';
/**
* @type {import('@rspack/cli').Configuration}
*/
module.exports = {
mode: process.env.NODE_ENV,
context: __dirname,
target: ['web'],
entry: {
main: './src/app.tsx',
},
resolve: {
alias: {
react: require.resolve('react'),
'react-dom': require.resolve('react-dom'),
},
},
builtins: {
// https://www.rspack.dev/config/builtins.html#builtinshtml
html: [
{
template: './index.html',
},
],
progress: !isSCM ? {} : false,
treeShaking: isProd,
},
module: {
// https://www.rspack.dev/config/module.html#rule
rules: [
{
test: /\.(png|gif|jpg|jpeg|svg|woff2)$/,
type: 'asset',
},
{
test: /\.(less|css)$/,
use: [
{
loader: 'less-loader',
options: {
// ...
},
},
],
type: 'css',
},
],
},
plugins: [],
/** module is too large now, we may need better way to tackle this in the future */
stats: isCI
? { all: false, modules: true, assets: true, chunks: true, warnings: true, errors: true }
: {
modules: false,
all: false,
warnings: false,
errors: true,
timings: true,
},
};

View File

@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client';
import { Editor } from './editor';
const app = createRoot(document.getElementById('root')!);
app.render(<Editor />);

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.1112 5.9556C31.0632 4.90759 29.2716 5.65513 29.2792 7.13722L29.2873 8.71503H12.1184C8.37321 8.71503 5.33711 11.7511 5.33711 15.4963H5.34238C5.33971 15.538 5.33839 15.58 5.33846 15.6224L5.34892 21.6473C5.35171 23.2499 7.22508 24.1194 8.4509 23.087L12.2732 19.8679C12.9121 19.3298 13.2806 18.5369 13.2801 17.7016L13.2795 16.715H29.3285L29.3351 17.9931C29.3427 19.4669 31.125 20.1998 32.1671 19.1576L37.5671 13.7576C38.215 13.1098 38.215 12.0594 37.5671 11.4115L32.1112 5.9556ZM13.279 15.8694L13.2788 15.6243C13.2788 15.5813 13.2773 15.5386 13.2745 15.4963H13.3371C13.3371 15.6265 13.3167 15.7518 13.279 15.8694ZM11.4759 37.9731C12.5239 39.0211 14.3156 38.2736 14.3079 36.7915L14.2998 35.2137H31.4687C35.2139 35.2137 38.25 32.1776 38.25 28.4324H38.2447C38.2474 28.3907 38.2487 28.3487 38.2487 28.3063L38.2382 22.2814C38.2354 20.6788 36.362 19.8093 35.1362 20.8417L31.314 24.0608C30.675 24.599 30.3065 25.3918 30.307 26.2272L30.3076 27.2137H14.2586L14.252 25.9356C14.2444 24.4618 12.4622 23.7289 11.42 24.7711L6.02002 30.1711C5.37215 30.819 5.37215 31.8694 6.02002 32.5172L11.4759 37.9731ZM30.3082 28.0593L30.3083 28.3044C30.3083 28.3474 30.3098 28.3901 30.3127 28.4324H30.25C30.25 28.3023 30.2704 28.1769 30.3082 28.0593Z" fill="url(#paint0_linear_775_1137)"/>
<defs>
<linearGradient id="paint0_linear_775_1137" x1="6.39609" y1="39.3063" x2="32.5905" y2="4.10488" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,13 @@
export function MouseIcon() {
return (
<svg width="34" 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>
);
}

View File

@ -0,0 +1,23 @@
export function PadIcon() {
return (
<svg width="48" 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>
);
}

View File

@ -0,0 +1,6 @@
<svg t="1724931640169" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1762"
width="20" height="20">
<path
d="M1024 800a96 96 0 0 1-96 96h-192a96 96 0 0 1-96-96v-64a96 96 0 0 1 96-96h62.656c-2.56-44.416-11.84-70.72-24.576-79.36-15.36-10.304-32.896-12.928-79.552-13.632l-17.28-0.32a436.544 436.544 0 0 1-24.32-1.152l-14.72-1.472a185.792 185.792 0 0 1-75.712-24.832c-19.968-12.032-36.608-33.92-50.944-65.92-14.272 32-30.912 53.888-50.88 65.92a185.792 185.792 0 0 1-75.648 24.832l-14.72 1.472c-7.936 0.64-14.72 0.96-24.32 1.152l-17.28 0.32c-46.72 0.64-64.256 3.328-79.616 13.696-12.736 8.576-22.016 34.88-24.512 79.296H288A96 96 0 0 1 384 736v64A96 96 0 0 1 288 896h-192A96 96 0 0 1 0 800v-64A96 96 0 0 1 96 640h64.448c3.2-65.664 19.52-109.888 52.864-132.352 24.96-16.896 47.04-22.208 89.28-23.936l47.36-1.152c3.84-0.128 7.168-0.256 10.496-0.512l4.992-0.32c25.984-1.984 45.312-7.04 62.144-17.28 12.8-7.68 27.392-34.752 41.152-80.32L416 384A96 96 0 0 1 320 288v-64A96 96 0 0 1 416 128h192A96 96 0 0 1 704 224v64A96 96 0 0 1 608 384l-53.504 0.128c13.696 45.568 28.352 72.64 41.088 80.32 16.832 10.24 36.16 15.296 62.208 17.28l4.992 0.32c3.264 0.256 6.592 0.384 10.432 0.512l47.36 1.152c42.24 1.728 64.32 7.04 89.344 23.936 33.28 22.4 49.6 66.688 52.8 132.352h65.28a96 96 0 0 1 96 96z m-704 0v-64a32 32 0 0 0-32-32h-192a32 32 0 0 0-32 32v64a32 32 0 0 0 32 32h192a32 32 0 0 0 32-32z m320-512v-64a32 32 0 0 0-32-32h-192a32 32 0 0 0-32 32v64a32 32 0 0 0 32 32h192a32 32 0 0 0 32-32z m320 512v-64a32 32 0 0 0-32-32h-192a32 32 0 0 0-32 32v64a32 32 0 0 0 32 32h192a32 32 0 0 0 32-32z"
fill="#666666" p-id="1763"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/fixed-layout-editor';
import { ConfigProvider } from '@douyinfe/semi-ui';
import { NodeRenderContext } from '../../context';
import { BaseNodeStyle, ErrorIcon } from './styles';
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
/**
* Provides methods related to node rendering
*
*/
const nodeRender = useNodeRender();
/**
* It can only be used when nodeEngine is enabled
* 使
*/
const form = nodeRender.form;
/**
* Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library
* Tooltip , ui
*/
const getPopupContainer = useCallback(() => node.renderData.node || document.body, []);
return (
<ConfigProvider getPopupContainer={getPopupContainer}>
{form?.state.invalid && <ErrorIcon />}
<BaseNodeStyle
onMouseEnter={nodeRender.onMouseEnter}
onMouseLeave={nodeRender.onMouseLeave}
style={{
...(nodeRender.isBlockOrderIcon || nodeRender.isBlockIcon ? { width: 260 } : {}),
outline: form?.state.invalid ? '1px solid red' : 'none',
}}
>
<NodeRenderContext.Provider value={nodeRender}>{form?.render()}</NodeRenderContext.Provider>
</BaseNodeStyle>
</ConfigProvider>
);
};

View File

@ -0,0 +1,31 @@
import styled from 'styled-components';
import { IconInfoCircle } from '@douyinfe/semi-icons';
export const BaseNodeStyle = 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;
transition: all 0.3s ease;
`;
export const ErrorIcon = () => (
<IconInfoCircle
style={{
position: 'absolute',
color: 'red',
left: -6,
top: -6,
zIndex: 1,
background: 'white',
borderRadius: 8,
}}
/>
);

View File

@ -0,0 +1,49 @@
import { type FlowNodeEntity, useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { IconPlus } from '@douyinfe/semi-icons';
import { BlockNodeRegistry } from '../../nodes/block';
import { Container } from './styles';
interface PropsType {
activated?: boolean;
node: FlowNodeEntity;
}
export default function BranchAdder(props: PropsType) {
const { activated, node } = props;
const nodeData = node.firstChild!.renderData;
const ctx = useClientContext();
const { operation, playground } = ctx;
const { isVertical } = node;
function addBranch() {
const block = operation.addBlock(node, BlockNodeRegistry.onAdd!(ctx, node));
setTimeout(() => {
playground.scrollToView({
bounds: block.bounds,
scrollToCenter: true,
});
}, 10);
}
if (playground.config.readonlyOrDisabled) return null;
return (
<Container
isVertical={isVertical}
activated={activated}
onMouseEnter={() => nodeData?.toggleMouseEnter()}
onMouseLeave={() => nodeData?.toggleMouseLeave()}
>
<div
onClick={() => {
addBranch();
}}
aria-hidden="true"
style={{ flexGrow: 1, textAlign: 'center' }}
>
<IconPlus />
</div>
</Container>
);
}

View File

@ -0,0 +1,24 @@
import styled from 'styled-components';
export const Container = styled.div<{ activated?: boolean; isVertical: boolean }>`
width: 28px;
height: 18px;
background: ${props => (props.activated ? '#82A7FC' : 'rgb(187, 191, 196)')};
display: flex;
border-radius: 9px;
justify-content: space-evenly;
align-items: center;
color: #fff;
font-size: 10px;
font-weight: bold;
transform: ${props => (props.isVertical ? '' : 'rotate(90deg)')};
div {
display: flex;
justify-content: center;
align-items: center;
svg {
width: 12px;
height: 12px;
}
}
`;

View File

@ -0,0 +1,47 @@
import type { FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeRegistries } from '../../nodes';
import { Icon } from '../../form-components/form-header/styles';
import { UIDragNodeContainer, UIDragCounts } from './styles';
export interface PropsType {
dragStart: FlowNodeEntity;
dragNodes: FlowNodeEntity[];
}
export function DragNode(props: PropsType): JSX.Element {
const { dragStart, dragNodes } = props;
const icon = FlowNodeRegistries.find(registry => registry.type === dragStart?.flowNodeType)?.info
?.icon;
const dragLength = (dragNodes || [])
.map(_node =>
_node.allCollapsedChildren.length
? _node.allCollapsedChildren.filter(_n => !_n.hidden).length
: 1,
)
.reduce((acm, curr) => acm + curr, 0);
return (
<UIDragNodeContainer>
<Icon src={icon} />
{dragStart?.id}
{dragLength > 1 && (
<>
<UIDragCounts>{dragLength}</UIDragCounts>
<UIDragNodeContainer
style={{
position: 'absolute',
top: 5,
right: -5,
left: 5,
bottom: -5,
opacity: 0.5,
}}
/>
</>
)}
</UIDragNodeContainer>
);
}

View File

@ -0,0 +1,35 @@
import styled from 'styled-components';
const primary = 'hsl(252 62% 54.9%)';
const primaryOpacity09 = 'hsl(252deg 62% 55% / 9%)';
export const UIDragNodeContainer = styled.div`
position: relative;
height: 32px;
border-radius: 5px;
display: flex;
align-items: center;
column-gap: 8px;
cursor: pointer;
font-size: 19px;
border: 1px solid ${primary};
padding: 0 15px;
&:hover: {
background-color: ${primaryOpacity09};
color: ${primary};
}
`;
export const UIDragCounts = styled.div`
position: absolute;
top: -8px;
right: -8px;
text-align: center;
line-height: 16px;
width: 16px;
height: 16px;
border-radius: 8px;
font-size: 12px;
color: #fff;
background-color: ${primary};
`;

View File

@ -0,0 +1,2 @@
export { DemoTools } from './tools';
export { DragNode } from './drag-node';

View File

@ -0,0 +1,162 @@
import { useCallback, useMemo, useState } from 'react';
import { useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { type FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
import { Popover, Toast, Typography } from '@douyinfe/semi-ui';
import { IconCopyAdd, IconPlusCircle } from '@douyinfe/semi-icons';
import { NodeList } from '../node-list';
import { readData } from '../../shortcuts/utils';
import { generateNodeId } from './utils';
import { PasteIcon, Wrap } from './styles';
const generateNewIdForChildren = (n: FlowNodeEntity): FlowNodeEntity => {
if (n.blocks) {
return {
...n,
id: generateNodeId(n),
blocks: n.blocks.map(b => generateNewIdForChildren(b)),
} as FlowNodeEntity;
} else {
return {
...n,
id: generateNodeId(n),
} as FlowNodeEntity;
}
};
export default function Adder(props: {
from: FlowNodeEntity;
to?: FlowNodeEntity;
hoverActivated: boolean;
}) {
const { from } = props;
const isVertical = from.isVertical;
const [visible, setVisible] = useState(false);
const { playground, operation, clipboard } = useClientContext();
const [pasteIconVisible, setPasteIconVisible] = useState(false);
const activated = useMemo(
() => props.hoverActivated && !playground.config.readonly,
[props.hoverActivated, playground.config.readonly],
);
const add = (addProps: any) => {
const blocks = addProps.blocks ? addProps.blocks : undefined;
const block = operation.addFromNode(from, {
...addProps,
blocks,
});
setTimeout(() => {
playground.scrollToView({
bounds: block.bounds,
scrollToCenter: true,
});
}, 10);
setVisible(false);
};
const handlePaste = useCallback(async (e: any) => {
try {
e.stopPropagation();
const nodes = await readData(clipboard);
if (!nodes) {
Toast.error({
content: 'The clipboard content has been updated, please copy the node again.',
});
return;
}
nodes.reverse().forEach((n: FlowNodeEntity) => {
const newNodeData = generateNewIdForChildren(n);
operation.addFromNode(from, newNodeData);
});
Toast.success({
content: 'Paste successfully!',
});
} catch (error) {
console.error(error);
Toast.error({
content: (
<Typography.Text>
Paste failed, please check if you have permission to read the clipboard,
</Typography.Text>
),
});
}
}, []);
if (playground.config.readonly) return null;
return (
<Popover
visible={visible}
onVisibleChange={setVisible}
content={<NodeList onSelect={add} from={from} />}
placement="right"
trigger="click"
popupAlign={{ offset: [30, 0] }}
overlayStyle={{
padding: 0,
}}
>
<Wrap
style={
props.hoverActivated
? {
width: 15,
height: 15,
}
: {}
}
onMouseDown={e => e.stopPropagation()}
>
{props.hoverActivated ? (
<IconPlusCircle
onClick={() => {
setVisible(true);
}}
onMouseEnter={() => {
const data = clipboard.readText();
setPasteIconVisible(!!data);
}}
style={{
backgroundColor: '#fff',
color: '#3370ff',
borderRadius: 15,
}}
/>
) : (
''
)}
{activated && pasteIconVisible && (
<Popover position="top" showArrow content="Paste">
<PasteIcon
onClick={handlePaste}
style={
isVertical
? {
right: -25,
top: 0,
}
: {
right: 0,
top: -20,
}
}
>
<IconCopyAdd
style={{
backgroundColor: 'var(--semi-color-bg-0)',
borderRadius: 15,
}}
/>
</PasteIcon>
</Popover>
)}
</Wrap>
</Popover>
);
}

View File

@ -0,0 +1,27 @@
import styled from 'styled-components';
export const PasteIcon = styled.div`
position: absolute;
width: 15px;
height: 15px;
color: #3370ff;
display: flex;
justify-content: center;
align-items: center;
`;
export const Wrap = styled.div`
position: relative;
width: 6px;
height: 6px;
background-color: rgb(143, 149, 158);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
svg: {
transform: scale(0.7);
}
`;

View File

@ -0,0 +1,4 @@
import { nanoid } from 'nanoid';
import { FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
export const generateNodeId = (n: FlowNodeEntity) => `${n.type || n.flowNodeType}_${nanoid()}`;

View File

@ -0,0 +1,69 @@
import styled from 'styled-components';
import {
FlowNodeEntity,
FlowNodeRegistry,
useClientContext,
} from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeRegistries } 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;
`;
function Node(props: { label: string; icon: JSX.Element; onClick: () => void; disabled: boolean }) {
return (
<NodeWrap
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;
}
`;
export function NodeList(props: { onSelect: (meta: any) => void; from: FlowNodeEntity }) {
const context = useClientContext();
const handleClick = (registry: FlowNodeRegistry) => {
const addProps = registry.onAdd(context, props.from);
props.onSelect?.(addProps);
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{FlowNodeRegistries.map(registry => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context, props.from) ?? true)}
icon={<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info.icon} />}
label={registry.type as string}
onClick={() => handleClick(registry)}
/>
))}
</NodesWrap>
);
}

View File

@ -0,0 +1,170 @@
import { FunctionComponent, useMemo } from 'react';
import {
useStartDragNode,
FlowNodeRenderData,
FlowNodeBaseType,
FlowGroupService,
type FlowNodeEntity,
SelectorBoxPopoverProps,
} from '@flowgram.ai/fixed-layout-editor';
import { Button, ButtonGroup, Tooltip } from '@douyinfe/semi-ui';
import {
IconCopy,
IconDeleteStroked,
IconExpand,
IconHandle,
IconShrink,
} from '@douyinfe/semi-icons';
import { FlowCommandId } from '../../shortcuts/constants';
import { IconGroupOutlined } from '../../plugins/group-plugin/icons';
const BUTTON_HEIGHT = 24;
export const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({
bounds,
children,
flowSelectConfig,
commandRegistry,
}) => {
const selectNodes = flowSelectConfig.selectedNodes;
const { startDrag } = useStartDragNode();
const draggable = selectNodes[0]?.getData(FlowNodeRenderData)?.draggable;
// Does the selected component have a group node? (High-cost computation must use memo)
const hasGroup: boolean = useMemo(() => {
if (!selectNodes || selectNodes.length === 0) {
return false;
}
const findGroupInNodes = (nodes: FlowNodeEntity[]): boolean =>
nodes.some(node => {
if (node.flowNodeType === FlowNodeBaseType.GROUP) {
return true;
}
if (node.blocks && node.blocks.length) {
return findGroupInNodes(node.blocks);
}
return false;
});
return findGroupInNodes(selectNodes);
}, [selectNodes]);
const canGroup = !hasGroup && FlowGroupService.validate(selectNodes);
return (
<>
<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 }}
>
{draggable && (
<Tooltip content="Drag">
<Button
style={{ cursor: 'grab', height: BUTTON_HEIGHT }}
icon={<IconHandle />}
type="primary"
theme="solid"
onMouseDown={e => {
e.stopPropagation();
startDrag(e, {
dragStartEntity: selectNodes[0],
dragEntities: selectNodes,
});
}}
/>
</Tooltip>
)}
<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={'Group'}>
<Button
icon={<IconGroupOutlined />}
type="primary"
theme="solid"
style={{
display: canGroup ? 'inherit' : 'none',
height: BUTTON_HEIGHT,
}}
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.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
style={{ cursor: draggable ? 'grab' : 'auto' }}
onMouseDown={e => {
e.stopPropagation();
startDrag(e, {
dragStartEntity: selectNodes[0],
dragEntities: selectNodes,
});
}}
>
{children}
</div>
</>
);
};

View File

@ -0,0 +1,13 @@
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { IconExpand } from '@douyinfe/semi-icons';
export const FitView = (props: { fitView: () => void }) => (
<Tooltip content="FitView">
<IconButton
icon={<IconExpand />}
type="tertiary"
theme="borderless"
onClick={() => props.fitView()}
/>
</Tooltip>
);

View File

@ -0,0 +1,60 @@
import { useState, useEffect } from 'react';
import {
usePlayground,
usePlaygroundTools,
useRefresh,
} from '@flowgram.ai/fixed-layout-editor';
import { Tooltip, IconButton } from '@douyinfe/semi-ui';
import { IconUndo, IconRedo } from '@douyinfe/semi-icons';
import { ZoomSelect } from './zoom-select';
import { SwitchVertical } from './switch-vertical';
import { ToolContainer, ToolSection } from './styles';
import { Save } from './save';
import { Readonly } from './readonly';
import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
import { FitView } from './fit-view';
export const DemoTools = () => {
const tools = usePlaygroundTools();
const [minimapVisible, setMinimapVisible] = useState(false);
const playground = usePlayground();
const refresh = useRefresh();
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());
return () => disposable.dispose();
}, [playground]);
return (
<ToolContainer className="fixed-demo-tools">
<ToolSection>
<SwitchVertical />
<ZoomSelect />
<FitView fitView={tools.fitView} />
<MinimapSwitch minimapVisible={minimapVisible} setMinimapVisible={setMinimapVisible} />
<Minimap visible={minimapVisible} />
<Readonly />
<Tooltip content="Undo">
<IconButton
theme="borderless"
icon={<IconUndo />}
disabled={!tools.canUndo}
onClick={() => tools.undo()}
/>
</Tooltip>
<Tooltip content="Redo">
<IconButton
theme="borderless"
icon={<IconRedo />}
disabled={!tools.canRedo}
onClick={() => tools.redo()}
/>
</Tooltip>
<Save disabled={playground.config.readonly} />
</ToolSection>
</ToolContainer>
);
};

View File

@ -0,0 +1,20 @@
import { Tooltip, IconButton } from '@douyinfe/semi-ui';
import { UIIconGridRectangle } from './styles';
export const MinimapSwitch = (props: {
minimapVisible: boolean;
setMinimapVisible: (visible: boolean) => void;
}) => {
const { minimapVisible, setMinimapVisible } = props;
return (
<Tooltip content="Minimap">
<IconButton
theme="borderless"
icon={<UIIconGridRectangle visible={minimapVisible} />}
onClick={() => setMinimapVisible(!minimapVisible)}
/>
</Tooltip>
);
};

View File

@ -0,0 +1,33 @@
import { FlowMinimapService, MinimapRender } from '@flowgram.ai/minimap-plugin';
import { useService } from '@flowgram.ai/fixed-layout-editor';
import { MinimapContainer } from './styles';
export const Minimap = ({ visible }: { visible?: boolean }) => {
const minimapService = useService(FlowMinimapService);
if (!visible) {
return <></>;
}
return (
<MinimapContainer>
<MinimapRender
service={minimapService}
panelStyles={{}}
containerStyles={{
pointerEvents: 'auto',
position: 'relative',
top: 'unset',
right: 'unset',
bottom: 'unset',
left: 'unset',
}}
inactiveStyle={{
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
}}
/>
</MinimapContainer>
);
};

View File

@ -0,0 +1,18 @@
import { useCallback } from 'react';
import { usePlayground } from '@flowgram.ai/fixed-layout-editor';
import { IconButton } from '@douyinfe/semi-ui';
import { IconUnlock, IconLock } from '@douyinfe/semi-icons';
export const Readonly = () => {
const playground = usePlayground();
const toggleReadonly = useCallback(() => {
playground.config.readonly = !playground.config.readonly;
}, [playground]);
return playground.config.readonly ? (
<IconButton theme="borderless" type="tertiary" icon={<IconLock />} onClick={toggleReadonly} />
) : (
<IconButton theme="borderless" type="tertiary" icon={<IconUnlock />} onClick={toggleReadonly} />
);
};

View File

@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from 'react';
import {
useClientContext,
getNodeForm,
FlowNodeEntity,
} from '@flowgram.ai/fixed-layout-editor';
import { Button, Badge } from '@douyinfe/semi-ui';
export function Save(props: { disabled: boolean }) {
const [errorCount, setErrorCount] = useState(0);
const clientContext = useClientContext();
const updateValidateData = useCallback(() => {
const allForms = clientContext.document.getAllNodes().map(node => getNodeForm(node));
const count = allForms.filter(form => form?.state.invalid).length;
setErrorCount(count);
}, [clientContext]);
/**
* Validate all node and Save
*/
const onSave = useCallback(async () => {
const allForms = clientContext.document.getAllNodes().map(node => getNodeForm(node));
await Promise.all(allForms.map(async form => form?.validate()));
console.log('>>>>> save data: ', clientContext.document.toJSON());
}, [clientContext]);
useEffect(() => {
/**
* Listen single node validate
*/
const listenSingleNodeValidate = (node: FlowNodeEntity) => {
const form = getNodeForm(node);
if (form) {
const formValidateDispose = form.onValidate(() => updateValidateData());
node.onDispose(() => formValidateDispose.dispose());
}
};
clientContext.document.getAllNodes().map(node => listenSingleNodeValidate(node));
const dispose = clientContext.document.onNodeCreate(({ node }) =>
listenSingleNodeValidate(node),
);
return () => dispose.dispose();
}, [clientContext]);
if (errorCount === 0) {
return (
<Button disabled={props.disabled} onClick={onSave}>
Save
</Button>
);
}
return (
<Badge count={errorCount} position="rightTop" type="danger">
<Button type="danger" disabled={props.disabled} onClick={onSave}>
Save
</Button>
</Badge>
);
}

View File

@ -0,0 +1,45 @@
import styled from 'styled-components';
import { IconGridRectangle } from '@douyinfe/semi-icons';
export const ToolContainer = styled.div`
position: absolute;
bottom: 16px;
display: flex;
justify-content: left;
min-width: 360px;
pointer-events: none;
gap: 8px;
z-index: 99;
`;
export const ToolSection = styled.div`
display: flex;
align-items: center;
background-color: #fff;
border: 1px solid rgba(68, 83, 130, 0.25);
border-radius: 10px;
box-shadow: rgba(0, 0, 0, 0.04) 0px 2px 6px 0px, rgba(0, 0, 0, 0.02) 0px 4px 12px 0px;
column-gap: 2px;
height: 40px;
padding: 0 4px;
pointer-events: auto;
`;
export const SelectZoom = styled.span`
padding: 2px;
border-radius: 8px;
border: 1px solid rgba(68, 83, 130, 0.25);
font-size: 12px;
width: 40px;
`;
export const MinimapContainer = styled.div`
position: absolute;
bottom: 60px;
width: 182px;
`;
export const UIIconGridRectangle = styled(IconGridRectangle)<{ visible: boolean }>`
color: ${props => (props.visible ? undefined : '#060709cc')};
`;

View File

@ -0,0 +1,25 @@
import { usePlaygroundTools } from '@flowgram.ai/fixed-layout-editor';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconServer } from '@douyinfe/semi-icons';
export const SwitchVertical = () => {
const tools = usePlaygroundTools();
return (
<Tooltip content={!tools.isVertical ? 'Vertical Layout' : 'Horizontal Layout'}>
<Button
theme="borderless"
size="small"
onClick={() => tools.changeLayout()}
icon={
<IconServer
style={{
transform: !tools.isVertical ? '' : 'rotate(90deg)',
transition: 'transform .3s ease',
}}
/>
}
type="tertiary"
/>
</Tooltip>
);
};

View File

@ -0,0 +1,32 @@
import { useState } from 'react';
import { usePlaygroundTools } from '@flowgram.ai/fixed-layout-editor';
import { Divider, Dropdown } from '@douyinfe/semi-ui';
import { SelectZoom } from './styles';
export const ZoomSelect = () => {
const tools = usePlaygroundTools();
const [dropDownVisible, openDropDown] = useState(false);
return (
<Dropdown
position="top"
trigger="custom"
visible={dropDownVisible}
onClickOutSide={() => openDropDown(false)}
render={
<Dropdown.Menu>
<Dropdown.Item onClick={() => tools.zoomin()}>Zoomin</Dropdown.Item>
<Dropdown.Item onClick={() => tools.zoomout()}>Zoomout</Dropdown.Item>
<Divider layout="horizontal" />
<Dropdown.Item onClick={() => tools.updateZoom(0.5)}>50%</Dropdown.Item>
<Dropdown.Item onClick={() => tools.updateZoom(1)}>100%</Dropdown.Item>
<Dropdown.Item onClick={() => tools.updateZoom(1.5)}>150%</Dropdown.Item>
<Dropdown.Item onClick={() => tools.updateZoom(2.0)}>200%</Dropdown.Item>
</Dropdown.Menu>
}
>
<SelectZoom onClick={() => openDropDown(true)}>{Math.floor(tools.zoom * 100)}%</SelectZoom>
</Dropdown>
);
};

View File

@ -0,0 +1 @@
export { NodeRenderContext } from './node-render-context';

View File

@ -0,0 +1,5 @@
import React from 'react';
import { type NodeRenderReturnType } from '@flowgram.ai/fixed-layout-editor';
export const NodeRenderContext = React.createContext<NodeRenderReturnType>({} as any);

View File

@ -0,0 +1,24 @@
import { EditorRenderer, FixedLayoutEditorProvider } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeRegistries } from './nodes';
import { initialData } from './initial-data';
import { useEditorProps } from './hooks/use-editor-props';
import { DemoTools } from './components';
import '@flowgram.ai/fixed-layout-editor/index.css';
export const Editor = () => {
/**
* Editor Config
*/
const editorProps = useEditorProps(initialData, FlowNodeRegistries);
return (
<div className="doc-feature-overview">
<FixedLayoutEditorProvider {...editorProps}>
<EditorRenderer />
<DemoTools />
</FixedLayoutEditorProvider>
</div>
);
};

View File

@ -0,0 +1,34 @@
import styled from 'styled-components';
import { FieldError, FieldState, FieldWarning } from '@flowgram.ai/fixed-layout-editor';
interface StatePanelProps {
errors?: FieldState['errors'];
warnings?: FieldState['warnings'];
}
const Error = styled.span`
font-size: 12px;
color: red;
`;
const Warning = styled.span`
font-size: 12px;
color: orange;
`;
export const Feedback = ({ errors, warnings }: StatePanelProps) => {
const renderFeedbacks = (fs: FieldError[] | FieldWarning[] | undefined) => {
if (!fs) return null;
return fs.map(f => <span key={f.name}>{f.message}</span>);
};
return (
<div>
<div>
<Error>{renderFeedbacks(errors)}</Error>
</div>
<div>
<Warning>{renderFeedbacks(warnings)}</Warning>
</div>
</div>
);
};

View File

@ -0,0 +1,25 @@
import React, { useContext } from 'react';
import { FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
import { NodeRenderContext } from '../../context';
import { FormTitleDescription, FormWrapper } from './styles';
/**
* @param props
* @constructor
*/
export function FormContent(props: { children?: React.ReactNode }) {
const { expanded, node } = useContext(NodeRenderContext);
const registry = node.getNodeRegistry<FlowNodeRegistry>();
return (
<FormWrapper>
{expanded ? (
<>
<FormTitleDescription>{registry.info?.description}</FormTitleDescription>
{props.children}
</>
) : undefined}
</FormWrapper>
);
}

View File

@ -0,0 +1,20 @@
import styled from 'styled-components';
export const FormWrapper = styled.div`
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
background-color: rgba(0, 0, 0, 0.02);
padding: 0 12px 12px;
`;
export const FormTitleDescription = styled.div`
color: var(--semi-color-text-2);
font-size: 12px;
line-height: 20px;
padding: 0px 4px;
word-break: break-all;
white-space: break-spaces;
`;

View File

@ -0,0 +1,83 @@
import { useContext } from 'react';
import { Field, FieldRenderProps, useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { IconButton, Dropdown, Typography, Button } from '@douyinfe/semi-ui';
import { IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';
import { IconMore } from '@douyinfe/semi-icons';
import { Feedback } from '../feedback';
import { FlowNodeRegistry } from '../../typings';
import { FlowCommandId } from '../../shortcuts/constants';
import { NodeRenderContext } from '../../context';
import { getIcon } from './utils';
import { Header, Operators, Title } from './styles';
const { Text } = Typography;
function DropdownContent() {
const { node, deleteNode } = useContext(NodeRenderContext);
const clientContext = useClientContext();
const registry = node.getNodeRegistry<FlowNodeRegistry>();
const handleCopy = () => {
clientContext.playground.commandService.executeCommand(FlowCommandId.COPY, node);
};
return (
<Dropdown.Menu>
<Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>
Copy
</Dropdown.Item>
<Dropdown.Item
onClick={deleteNode}
disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}
>
Delete
</Dropdown.Item>
</Dropdown.Menu>
);
}
export function FormHeader() {
const { node, expanded, startDrag, toggleExpand, readonly } = useContext(NodeRenderContext);
return (
<Header
onMouseDown={e => {
// trigger drag node
startDrag(e);
e.stopPropagation();
}}
>
{getIcon(node)}
<Title>
<Field name="title">
{({ field: { value, onChange }, fieldState }: FieldRenderProps<string>) => (
<div style={{ height: 24 }}>
<Text ellipsis={{ showTooltip: true }}>{value}</Text>
<Feedback errors={fieldState?.errors} />
</div>
)}
</Field>
</Title>
<Button
type="primary"
icon={expanded ? <IconSmallTriangleDown /> : <IconSmallTriangleLeft />}
size="small"
theme="borderless"
onClick={toggleExpand}
/>
{readonly ? undefined : (
<Operators>
<Dropdown trigger="hover" position="bottomRight" render={<DropdownContent />}>
<IconButton
color="secondary"
size="small"
theme="borderless"
icon={<IconMore />}
onClick={e => e.stopPropagation()}
/>
</Dropdown>
</Operators>
)}
</Header>
);
}

View File

@ -0,0 +1,35 @@
import styled from 'styled-components';
export const Header = styled.div`
box-sizing: border-box;
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
column-gap: 8px;
border-radius: 8px;
background: linear-gradient(#f2f2ff 0%, rgba(0, 0, 0, 0.02) 100%);
overflow: hidden;
padding: 8px;
`;
export const Title = styled.div`
font-size: 20px;
flex: 1;
width: 0;
`;
export const Icon = styled.img`
width: 24px;
height: 24px;
scale: 0.8;
border-radius: 4px;
`;
export const Operators = styled.div`
display: flex;
align-items: center;
column-gap: 4px;
`;

View File

@ -0,0 +1,10 @@
import { type FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import { Icon } from './styles';
export const getIcon = (node: FlowNodeEntity) => {
const icon = node.getNodeRegistry<FlowNodeRegistry>().info?.icon;
if (!icon) return null;
return <Icon src={icon} />;
};

View File

@ -0,0 +1,47 @@
import { useContext } from 'react';
import { Field } from '@flowgram.ai/fixed-layout-editor';
import { FxExpression } from '../fx-expression';
import { FormItem } from '../form-item';
import { Feedback } from '../feedback';
import { JsonSchema } from '../../typings';
import { NodeRenderContext } from '../../context';
export function FormInputs() {
const { readonly } = useContext(NodeRenderContext);
return (
<Field<JsonSchema> name="inputs">
{({ field: inputsField }) => {
const required = inputsField.value?.required || [];
const properties = inputsField.value?.properties;
if (!properties) {
return <></>;
}
const content = Object.keys(properties).map(key => {
const property = properties[key];
return (
<Field key={key} name={`inputsValues.${key}`} defaultValue={property.default}>
{({ field, fieldState }) => (
<FormItem
name={key}
type={property.type as string}
required={required.includes(key)}
>
<FxExpression
value={field.value}
onChange={field.onChange}
disabled={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
/>
<Feedback errors={fieldState?.errors} />
</FormItem>
)}
</Field>
);
});
return <>{content}</>;
}}
</Field>
);
}

View File

@ -0,0 +1,3 @@
// import styled from 'styled-components';
// TODO

View File

@ -0,0 +1,9 @@
.form-item-type-tag {
color: inherit;
padding: 0 2px;
height: 18px;
width: 18px;
vertical-align: middle;
flex-shrink: 0;
flex-grow: 0;
}

View File

@ -0,0 +1,76 @@
import React, { useCallback } from 'react';
import { Typography, Tooltip } from '@douyinfe/semi-ui';
import { TypeTag } from '../type-tag';
import './index.css';
const { Text } = Typography;
interface FormItemProps {
children: React.ReactNode;
name: string;
type: string;
required?: boolean;
description?: string;
labelWidth?: number;
}
export function FormItem({
children,
name,
required,
description,
type,
labelWidth,
}: FormItemProps): JSX.Element {
const renderTitle = useCallback(
(showTooltip?: boolean) => (
<div style={{ width: '0', display: 'flex', flex: '1' }}>
<Text style={{ width: '100%' }} ellipsis={{ showTooltip: !!showTooltip }}>
{name}
</Text>
{required && <span style={{ color: '#f93920', paddingLeft: '2px' }}>*</span>}
</div>
),
[],
);
return (
<div
style={{
fontSize: 12,
marginBottom: 6,
width: '100%',
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
}}
>
<div
style={{
justifyContent: 'center',
alignItems: 'center',
color: 'var(--semi-color-text-0)',
width: labelWidth || 118,
position: 'relative',
display: 'flex',
columnGap: 4,
flexShrink: 0,
}}
>
<TypeTag className="form-item-type-tag" type={type} />
{description ? <Tooltip content={description}>{renderTitle()}</Tooltip> : renderTitle(true)}
</div>
<div
style={{
flexGrow: 1,
minWidth: 0,
}}
>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { Field } from '@flowgram.ai/fixed-layout-editor';
import { TypeTag } from '../type-tag';
import { JsonSchema } from '../../typings';
import { FormOutputsContainer } from './styles';
export function FormOutputs() {
return (
<Field<JsonSchema> name={'outputs'}>
{({ field }) => {
const properties = field.value?.properties;
if (properties) {
const content = Object.keys(properties).map(key => {
const property = properties[key];
return <TypeTag key={key} name={key} type={property.type as string} />;
});
return <FormOutputsContainer>{content}</FormOutputsContainer>;
}
return <></>;
}}
</Field>
);
}

View File

@ -0,0 +1,14 @@
import styled from 'styled-components';
export const FormOutputsContainer = styled.div`
display: flex;
gap: 6px;
flex-wrap: wrap;
border-top: 1px solid var(--semi-color-border);
padding: 8px 0 0;
width: 100%;
:global(.semi-tag .semi-tag-content) {
font-size: 10px;
}
`;

View File

@ -0,0 +1,61 @@
import { type SVGProps } from 'react';
import { Input, Button } from '@douyinfe/semi-ui';
import { FlowValueSchema, FlowRefValueSchema } from '../../typings';
import { VariableSelector } from '../../plugins/variable-plugin/variable-selector';
export function FxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M5.581 4.49A2.75 2.75 0 0 1 8.319 2h.931a.75.75 0 0 1 0 1.5h-.931a1.25 1.25 0 0 0-1.245 1.131l-.083.869H9.25a.75.75 0 0 1 0 1.5H6.849l-.43 4.51A2.75 2.75 0 0 1 3.681 14H2.75a.75.75 0 0 1 0-1.5h.931a1.25 1.25 0 0 0 1.245-1.132L5.342 7H3.75a.75.75 0 0 1 0-1.5h1.735zM9.22 9.22a.75.75 0 0 1 1.06 0l1.22 1.22l1.22-1.22a.75.75 0 1 1 1.06 1.06l-1.22 1.22l1.22 1.22a.75.75 0 1 1-1.06 1.06l-1.22-1.22l-1.22 1.22a.75.75 0 1 1-1.06-1.06l1.22-1.22l-1.22-1.22a.75.75 0 0 1 0-1.06"
clipRule="evenodd"
></path>
</svg>
);
}
export interface FxExpressionProps {
value?: FlowValueSchema;
onChange: (value: FlowValueSchema) => void;
literal?: boolean;
hasError?: boolean;
disabled?: boolean;
}
export function FxExpression(props: FxExpressionProps) {
const { value, onChange, disabled, literal } = props;
if (literal) return <Input value={value as string} onChange={onChange} disabled={disabled} />;
const isExpression = typeof value === 'object' && value.type === 'expression';
const toggleExpression = () => {
if (isExpression) {
onChange((value as FlowRefValueSchema).content as string);
} else {
onChange({ content: value as string, type: 'expression' });
}
};
return (
<div style={{ display: 'flex' }}>
{isExpression ? (
<VariableSelector
value={value.content}
style={{ flexGrow: 1 }}
onChange={v => onChange({ type: 'expression', content: v })}
disabled={disabled}
/>
) : (
<Input
value={value as string}
onChange={onChange}
validateStatus={props.hasError ? 'error' : undefined}
disabled={disabled}
style={{ flexGrow: 1, outline: props.hasError ? '1px solid red' : undefined }}
/>
)}
{!disabled && <Button theme="borderless" icon={<FxIcon />} onClick={toggleExpression} />}
</div>
);
}

View File

@ -0,0 +1,8 @@
export * from './feedback';
export * from './form-content';
export * from './form-outputs';
export * from './form-inputs';
export * from './form-header';
export * from './form-item';
export * from './type-tag';
export * from './properties-edit';

View File

@ -0,0 +1,111 @@
import React, { useContext, useState } from 'react';
import { Button } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
import { JsonSchema } from '../../typings';
import { NodeRenderContext } from '../../context';
import { PropertyEdit } from './property-edit';
export interface PropertiesEditProps {
value?: Record<string, JsonSchema>;
onChange: (value: Record<string, JsonSchema>) => void;
useFx?: boolean;
}
export const PropertiesEdit: React.FC<PropertiesEditProps> = props => {
const value = (props.value || {}) as Record<string, JsonSchema>;
const { readonly } = useContext(NodeRenderContext);
const [newProperty, updateNewPropertyFromCache] = useState<{ key: string; value: JsonSchema }>({
key: '',
value: { type: 'string' },
});
const [newPropertyVisible, setNewPropertyVisible] = useState<boolean>();
const clearCache = () => {
updateNewPropertyFromCache({ key: '', value: { type: 'string' } });
setNewPropertyVisible(false);
};
const updateProperty = (
propertyValue: JsonSchema,
propertyKey: string,
newPropertyKey?: string,
) => {
const newValue = { ...value };
if (newPropertyKey) {
delete newValue[propertyKey];
newValue[newPropertyKey] = propertyValue;
} else {
newValue[propertyKey] = propertyValue;
}
props.onChange(newValue);
};
const updateNewProperty = (
propertyValue: JsonSchema,
propertyKey: string,
newPropertyKey?: string,
) => {
// const newValue = { ...value }
if (newPropertyKey) {
if (!(newPropertyKey in value)) {
updateProperty(propertyValue, propertyKey, newPropertyKey);
}
clearCache();
} else {
updateNewPropertyFromCache({
key: newPropertyKey || propertyKey,
value: propertyValue,
});
}
};
return (
<>
{Object.keys(props.value || {}).map(key => {
const property = (value[key] || {}) as JsonSchema;
return (
<PropertyEdit
key={key}
propertyKey={key}
useFx={props.useFx}
value={property}
disabled={readonly}
onChange={updateProperty}
onDelete={() => {
const newValue = { ...value };
delete newValue[key];
props.onChange(newValue);
}}
/>
);
})}
{newPropertyVisible && (
<PropertyEdit
propertyKey={newProperty.key}
value={newProperty.value}
useFx={props.useFx}
onChange={updateNewProperty}
onDelete={() => {
const key = newProperty.key;
// after onblur
setTimeout(() => {
const newValue = { ...value };
delete newValue[key];
props.onChange(newValue);
clearCache();
}, 10);
}}
/>
)}
{!readonly && (
<div>
<Button
theme="borderless"
icon={<IconPlus />}
onClick={() => setNewPropertyVisible(true)}
>
Add
</Button>
</div>
)}
</>
);
};

View File

@ -0,0 +1,72 @@
import React, { useState, useLayoutEffect } from 'react';
import { Input, Button } from '@douyinfe/semi-ui';
import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { TypeSelector } from '../type-selector';
import { JsonSchema } from '../../typings';
import { VariableSelector } from '../../plugins/variable-plugin/variable-selector';
import { LeftColumn, Row } from './styles';
export interface PropertyEditProps {
propertyKey: string;
value: JsonSchema;
useFx?: boolean;
disabled?: boolean;
onChange: (value: JsonSchema, propertyKey: string, newPropertyKey?: string) => void;
onDelete?: () => void;
}
export const PropertyEdit: React.FC<PropertyEditProps> = props => {
const { value, disabled } = props;
const [inputKey, updateKey] = useState(props.propertyKey);
const updateProperty = (key: keyof JsonSchema, val: any) => {
value[key] = val;
props.onChange(value, props.propertyKey);
};
useLayoutEffect(() => {
updateKey(props.propertyKey);
}, [props.propertyKey]);
return (
<Row>
<LeftColumn>
<TypeSelector
value={value.type}
disabled={disabled}
style={{ position: 'absolute', top: 6, left: 4, zIndex: 1 }}
onChange={val => updateProperty('type', val)}
/>
<Input
value={inputKey}
disabled={disabled}
onChange={v => updateKey(v.trim())}
onBlur={() => {
if (inputKey !== '') {
props.onChange(value, props.propertyKey, inputKey);
} else {
updateKey(props.propertyKey);
}
}}
style={{ paddingLeft: 26 }}
/>
</LeftColumn>
{props.useFx ? (
<VariableSelector
value={value.default}
disabled={disabled}
onChange={val => updateProperty('default', val)}
style={{ flexGrow: 1, height: 32 }}
/>
) : (
<Input
disabled={disabled}
value={value.default}
onChange={val => updateProperty('default', val)}
/>
)}
{props.onDelete && !disabled && (
<Button theme="borderless" icon={<IconCrossCircleStroked />} onClick={props.onDelete} />
)}
</Row>
);
};

View File

@ -0,0 +1,15 @@
import styled from 'styled-components';
export const Row = styled.div`
display: flex;
justify-content: flex-start;
align-items: center;
font-size: 12px;
margin-bottom: 6px;
`;
export const LeftColumn = styled.div`
width: 300px;
margin-right: 10px;
position: relative;
`;

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Tag, Dropdown } from '@douyinfe/semi-ui';
import { VariableTypeIcons } from '../plugins/variable-plugin/icons';
export interface TypeSelectorProps {
value?: string;
disabled?: boolean;
onChange?: (value?: string) => void;
style?: React.CSSProperties;
}
const dropdownMenus = ['object', 'boolean', 'array', 'string', 'integer', 'number'];
export const TypeSelector: React.FC<TypeSelectorProps> = props => {
const { value, disabled } = props;
const icon = VariableTypeIcons[value as any];
return (
<Dropdown
trigger="hover"
position="bottomRight"
disabled={disabled}
render={
<Dropdown.Menu>
{dropdownMenus.map(key => (
<Dropdown.Item
key={key}
onClick={() => {
props.onChange?.(key);
}}
>
{VariableTypeIcons[key]}
<span style={{ paddingLeft: '4px' }}>{key}</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
}
>
<Tag
color="white"
style={props.style}
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{icon}
</Tag>
</Dropdown>
);
};

View File

@ -0,0 +1,49 @@
import styled from 'styled-components';
import { Tag, Tooltip } from '@douyinfe/semi-ui';
import { VariableTypeIcons, ArrayIcons } from '../plugins/variable-plugin/icons';
interface PropsType {
name?: string | JSX.Element;
type: string;
className?: string;
isArray?: boolean;
}
const TooltipContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
column-gap: 6px;
`;
export function TypeTag({ name, type, isArray, className }: PropsType) {
const icon = isArray ? ArrayIcons[type] : VariableTypeIcons[type];
return (
<Tooltip
content={
<TooltipContainer>
{icon} {type}
</TooltipContainer>
}
>
<Tag color="white" className={className} style={{ padding: 4, maxWidth: 450 }}>
{icon}
{name && (
<span
style={{
display: 'inline-block',
marginLeft: 4,
marginTop: -1,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{' '}
{name}
</span>
)}
</Tag>
</Tooltip>
);
}

View File

@ -0,0 +1 @@
export { useEditorProps } from './use-editor-props';

View File

@ -0,0 +1,241 @@
import { useMemo } from 'react';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { createGroupPlugin } from '@flowgram.ai/group-plugin';
import { defaultFixedSemiMaterials } from '@flowgram.ai/fixed-semi-materials';
import {
FixedLayoutProps,
FlowDocumentJSON,
FlowLayoutDefault,
FlowRendererKey,
ShortcutsRegistry,
} from '@flowgram.ai/fixed-layout-editor';
import { type FlowNodeRegistry } from '../typings';
import { shortcutGetter } from '../shortcuts';
import { GroupBoxHeader, GroupNode } from '../plugins/group-plugin';
import { createVariablePlugin, createClipboardPlugin } from '../plugins';
import { defaultFormMeta } from '../nodes';
import { SelectorBoxPopover } from '../components/selector-box-popover';
import NodeAdder from '../components/node-adder';
import BranchAdder from '../components/branch-adder';
import { BaseNode } from '../components/base-node';
import { DragNode } from '../components';
export function useEditorProps(
initialData: FlowDocumentJSON,
nodeRegistries: FlowNodeRegistry[],
): FixedLayoutProps {
return useMemo<FixedLayoutProps>(
() => ({
/**
* Whether to enable the background
*/
background: true,
/**
* Whether it is read-only or not, the node cannot be dragged in read-only mode
*/
readonly: false,
/**
* Initial data
*
*/
initialData,
/**
* Node registries
*
*/
nodeRegistries,
/**
* Get the default node registry, which will be merged with the 'nodeRegistries'
* nodeRegistries
*/
getNodeDefaultRegistry(type) {
return {
type,
meta: {
/**
* Default expanded
*
*/
defaultExpanded: true,
},
/**
* Default FormMeta
*
*/
formMeta: defaultFormMeta,
};
},
/**
* Set default layout
*/
defaultLayout: FlowLayoutDefault.VERTICAL_FIXED_LAYOUT, // or FlowLayoutDefault.HORIZONTAL_FIXED_LAYOUT
/**
* Style config
*/
// constants: {
// [ConstantKeys.NODE_SPACING]: 24,
// [ConstantKeys.INLINE_SPACING_BOTTOM]: 24,
// [ConstantKeys.INLINE_BLOCKS_INLINE_SPACING_BOTTOM]: 13,
// [ConstantKeys.ROUNDED_LINE_X_RADIUS]: 8,
// [ConstantKeys.ROUNDED_LINE_Y_RADIUS]: 10,
// [ConstantKeys.INLINE_BLOCKS_INLINE_SPACING_TOP]: 23,
// [ConstantKeys.INLINE_BLOCKS_PADDING_BOTTOM]: 30,
// [ConstantKeys.COLLAPSED_SPACING]: 10,
// [ConstantKeys.BASE_COLOR]: '#B8BCC1',
// [ConstantKeys.BASE_ACTIVATED_COLOR]: '#82A7FC',
// },
/**
* SelectBox config
*/
selectBox: {
SelectorBoxPopover,
},
// Config shortcuts
shortcuts: (registry: ShortcutsRegistry, ctx) => {
registry.addHandlers(...shortcutGetter.map(getter => getter(ctx)));
},
/**
* Drag/Drop config
*/
dragdrop: {
/**
* Callback when drag drop
*/
onDrop: (ctx, dropData) => {
// console.log(
// '>>> onDrop: ',
// dropData.dropNode.id,
// dropData.dragNodes.map(n => n.id),
// );
},
canDrop: (ctx, dropData) =>
// console.log(
// '>>> canDrop: ',
// dropData.isBranch,
// dropData.dropNode.id,
// dropData.dragNodes.map(n => n.id),
// );
true,
},
/**
* Redo/Undo enable
*/
history: {
enable: true,
enableChangeNode: true, // Listen Node engine data change
onApply(ctx, opt) {
// Listen change to trigger auto save
// console.log('auto save: ', ctx.document.toJSON(), opt);
},
},
/**
* Node engine enable, you can configure formMeta in the FlowNodeRegistry
*/
nodeEngine: {
enable: true,
},
/**
* Variable engine enable
*/
variableEngine: {
enable: true,
layout: 'fixed',
},
/**
* Materials, components can be customized based on the key
* @see @see https://github.com/coze-dev/flowgram.ai/blob/main/packages/materials/fixed-semi-materials/src/components/index.tsx
* key UI
*/
materials: {
renderNodes: {
...defaultFixedSemiMaterials,
[FlowRendererKey.ADDER]: NodeAdder,
[FlowRendererKey.BRANCH_ADDER]: BranchAdder,
[FlowRendererKey.DRAG_NODE]: DragNode,
},
renderDefaultNode: BaseNode, // node render
renderTexts: {
'loop-end-text': 'Loop End',
'loop-while-text': 'Condition Satisfied',
'loop-traverse-text': 'Loop',
},
},
/**
* Playground init
*/
onInit: ctx => {
/**
* Data can also be dynamically loaded via fromJSON
* fromJSON
*/
// ctx.document.fromJSON(initialData)
console.log('---- Playground Init ----');
},
/**
* Playground render
*/
onAllLayersRendered: ctx => {
setTimeout(() => {
ctx.playground.config.fitView(ctx.document.root.bounds.pad(30));
}, 10);
},
/**
* Playground dispose
*/
onDispose: () => {
console.log('---- Playground Dispose ----');
},
plugins: () => [
/**
* Minimap plugin
*
*/
createMinimapPlugin({
disableLayer: true,
enableDisplayAllNodes: true,
canvasStyle: {
canvasWidth: 182,
canvasHeight: 102,
canvasPadding: 50,
canvasBackground: 'rgba(245, 245, 245, 1)',
canvasBorderRadius: 10,
viewportBackground: 'rgba(235, 235, 235, 1)',
viewportBorderRadius: 4,
viewportBorderColor: 'rgba(201, 201, 201, 1)',
viewportBorderWidth: 1,
viewportBorderDashLength: 2,
nodeColor: 'rgba(255, 255, 255, 1)',
nodeBorderRadius: 2,
nodeBorderWidth: 0.145,
nodeBorderColor: 'rgba(6, 7, 9, 0.10)',
overlayColor: 'rgba(255, 255, 255, 0)',
},
inactiveDebounceTime: 1,
}),
/**
* Group plugin
*
*/
createGroupPlugin({
components: {
GroupBoxHeader,
GroupNode,
},
}),
/**
* Variable plugin
*
*/
createVariablePlugin({}),
/**
* Clipboard plugin
*
*/
createClipboardPlugin(),
],
}),
[],
);
}

View File

@ -0,0 +1 @@
export { Editor as DemoFixedLayout } from './editor';

View File

@ -0,0 +1,130 @@
import { FlowDocumentJSON } from './typings';
export const initialData: FlowDocumentJSON = {
nodes: [
{
id: 'start_0',
type: 'start',
blocks: [],
data: {
title: 'Start',
outputs: {
type: 'object',
properties: {
query: {
type: 'string',
default: 'Hello Flow.',
},
},
},
},
},
{
id: 'llm_0',
type: 'llm',
blocks: [],
data: {
title: 'LLM',
inputsValues: {
modelType: 'gpt-3.5-turbo',
temperature: 0.5,
systemPrompt: 'You are an AI assistant.',
prompt: '',
},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
properties: {
modelType: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
},
prompt: {
type: 'string',
},
},
},
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
},
{
id: 'loop_0',
type: 'loop',
data: {
title: 'Loop',
},
blocks: [
{
id: 'condition_0',
type: 'condition',
data: {
title: 'Condition',
},
blocks: [
{
id: 'branch_0',
type: 'block',
data: {
title: 'If_0',
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
blocks: [],
},
{
id: 'branch_1',
type: 'block',
data: {
title: 'If_1',
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
meta: {},
blocks: [],
},
],
},
],
},
{
id: 'end_0',
type: 'end',
blocks: [],
data: {
title: 'End',
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
],
};

View File

@ -0,0 +1,35 @@
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconIf from '../../assets/icon-if.png';
let id = 2;
export const BlockNodeRegistry: FlowNodeRegistry = {
type: 'block',
meta: {
copyDisable: true,
},
info: {
icon: iconIf,
description: 'Execute the branch when the condition is met.',
},
canAdd: () => false,
onAdd(ctx, from) {
return {
id: `if_${nanoid(5)}`,
type: 'block',
data: {
title: `If_${id++}`,
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'string',
},
},
},
},
};
},
};

View File

@ -0,0 +1,58 @@
import { nanoid } from 'nanoid';
import { FlowNodeSplitType } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import iconCondition from '../../assets/icon-condition.svg';
export const ConditionNodeRegistry: FlowNodeRegistry = {
extend: FlowNodeSplitType.DYNAMIC_SPLIT,
type: 'condition',
info: {
icon: iconCondition,
description:
'Connect multiple downstream branches. Only the corresponding branch will be executed if the set conditions are met.',
},
onAdd() {
return {
id: `condition_${nanoid(5)}`,
type: 'condition',
data: {
title: 'Condition',
},
blocks: [
{
id: nanoid(5),
type: 'block',
data: {
title: 'If_0',
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
},
{
id: nanoid(5),
type: 'block',
data: {
title: 'If_1',
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
},
],
};
},
};

View File

@ -0,0 +1,30 @@
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeJSON } from '../typings';
import { FormHeader, FormContent, FormInputs, FormOutputs } from '../form-components';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<FormInputs />
<FormOutputs />
</FormContent>
</>
);
export const defaultFormMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }) => (value ? undefined : 'Title is required'),
'inputsValues.*': ({ value, context, formValues, name }) => {
const valuePropetyKey = name.replace(/^inputsValues\./, '');
const required = formValues.inputs?.required || [];
if (required.includes(valuePropetyKey) && (value === '' || value === undefined)) {
return `${valuePropetyKey} is required`;
}
return undefined;
},
},
};

View File

@ -0,0 +1,38 @@
import {
Field,
FieldRenderProps,
FormRenderProps,
FormMeta,
ValidateTrigger,
} from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeJSON, JsonSchema } from '../../typings';
import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<Field
name="outputs.properties"
render={({
field: { value, onChange },
fieldState,
}: FieldRenderProps<Record<string, JsonSchema>>) => (
<>
<PropertiesEdit value={value} onChange={onChange} useFx={true} />
</>
)}
/>
<FormOutputs />
</FormContent>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
},
};

View File

@ -0,0 +1,47 @@
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconEnd from '../../assets/icon-end.jpg';
import { formMeta } from './form-meta';
export const EndNodeRegistry: FlowNodeRegistry = {
type: 'end',
meta: {
isNodeEnd: true, // Mark as end
selectable: false, // End node cannot select
copyDisable: true, // End node canot copy
},
info: {
icon: iconEnd,
description:
'The final node of the workflow, used to return the result information after the workflow is run.',
},
/**
* Render node via formMeta
*/
formMeta,
canAdd(ctx, from) {
// You can only add to the last node of the branch
return from.isLast;
},
canDelete(ctx, node) {
return node.parent === ctx.document.root;
},
onAdd(ctx, from) {
return {
id: `end_${nanoid()}`,
type: 'end',
data: {
title: 'End',
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
};
},
};

View File

@ -0,0 +1,18 @@
import { type FlowNodeRegistry } from '../typings';
import { StartNodeRegistry } from './start';
import { LoopNodeRegistry } from './loop';
import { LLMNodeRegistry } from './llm';
import { EndNodeRegistry } from './end';
import { ConditionNodeRegistry } from './condition';
import { BlockNodeRegistry } from './block';
export const FlowNodeRegistries: FlowNodeRegistry[] = [
StartNodeRegistry,
EndNodeRegistry,
ConditionNodeRegistry,
LLMNodeRegistry,
LoopNodeRegistry,
BlockNodeRegistry,
];
export { defaultFormMeta } from './default-form-meta';

View File

@ -0,0 +1,48 @@
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconLLM from '../../assets/icon-llm.jpg';
let index = 0;
export const LLMNodeRegistry: FlowNodeRegistry = {
type: 'llm',
info: {
icon: iconLLM,
description:
'Call the large language model and use variables and prompt words to generate responses.',
},
onAdd() {
return {
id: `llm_${nanoid(5)}`,
type: 'llm',
data: {
title: `LLM_${++index}`,
inputsValues: {},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
properties: {
modelType: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
},
prompt: {
type: 'string',
},
},
},
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
};
},
};

View File

@ -0,0 +1,22 @@
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconLoop from '../../assets/icon-loop.svg';
export const LoopNodeRegistry: FlowNodeRegistry = {
type: 'loop',
info: {
icon: iconLoop,
description:
'Used to repeatedly execute a series of tasks by setting the number of iterations and logic',
},
onAdd() {
return {
id: `loop_${nanoid(5)}`,
type: 'loop',
data: {
title: 'Loop',
},
};
},
};

View File

@ -0,0 +1,38 @@
import {
Field,
FieldRenderProps,
FormRenderProps,
FormMeta,
ValidateTrigger,
} from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeJSON, JsonSchema } from '../../typings';
import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<Field
name="outputs.properties"
render={({
field: { value, onChange },
fieldState,
}: FieldRenderProps<Record<string, JsonSchema>>) => (
<>
<PropertiesEdit value={value} onChange={onChange} />
</>
)}
/>
<FormOutputs />
</FormContent>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
},
};

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