feat: Add demo-nextjs-antd demo and free-antd-materials package (#366)
* feat: add free-antd-materials * feat: add demo-nextjs-antd * fix(free-antd-materials): pass ts tests * chore: rename free-antd-materials to form-antd-materials
2
apps/demo-nextjs-antd/.eslintignore
Normal file
@ -0,0 +1,2 @@
|
||||
.eslintrc.js
|
||||
src/editor/plugins/context-menu-plugin/context-menu-layer.tsx
|
||||
21
apps/demo-nextjs-antd/.eslintrc.js
Normal file
@ -0,0 +1,21 @@
|
||||
const { defineConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
parser: '@babel/eslint-parser',
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
parserOptions: {
|
||||
requireConfigFile: false,
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
plugins: ['json'],
|
||||
extends: ['next', 'next/core-web-vitals'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
});
|
||||
41
apps/demo-nextjs-antd/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
apps/demo-nextjs-antd/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
19
apps/demo-nextjs-antd/next.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import path from 'path';
|
||||
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@app': path.resolve(__dirname, 'src/app'),
|
||||
'@editor': path.resolve(__dirname, 'src/editor'),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
70
apps/demo-nextjs-antd/package.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@flowgram.ai/demo-nextjs-antd",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"public/",
|
||||
"src/",
|
||||
".eslintrc.js",
|
||||
".gitignore",
|
||||
"next.config.ts",
|
||||
"pnpm-lock.yaml",
|
||||
"postcss.config.mjs",
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint ./src --cache",
|
||||
"lint:fix": "eslint ./src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.25.4",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"next": "15.2.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"classnames": "^2.5.1",
|
||||
"server-only": "^0.0.1",
|
||||
"styled-components": "^5",
|
||||
"nanoid": "^4.0.2",
|
||||
"@ant-design/icons": "5.x",
|
||||
"@flowgram.ai/free-layout-editor": "workspace:*",
|
||||
"@flowgram.ai/free-snap-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-lines-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
|
||||
"@flowgram.ai/minimap-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-container-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-group-plugin": "workspace:*",
|
||||
"@flowgram.ai/form-antd-materials": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@types/styled-components": "^5",
|
||||
"typescript": "^5.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18",
|
||||
"@types/next": "^9.0.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^8.54.0",
|
||||
"@babel/eslint-parser": "~7.19.1",
|
||||
"eslint-plugin-json": "^4.0.1",
|
||||
"eslint-plugin-next": "0.0.0",
|
||||
"eslint-config-next": "^15.3.1",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"sass": "^1.89.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
4849
apps/demo-nextjs-antd/pnpm-lock.yaml
generated
Normal file
5
apps/demo-nextjs-antd/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
apps/demo-nextjs-antd/public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
apps/demo-nextjs-antd/public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
apps/demo-nextjs-antd/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
apps/demo-nextjs-antd/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
apps/demo-nextjs-antd/public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
BIN
apps/demo-nextjs-antd/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
26
apps/demo-nextjs-antd/src/app/globals.css
Normal file
@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
30
apps/demo-nextjs-antd/src/app/layout.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Workflow Demo',
|
||||
description: 'Workflow Demo Next.js',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
6
apps/demo-nextjs-antd/src/app/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
import { EditorClient } from '@editor/index';
|
||||
|
||||
export default function Home() {
|
||||
return <EditorClient />;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
export const IconAutoLayout = (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 2C2.44772 2 2 2.44771 2 3V12C2 12.5523 2.44772 13 3 13H10C10.5523 13 11 12.5523 11 12V3C11 2.44772 10.5523 2 10 2H3zM4 11V4H9V11H4zM21 22C21.5523 22 22 21.5523 22 21V12C22 11.4477 21.5523 11 21 11H14C13.4477 11 13 11.4477 13 12V21C13 21.5523 13.4477 22 14 22H21zM20 13V20H15V13H20zM2 16C2 15.4477 2.44772 15 3 15H10C10.5523 15 11 15.4477 11 16V21C11 21.5523 10.5523 22 10 22H3C2.44772 22 2 21.5523 2 21V16zM4 20V17H9V20H4zM21 9C21.5523 9 22 8.55228 22 8V3C22 2.44772 21.5523 2 21 2H14C13.4477 2 13 2.44772 13 3V8C13 8.55228 13.4477 9 14 9H21zM20 4V7H15V4H20z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
19
apps/demo-nextjs-antd/src/editor/assets/icon-comment.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { CSSProperties, FC } from 'react';
|
||||
|
||||
interface IconCommentProps {
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const IconComment: FC<IconCommentProps> = ({ style }) => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={style}
|
||||
>
|
||||
<path d="M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z"></path>
|
||||
<path d="M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z"></path>
|
||||
</svg>
|
||||
);
|
||||
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44" height="45" viewBox="0 0 44 45" fill="none" class="injected-svg" data-src="https://lf3-static.bytednsdoc.com/obj/eden-cn/uvpahtvabh_lm_zhhwh/ljhwZthlaukjlkulzlp/activity_icons/exclusive-split-0518.svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4705 14.0152C15.299 12.8436 15.299 10.944 16.4705 9.77244L20.7131 5.5297C21.8846 4.3581 23.784 4.3581 24.9556 5.5297L29.1981 9.77244C30.3697 10.944 30.3697 12.8436 29.1981 14.0152L25.1206 18.0929H32.6674C36.5334 18.0929 39.6674 21.2269 39.6674 25.0929V33.154V33.3271V37.154C39.6674 38.2585 38.7719 39.154 37.6674 39.154H33.6674C32.5628 39.154 31.6674 38.2585 31.6674 37.154V33.3271V33.154V26.0929H23.5948H15.6674V33.1327L17.2685 33.1244C18.8397 33.1163 19.6322 35.0156 18.5212 36.1266L12.7374 41.9103C12.0506 42.5971 10.9371 42.5971 10.2503 41.9103L4.52588 36.1859C3.42107 35.0811 4.19797 33.1917 5.76038 33.1837L7.66737 33.1739V25.0929C7.66737 21.227 10.8014 18.0929 14.6674 18.0929H20.5481L16.4705 14.0152Z" fill="url(#paint0_linear_2752_183702-7)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2752_183702-7" x1="38.52" y1="43.3915" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3370FF"/>
|
||||
<stop offset="0.997908" stop-color="#33A9FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/demo-nextjs-antd/src/editor/assets/icon-end.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/demo-nextjs-antd/src/editor/assets/icon-llm.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/demo-nextjs-antd/src/editor/assets/icon-loop.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
19
apps/demo-nextjs-antd/src/editor/assets/icon-minimap.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
export const IconMinimap = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g1">
|
||||
<path
|
||||
id="path1"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
d="M 18.09091 6.883101 L 5.409091 6.883101 L 5.409091 16.746737 L 10.664648 16.746737 C 10.927091 17.116341 11.30353 17.422749 11.792977 17.611004 L 12.664289 17.946156 L 12.744959 18.155828 L 5.409091 18.155828 C 4.630871 18.155828 4 17.524979 4 16.746737 L 4 6.883101 C 4 6.104881 4.630871 5.47401 5.409091 5.47401 L 18.09091 5.47401 C 18.86915 5.47401 19.5 6.104881 19.5 6.883101 L 19.5 12.52348 C 19.247208 11.883823 18.730145 11.365912 18.09091 11.111994 L 18.09091 6.883101 Z M 18.09091 18.155828 L 17.881165 18.155828 L 19.469212 14.368896 C 19.479921 14.343321 19.490206 14.317817 19.5 14.292241 L 19.5 16.746737 C 19.5 17.524979 18.86915 18.155828 18.09091 18.155828 Z"
|
||||
/>
|
||||
<path
|
||||
id="path2"
|
||||
fill="#000000"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 18.494614 13.960189 C 18.982441 12.796985 17.813459 11.628003 16.650255 12.11576 L 12.133272 14.01 C 10.962248 14.501069 10.987188 16.168798 12.172375 16.62464 L 13.482055 17.128389 L 13.985805 18.438068 C 14.441646 19.623184 16.109375 19.648125 16.600443 18.477171 L 18.494614 13.960189 Z M 17.19515 13.415224 L 15.30098 17.932205 L 14.79723 16.622526 C 14.654066 16.250385 14.359989 15.956307 13.987918 15.813213 L 12.678168 15.309464 L 17.19515 13.415224 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
36
apps/demo-nextjs-antd/src/editor/assets/icon-mouse.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
export function IconMouse(props: { width?: number; height?: number }) {
|
||||
const { width, height } = props;
|
||||
return (
|
||||
<svg
|
||||
width={width || 34}
|
||||
height={height || 52}
|
||||
viewBox="0 0 34 52"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M30.9998 16.6666V35.3333C30.9998 37.5748 30.9948 38.4695 30.9 39.1895C30.2108 44.4247 26.0912 48.5443 20.856 49.2335C20.1361 49.3283 19.2413 49.3333 16.9998 49.3333C14.7584 49.3333 13.8636 49.3283 13.1437 49.2335C7.90847 48.5443 3.78888 44.4247 3.09965 39.1895C3.00487 38.4695 2.99984 37.5748 2.99984 35.3333V16.6666C2.99984 14.4252 3.00487 13.5304 3.09965 12.8105C3.78888 7.57528 7.90847 3.45569 13.1437 2.76646C13.7232 2.69017 14.4159 2.67202 15.8332 2.66785V9.86573C14.4738 10.3462 13.4998 11.6426 13.4998 13.1666V17.8332C13.4998 19.3571 14.4738 20.6536 15.8332 21.1341V23.6666C15.8332 24.3109 16.3555 24.8333 16.9998 24.8333C17.6442 24.8333 18.1665 24.3109 18.1665 23.6666V21.1341C19.5259 20.6536 20.4998 19.3572 20.4998 17.8332V13.1666C20.4998 11.6426 19.5259 10.3462 18.1665 9.86571V2.66785C19.5837 2.67202 20.2765 2.69017 20.856 2.76646C26.0912 3.45569 30.2108 7.57528 30.9 12.8105C30.9948 13.5304 30.9998 14.4252 30.9998 16.6666ZM0.666504 16.6666C0.666504 14.4993 0.666504 13.4157 0.786276 12.5059C1.61335 6.22368 6.55687 1.28016 12.8391 0.453085C13.7489 0.333313 14.8325 0.333313 16.9998 0.333313C19.1671 0.333313 20.2508 0.333313 21.1605 0.453085C27.4428 1.28016 32.3863 6.22368 33.2134 12.5059C33.3332 13.4157 33.3332 14.4994 33.3332 16.6666V35.3333C33.3332 37.5006 33.3332 38.5843 33.2134 39.494C32.3863 45.7763 27.4428 50.7198 21.1605 51.5469C20.2508 51.6666 19.1671 51.6666 16.9998 51.6666C14.8325 51.6666 13.7489 51.6666 12.8391 51.5469C6.55687 50.7198 1.61335 45.7763 0.786276 39.494C0.666504 38.5843 0.666504 37.5006 0.666504 35.3333V16.6666ZM15.8332 13.1666C15.8332 13.0011 15.8676 12.8437 15.9297 12.7011C15.9886 12.566 16.0722 12.4443 16.1749 12.3416C16.386 12.1305 16.6777 11.9999 16.9998 11.9999C17.6435 11.9999 18.1654 12.5212 18.1665 13.1646L18.1665 13.1666V17.8332L18.1665 17.8353C18.1665 17.8364 18.1665 17.8376 18.1665 17.8387C18.1661 17.9132 18.1588 17.986 18.1452 18.0565C18.0853 18.3656 17.9033 18.6312 17.6515 18.8011C17.4655 18.9266 17.2412 18.9999 16.9998 18.9999C16.3555 18.9999 15.8332 18.4776 15.8332 17.8332V13.1666Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconMouseTool = () => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.5 8C4.5 4.13401 7.63401 1 11.5 1H12.5C16.366 1 19.5 4.13401 19.5 8V17C19.5 20.3137 16.8137 23 13.5 23H10.5C7.18629 23 4.5 20.3137 4.5 17V8ZM11.2517 3.00606C8.60561 3.13547 6.5 5.32184 6.5 8V17C6.5 19.2091 8.29086 21 10.5 21H13.5C15.7091 21 17.5 19.2091 17.5 17V8C17.5 5.32297 15.3962 3.13732 12.7517 3.00622V5.28013C13.2606 5.54331 13.6074 6.06549 13.6074 6.66669V8.75759C13.6074 9.35879 13.2606 9.88097 12.7517 10.1441V11.4091C12.7517 11.8233 12.4159 12.1591 12.0017 12.1591C11.5875 12.1591 11.2517 11.8233 11.2517 11.4091V10.1457C10.7411 9.88298 10.3931 9.35994 10.3931 8.75759V6.66669C10.3931 6.06433 10.7411 5.5413 11.2517 5.27862V3.00606ZM12.0017 6.14397C11.7059 6.14397 11.466 6.38381 11.466 6.67968V8.74462C11.466 9.03907 11.7036 9.27804 11.9975 9.28031L12.0002 9.28032C12.0456 9.28032 12.0896 9.27482 12.1316 9.26447C12.3401 9.21256 12.5002 9.0386 12.5318 8.82287C12.5345 8.80149 12.5359 8.7797 12.5359 8.75759V6.66669C12.5359 6.64463 12.5345 6.62288 12.5318 6.60154C12.4999 6.38354 12.3368 6.20817 12.1252 6.15826C12.0856 6.14891 12.0442 6.14397 12.0017 6.14397Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
51
apps/demo-nextjs-antd/src/editor/assets/icon-pad.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
export function IconPad(props: { width?: number; height?: number }) {
|
||||
const { width, height } = props;
|
||||
return (
|
||||
<svg
|
||||
width={width || 48}
|
||||
height={height || 38}
|
||||
viewBox="0 0 48 38"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="1.83317"
|
||||
y="1.49998"
|
||||
width="44.3333"
|
||||
height="35"
|
||||
rx="3.5"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.8"
|
||||
strokeWidth="2.33333"
|
||||
/>
|
||||
<path
|
||||
d="M14.6665 30.6667H33.3332"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.8"
|
||||
strokeWidth="2.33333"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconPadTool = () => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z"
|
||||
></path>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
BIN
apps/demo-nextjs-antd/src/editor/assets/icon-start.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
10
apps/demo-nextjs-antd/src/editor/assets/icon-switch-line.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
export const IconSwitchLine = (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
id="switch-line"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
d="M 12.728118 10.060962 C 13.064282 8.716098 14.272528 7.772551 15.65877 7.772343 L 17.689898 7.772343 C 18.0798 7.772343 18.39588 7.456264 18.39588 7.066362 C 18.39588 6.676458 18.0798 6.36038 17.689898 6.36038 L 15.659616 6.36038 C 13.62515 6.360315 11.851767 7.745007 11.358504 9.718771 C 11.02234 11.063635 9.814095 12.007183 8.427853 12.007389 L 7.101437 12.007389 C 6.711768 12.007389 6.395878 12.323277 6.395878 12.712947 C 6.395878 13.102616 6.711768 13.418506 7.101437 13.418506 L 8.426159 13.418506 C 9.812716 13.418323 11.021417 14.361954 11.357657 15.707124 C 11.850921 17.680887 13.624304 19.065578 15.65877 19.065516 L 17.689049 19.065516 C 18.078953 19.065516 18.395033 18.749435 18.395033 18.359533 C 18.395033 17.969631 18.078953 17.653551 17.689049 17.653551 L 15.65877 17.653551 C 14.272528 17.653345 13.064282 16.709797 12.728118 15.364932 C 12.454905 14.27114 11.774856 13.322707 10.826583 12.712947 C 11.774536 12.10303 12.454268 11.154617 12.727271 10.060962 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@ -0,0 +1,27 @@
|
||||
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { NodeRenderContext } from '@editor/context';
|
||||
import { ErrorIcon } from './styles';
|
||||
import { NodeWrapper } from './node-wrapper';
|
||||
|
||||
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
|
||||
/**
|
||||
* Provides methods related to node rendering
|
||||
* 提供节点渲染相关的方法
|
||||
*/
|
||||
const nodeRender = useNodeRender();
|
||||
/**
|
||||
* It can only be used when nodeEngine is enabled
|
||||
* 只有在节点引擎开启时候才能使用表单
|
||||
*/
|
||||
const form = nodeRender.form;
|
||||
|
||||
return (
|
||||
<NodeRenderContext.Provider value={nodeRender}>
|
||||
<NodeWrapper>
|
||||
{form?.state.invalid && <ErrorIcon />}
|
||||
{form?.render()}
|
||||
</NodeWrapper>
|
||||
</NodeRenderContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
.node-wrapper {
|
||||
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;
|
||||
min-width: 360px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
&.selected {
|
||||
border: 1px solid #4e40e5;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
|
||||
import { useClientContext, WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { SidebarContext } from '@editor/context';
|
||||
import { useNodeRenderContext } from '../../hooks';
|
||||
import { scrollToView } from './utils';
|
||||
import './node-wrapper.scss';
|
||||
|
||||
// import { NodeWrapperStyle } from "./styles";
|
||||
|
||||
export interface NodeWrapperProps {
|
||||
isScrollToView?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for drag-and-drop/click events and ports rendering of nodes
|
||||
* 用于节点的拖拽/点击事件和点位渲染
|
||||
*/
|
||||
export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
|
||||
// IMPORTANT 这里写了如何处理node的数据
|
||||
const { children, isScrollToView = false } = props;
|
||||
const nodeRender = useNodeRenderContext();
|
||||
const { selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur } = nodeRender;
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const sidebar = useContext(SidebarContext);
|
||||
const form = nodeRender.form;
|
||||
const ctx = useClientContext();
|
||||
|
||||
const portsRender = ports.map((p) => <WorkflowPortRender key={p.id} entity={p} />);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`node-wrapper ${selected ? 'selected' : ''}`}
|
||||
ref={nodeRef}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
startDrag(e);
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
selectNode(e);
|
||||
if (!isDragging) {
|
||||
sidebar.setNodeId(nodeRender.node.id);
|
||||
// 可选:将 isScrollToView 设为 true,可以让节点选中后滚动到画布中间
|
||||
// Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected.
|
||||
if (isScrollToView) {
|
||||
scrollToView(ctx, nodeRender.node);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => setIsDragging(false)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
data-node-selected={String(selected)}
|
||||
style={{
|
||||
outline: form?.state.invalid ? '1px solid red' : 'none',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{portsRender}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
export const ErrorIcon = () => (
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: 'red',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -0,0 +1,18 @@
|
||||
import { FlowNodeEntity, FreeLayoutPluginContext } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export function scrollToView(
|
||||
ctx: FreeLayoutPluginContext,
|
||||
node: FlowNodeEntity,
|
||||
sidebarWidth = 448
|
||||
) {
|
||||
const bounds = node.transform.bounds;
|
||||
ctx.playground.scrollToView({
|
||||
bounds,
|
||||
scrollDelta: {
|
||||
x: sidebarWidth / 2,
|
||||
y: 0,
|
||||
},
|
||||
zoom: 1,
|
||||
scrollToCenter: true,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const Editor = dynamic(() => import('./editor').then((module) => module.Editor), { ssr: false });
|
||||
|
||||
export const EditorClient = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
// only render <Editor /> in browser client
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Editor />;
|
||||
};
|
||||
23
apps/demo-nextjs-antd/src/editor/components/editor.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { EditorRenderer, FreeLayoutEditorProvider } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { SidebarProvider, SidebarRenderer } from '@editor/components/sidebar';
|
||||
import '@flowgram.ai/free-layout-editor/index.css';
|
||||
import { useEditorProps } from '../hooks/use-editor-props';
|
||||
import { nodeRegistries } from '../data/node-registries';
|
||||
import { initialData } from '../data/initial-data';
|
||||
import { Tools } from './tools';
|
||||
|
||||
export const Editor = () => {
|
||||
const editorProps = useEditorProps(initialData, nodeRegistries);
|
||||
return (
|
||||
<FreeLayoutEditorProvider {...editorProps}>
|
||||
<SidebarProvider>
|
||||
<Tools />
|
||||
<EditorRenderer className="mastra-workflow-editor" />
|
||||
<SidebarRenderer />
|
||||
</SidebarProvider>
|
||||
</FreeLayoutEditorProvider>
|
||||
);
|
||||
};
|
||||
37
apps/demo-nextjs-antd/src/editor/components/form-render.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Field } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export const FormRender = () => (
|
||||
<>
|
||||
<div className="w-full cursor-move">
|
||||
<Field<string> name="title">
|
||||
{({ field }) => <h1 className="text-xl font-bold">{field.value}</h1>}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="content flex flex-col gap-3">
|
||||
<Field<string> name="input">
|
||||
{({ field }) => (
|
||||
<div className="inline-flex justify-between w-full">
|
||||
<h2 className="text-lg">Input</h2>
|
||||
<input
|
||||
className="border border-gray-400 rounded"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field<string> name="output">
|
||||
{({ field }) => (
|
||||
<div className="inline-flex justify-between w-full">
|
||||
<h2 className="text-lg">Output</h2>
|
||||
<input
|
||||
className="border border-gray-400 rounded"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
100
apps/demo-nextjs-antd/src/editor/components/group/color.ts
Normal file
@ -0,0 +1,100 @@
|
||||
type GroupColor = {
|
||||
'50': string;
|
||||
'300': string;
|
||||
'400': string;
|
||||
};
|
||||
|
||||
export const defaultColor = 'Blue';
|
||||
|
||||
export const groupColors: Record<string, GroupColor> = {
|
||||
Red: {
|
||||
'50': '#fef2f2',
|
||||
'300': '#fca5a5',
|
||||
'400': '#f87171',
|
||||
},
|
||||
Orange: {
|
||||
'50': '#fff7ed',
|
||||
'300': '#fdba74',
|
||||
'400': '#fb923c',
|
||||
},
|
||||
Amber: {
|
||||
'50': '#fffbeb',
|
||||
'300': '#fcd34d',
|
||||
'400': '#fbbf24',
|
||||
},
|
||||
Yellow: {
|
||||
'50': '#fef9c3',
|
||||
'300': '#fde047',
|
||||
'400': '#facc15',
|
||||
},
|
||||
Lime: {
|
||||
'50': '#f7fee7',
|
||||
'300': '#bef264',
|
||||
'400': '#a3e635',
|
||||
},
|
||||
Green: {
|
||||
'50': '#f0fdf4',
|
||||
'300': '#86efac',
|
||||
'400': '#4ade80',
|
||||
},
|
||||
Emerald: {
|
||||
'50': '#ecfdf5',
|
||||
'300': '#6ee7b7',
|
||||
'400': '#34d399',
|
||||
},
|
||||
Teal: {
|
||||
'50': '#f0fdfa',
|
||||
'300': '#5eead4',
|
||||
'400': '#2dd4bf',
|
||||
},
|
||||
Cyan: {
|
||||
'50': '#ecfeff',
|
||||
'300': '#67e8f9',
|
||||
'400': '#22d3ee',
|
||||
},
|
||||
Sky: {
|
||||
'50': '#ecfeff',
|
||||
'300': '#7dd3fc',
|
||||
'400': '#38bdf8',
|
||||
},
|
||||
Blue: {
|
||||
'50': '#eff6ff',
|
||||
'300': '#93c5fd',
|
||||
'400': '#60a5fa',
|
||||
},
|
||||
Indigo: {
|
||||
'50': '#eef2ff',
|
||||
'300': '#a5b4fc',
|
||||
'400': '#818cf8',
|
||||
},
|
||||
Violet: {
|
||||
'50': '#f5f3ff',
|
||||
'300': '#c4b5fd',
|
||||
'400': '#a78bfa',
|
||||
},
|
||||
Purple: {
|
||||
'50': '#faf5ff',
|
||||
'300': '#d8b4fe',
|
||||
'400': '#c084fc',
|
||||
},
|
||||
Fuchsia: {
|
||||
'50': '#fdf4ff',
|
||||
'300': '#f0abfc',
|
||||
'400': '#e879f9',
|
||||
},
|
||||
Pink: {
|
||||
'50': '#fdf2f8',
|
||||
'300': '#f9a8d4',
|
||||
'400': '#f472b6',
|
||||
},
|
||||
Rose: {
|
||||
'50': '#fff1f2',
|
||||
'300': '#fda4af',
|
||||
'400': '#fb7185',
|
||||
},
|
||||
Gray: {
|
||||
'50': '#f9fafb',
|
||||
'300': '#d1d5db',
|
||||
'400': '#9ca3af',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import { CSSProperties, FC, useEffect } from 'react';
|
||||
|
||||
import { WorkflowNodeEntity, useWatch } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
import { defaultColor, groupColors } from '../color';
|
||||
|
||||
interface GroupBackgroundProps {
|
||||
node: WorkflowNodeEntity;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GroupBackground: FC<GroupBackgroundProps> = ({ node, style }) => {
|
||||
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
|
||||
const color = groupColors[colorName];
|
||||
|
||||
useEffect(() => {
|
||||
const styleElement = document.createElement('style');
|
||||
|
||||
// 使用独特的选择器
|
||||
const styleContent = `
|
||||
.workflow-group-render[data-group-id="${node.id}"] .workflow-group-background {
|
||||
border: 1px solid ${color['300']};
|
||||
}
|
||||
|
||||
.workflow-group-render.selected[data-group-id="${node.id}"] .workflow-group-background {
|
||||
border: 1px solid ${color['400']};
|
||||
}
|
||||
`;
|
||||
|
||||
styleElement.textContent = styleContent;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return () => {
|
||||
styleElement.remove();
|
||||
};
|
||||
}, [color]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-group-background"
|
||||
data-flow-editor-selectable="true"
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: `${color['300']}29`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Field } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
import { defaultColor, groupColors } from '../color';
|
||||
|
||||
export const GroupColor: FC = () => (
|
||||
<Field<string> name={GroupField.Color}>
|
||||
{({ field }) => {
|
||||
const colorName = field.value ?? defaultColor;
|
||||
return (
|
||||
<Popover
|
||||
placement="top"
|
||||
mouseLeaveDelay={300}
|
||||
content={
|
||||
<div className="workflow-group-color-palette">
|
||||
{Object.entries(groupColors).map(([name, color]) => (
|
||||
<Tooltip title={name} key={name} mouseEnterDelay={300}>
|
||||
<span
|
||||
className="workflow-group-color-item"
|
||||
key={name}
|
||||
style={{
|
||||
backgroundColor: color['300'],
|
||||
borderColor: name === colorName ? color['400'] : '#fff',
|
||||
}}
|
||||
onClick={() => field.onChange(name)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="workflow-group-color"
|
||||
style={{
|
||||
backgroundColor: groupColors[colorName]['300'],
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
);
|
||||
@ -0,0 +1,41 @@
|
||||
import type { CSSProperties, FC, MouseEvent, ReactNode } from 'react';
|
||||
|
||||
import { useWatch } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
import { defaultColor, groupColors } from '../color';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
onMouseDown: (e: MouseEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
children: ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GroupHeader: FC<GroupHeaderProps> = ({
|
||||
onMouseDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
children,
|
||||
style,
|
||||
}) => {
|
||||
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
|
||||
const color = groupColors[colorName];
|
||||
return (
|
||||
<div
|
||||
className="workflow-group-header"
|
||||
data-flow-editor-selectable="false"
|
||||
onMouseDown={onMouseDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: color['50'],
|
||||
borderColor: color['300'],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
interface IconGroupProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const IconGroup: FC<IconGroupProps> = ({ size }) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
id="group"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 0.009766 10 L 0.009766 9.990234 L 0 9.990234 L 0 7.5 L 1 7.5 L 1 9 L 2.5 9 L 2.5 10 L 0.009766 10 Z M 3.710938 10 L 3.710938 9 L 6.199219 9 L 6.199219 10 L 3.710938 10 Z M 7.5 10 L 7.5 9 L 9 9 L 9 7.5 L 10 7.5 L 10 9.990234 L 9.990234 9.990234 L 9.990234 10 L 7.5 10 Z M 0 6.289063 L 0 3.800781 L 1 3.800781 L 1 6.289063 L 0 6.289063 Z M 9 6.289063 L 9 3.800781 L 10 3.800781 L 10 6.289063 L 9 6.289063 Z M 0 2.5 L 0 0.009766 L 0.009766 0.009766 L 0.009766 0 L 2.5 0 L 2.5 1 L 1 1 L 1 2.5 L 0 2.5 Z M 9 2.5 L 9 1 L 7.5 1 L 7.5 0 L 9.990234 0 L 9.990234 0.009766 L 10 0.009766 L 10 2.5 L 9 2.5 Z M 3.710938 1 L 3.710938 0 L 6.199219 0 L 6.199219 1 L 3.710938 1 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconUngroup: FC<IconGroupProps> = ({ size }) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
id="ungroup"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 9.654297 10.345703 L 8.808594 9.5 L 7.175781 9.5 L 7.175781 8.609375 L 7.917969 8.609375 L 1.390625 2.082031 L 1.390625 2.824219 L 0.5 2.824219 L 0.5 1.191406 L -0.345703 0.345703 L 0.283203 -0.283203 L 1.166016 0.599609 L 2.724609 0.599609 L 2.724609 1.490234 L 2.056641 1.490234 L 8.509766 7.943359 L 8.509766 7.275391 L 9.400391 7.275391 L 9.400391 8.833984 L 10.283203 9.716797 L 9.654297 10.345703 Z M 0.509766 9.5 L 0.509766 9.490234 L 0.5 9.490234 L 0.5 7.275391 L 1.390625 7.275391 L 1.390625 8.609375 L 2.724609 8.609375 L 2.724609 9.5 L 0.509766 9.5 Z M 3.802734 9.5 L 3.802734 8.609375 L 6.017578 8.609375 L 6.017578 9.5 L 3.802734 9.5 Z M 0.5 6.197266 L 0.5 3.982422 L 1.390625 3.982422 L 1.390625 6.197266 L 0.5 6.197266 Z M 8.509766 6.197266 L 8.509766 3.982422 L 9.400391 3.982422 L 9.400391 6.197266 L 8.509766 6.197266 Z M 8.509766 2.824219 L 8.509766 1.490234 L 7.175781 1.490234 L 7.175781 0.599609 L 9.390625 0.599609 L 9.390625 0.609375 L 9.400391 0.609375 L 9.400391 2.824219 L 8.509766 2.824219 Z M 3.802734 1.490234 L 3.802734 0.599609 L 6.017578 0.599609 L 6.017578 1.490234 L 3.802734 1.490234 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@ -0,0 +1,2 @@
|
||||
export { GroupNodeRender } from './node-render';
|
||||
export { IconGroup } from './icon-group';
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
FlowNodeFormData,
|
||||
Form,
|
||||
FormModelV2,
|
||||
useNodeRender,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { useNodeSize } from '@flowgram.ai/free-container-plugin';
|
||||
|
||||
import { HEADER_HEIGHT, HEADER_PADDING } from '../constant';
|
||||
import { UngroupButton } from './ungroup';
|
||||
import { GroupTools } from './tools';
|
||||
import { GroupTips } from './tips';
|
||||
import { GroupHeader } from './header';
|
||||
import { GroupBackground } from './background';
|
||||
|
||||
export const GroupNodeRender = () => {
|
||||
const { node, selected, selectNode, nodeRef, startDrag, onFocus, onBlur } = useNodeRender();
|
||||
const nodeSize = useNodeSize();
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
const formControl = formModel?.formControl;
|
||||
|
||||
const { height, width } = nodeSize ?? {};
|
||||
const nodeHeight = height ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
// prevent lines in outside cannot be selected - 防止外层线条不可选中
|
||||
const element = node.renderData.node;
|
||||
element.style.pointerEvents = 'none';
|
||||
}, [node]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`workflow-group-render ${selected ? 'selected' : ''}`}
|
||||
ref={nodeRef}
|
||||
data-group-id={node.id}
|
||||
data-node-selected={String(selected)}
|
||||
onMouseDown={selectNode}
|
||||
onClick={(e) => {
|
||||
selectNode(e);
|
||||
}}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
<Form control={formControl}>
|
||||
<>
|
||||
<GroupHeader
|
||||
onMouseDown={(e) => {
|
||||
startDrag(e);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
height: HEADER_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<GroupTools />
|
||||
</GroupHeader>
|
||||
<GroupTips />
|
||||
<UngroupButton node={node} />
|
||||
<GroupBackground
|
||||
node={node}
|
||||
style={{
|
||||
top: HEADER_HEIGHT + HEADER_PADDING,
|
||||
height: nodeHeight - HEADER_HEIGHT - HEADER_PADDING,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- no need */
|
||||
|
||||
const STORAGE_KEY = 'workflow-move-into-group-tip-visible';
|
||||
const STORAGE_VALUE = 'false';
|
||||
|
||||
export class TipsGlobalStore {
|
||||
private static _instance?: TipsGlobalStore;
|
||||
|
||||
public static get instance(): TipsGlobalStore {
|
||||
if (!this._instance) {
|
||||
this._instance = new TipsGlobalStore();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private closed = false;
|
||||
|
||||
public isClosed(): boolean {
|
||||
return this.isCloseForever() || this.closed;
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
public isCloseForever(): boolean {
|
||||
return localStorage.getItem(STORAGE_KEY) === STORAGE_VALUE;
|
||||
}
|
||||
|
||||
public closeForever(): void {
|
||||
localStorage.setItem(STORAGE_KEY, STORAGE_VALUE);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export const IconClose = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
|
||||
<path
|
||||
fill="#060709"
|
||||
fillOpacity="0.5"
|
||||
d="M12.13 12.128a.5.5 0 0 0 .001-.706L8.71 8l3.422-3.423a.5.5 0 0 0-.001-.705.5.5 0 0 0-.706-.002L8.002 7.293 4.579 3.87a.5.5 0 0 0-.705.002.5.5 0 0 0-.002.705L7.295 8l-3.423 3.422a.5.5 0 0 0 .002.706c.195.195.51.197.705.001l3.423-3.422 3.422 3.422c.196.196.51.194.706-.001"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
@ -0,0 +1,36 @@
|
||||
import { useControlTips } from './use-control';
|
||||
import { GroupTipsStyle } from './style';
|
||||
import { isMacOS } from './is-mac-os';
|
||||
import { IconClose } from './icon-close';
|
||||
|
||||
export const GroupTips = () => {
|
||||
const { visible, close, closeForever } = useControlTips();
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupTipsStyle className={'workflow-group-tips'}>
|
||||
<div className="container">
|
||||
<div className="content">
|
||||
<p className="text">{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}</p>
|
||||
<div
|
||||
className="space"
|
||||
style={{
|
||||
width: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<p className="close-forever" onClick={closeForever}>
|
||||
Never Remind
|
||||
</p>
|
||||
<div className="close" onClick={close}>
|
||||
<IconClose />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GroupTipsStyle>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);
|
||||
@ -0,0 +1,74 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const GroupTipsStyle = styled.div`
|
||||
position: absolute;
|
||||
top: 35px;
|
||||
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
|
||||
.container {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: rgb(255 255 255);
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
width: fit-content;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 20px;
|
||||
color: rgba(15, 21, 40, 82%);
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.space {
|
||||
width: 128px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
|
||||
.close-forever {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 0 3px;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 12px;
|
||||
color: rgba(32, 41, 69, 62%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
|
||||
import {
|
||||
NodeIntoContainerService,
|
||||
NodeIntoContainerType,
|
||||
} from '@flowgram.ai/free-container-plugin';
|
||||
|
||||
import { TipsGlobalStore } from './global-store';
|
||||
|
||||
export const useControlTips = () => {
|
||||
const node = useCurrentEntity();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const globalStore = TipsGlobalStore.instance;
|
||||
|
||||
const nodeIntoContainerService = useService<NodeIntoContainerService>(NodeIntoContainerService);
|
||||
|
||||
const show = useCallback(() => {
|
||||
if (globalStore.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(true);
|
||||
}, [globalStore]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
globalStore.close();
|
||||
setVisible(false);
|
||||
}, [globalStore]);
|
||||
|
||||
const closeForever = useCallback(() => {
|
||||
globalStore.closeForever();
|
||||
close();
|
||||
}, [close, globalStore]);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听移入
|
||||
const inDisposer = nodeIntoContainerService.on((e) => {
|
||||
if (e.type !== NodeIntoContainerType.In) {
|
||||
return;
|
||||
}
|
||||
if (e.targetContainer === node) {
|
||||
show();
|
||||
}
|
||||
});
|
||||
// 监听移出事件
|
||||
const outDisposer = nodeIntoContainerService.on((e) => {
|
||||
if (e.type !== NodeIntoContainerType.Out) {
|
||||
return;
|
||||
}
|
||||
if (e.sourceContainer === node && !node.blocks.length) {
|
||||
setVisible(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
inDisposer.dispose();
|
||||
outDisposer.dispose();
|
||||
};
|
||||
}, [nodeIntoContainerService, node, show, close, visible]);
|
||||
|
||||
return {
|
||||
visible,
|
||||
close,
|
||||
closeForever,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import { Field } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
|
||||
export const GroupTitle: FC = () => {
|
||||
const [inputting, setInputting] = useState(false);
|
||||
return (
|
||||
<Field<string> name={GroupField.Title}>
|
||||
{({ field }) =>
|
||||
inputting ? (
|
||||
<Input
|
||||
autoFocus
|
||||
className="workflow-group-title-input"
|
||||
size="small"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onBlur={() => setInputting(false)}
|
||||
draggable={false}
|
||||
onSubmit={() => setInputting(false)}
|
||||
/>
|
||||
) : (
|
||||
<p className="workflow-group-title" onDoubleClick={() => setInputting(true)}>
|
||||
{field.value ?? 'Group'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
|
||||
import { GroupTitle } from './title';
|
||||
import { GroupColor } from './color';
|
||||
|
||||
export const GroupTools: FC = () => (
|
||||
<div className="workflow-group-tools">
|
||||
<HolderOutlined className="workflow-group-tools-drag" />
|
||||
<GroupTitle />
|
||||
<GroupColor />
|
||||
</div>
|
||||
);
|
||||
@ -0,0 +1,30 @@
|
||||
import { CSSProperties, FC } from 'react';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { CommandRegistry, WorkflowNodeEntity, useService } from '@flowgram.ai/free-layout-editor';
|
||||
import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
|
||||
|
||||
import { IconUngroup } from './icon-group';
|
||||
|
||||
interface UngroupButtonProps {
|
||||
node: WorkflowNodeEntity;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const UngroupButton: FC<UngroupButtonProps> = ({ node, style }) => {
|
||||
const commandRegistry = useService(CommandRegistry);
|
||||
return (
|
||||
<Tooltip title="Ungroup">
|
||||
<div className="workflow-group-ungroup" style={style}>
|
||||
<Button
|
||||
icon={<IconUngroup size={14} />}
|
||||
style={{ height: 30, width: 30 }}
|
||||
type="text"
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(WorkflowGroupCommand.Ungroup, node);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
export const HEADER_HEIGHT = 30;
|
||||
export const HEADER_PADDING = 5;
|
||||
|
||||
export enum GroupField {
|
||||
Title = 'title',
|
||||
Color = 'color',
|
||||
}
|
||||
109
apps/demo-nextjs-antd/src/editor/components/group/index.css
Normal file
@ -0,0 +1,109 @@
|
||||
.workflow-group-render {
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workflow-group-header {
|
||||
height: 30px;
|
||||
width: fit-content;
|
||||
background-color: #fefce8;
|
||||
border: 1px solid #facc15;
|
||||
border-radius: 8px;
|
||||
padding-right: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.workflow-group-ungroup {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
position: absolute;
|
||||
top: 35px;
|
||||
right: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.workflow-group-ungroup .ant-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-group-ungroup:hover .ant-btn {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.workflow-group-background {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
background-color: #fddf4729;
|
||||
border: 1px solid #fde047;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-group-render.selected .workflow-group-background {
|
||||
border: 1px solid #facc15;
|
||||
}
|
||||
|
||||
.workflow-group-tools {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
cursor: move;
|
||||
color: oklch(44.6% 0.043 257.281);
|
||||
font-size: 14px;
|
||||
}
|
||||
.workflow-group-title {
|
||||
margin: 0;
|
||||
max-width: 242px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-group-tools-drag {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.workflow-group-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: #fde047;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workflow-group-title-input {
|
||||
width: 242px;
|
||||
border: none;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.workflow-group-color-palette {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 24px);
|
||||
gap: 12px;
|
||||
margin: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.workflow-group-color-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #fde047;
|
||||
cursor: pointer;
|
||||
border: 3px solid;
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import './index.css';
|
||||
|
||||
export { GroupNodeRender } from './components';
|
||||
export { IconGroup } from './components';
|
||||
5
apps/demo-nextjs-antd/src/editor/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './line-add-button';
|
||||
export * from './node-panel';
|
||||
export * from './node-comment';
|
||||
export * from './group';
|
||||
export * from './selector-box-popover';
|
||||
@ -0,0 +1,26 @@
|
||||
export const IconPlusCircle = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="add">
|
||||
<path
|
||||
id="background"
|
||||
fill="#ffffff"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 24 12 C 24 5.372583 18.627417 0 12 0 C 5.372583 0 -0 5.372583 -0 12 C -0 18.627417 5.372583 24 12 24 C 18.627417 24 24 18.627417 24 12 Z"
|
||||
/>
|
||||
<path
|
||||
id="content"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 22 12.005 C 22 6.482153 17.522848 2.004999 12 2.004999 C 6.477152 2.004999 2 6.482153 2 12.005 C 2 17.527847 6.477152 22.004999 12 22.004999 C 17.522848 22.004999 22 17.527847 22 12.005 Z"
|
||||
/>
|
||||
<path
|
||||
id="cross"
|
||||
fill="#ffffff"
|
||||
stroke="none"
|
||||
d="M 11.411996 16.411797 C 11.411996 16.736704 11.675362 17 12.00023 17 C 12.325109 17 12.588474 16.736704 12.588474 16.411797 L 12.588474 12.58826 L 16.41201 12.58826 C 16.736919 12.58826 17.000216 12.324894 17.000216 12.000015 C 17.000216 11.675147 16.736919 11.411781 16.41201 11.411781 L 12.588474 11.411781 L 12.588474 7.588234 C 12.588474 7.263367 12.325109 7 12.00023 7 C 11.675362 7 11.411996 7.263367 11.411996 7.588234 L 11.411996 11.411781 L 7.588449 11.411781 C 7.263581 11.411781 7.000215 11.675147 7.000215 12.000015 C 7.000215 12.324894 7.263581 12.58826 7.588449 12.58826 L 11.411996 12.58826 L 11.411996 16.411797 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@ -0,0 +1,8 @@
|
||||
.line-add-button {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -60%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
import { IconPlusCircle } from './button';
|
||||
import './index.scss';
|
||||
import { useVisible } from './use-visible';
|
||||
|
||||
import {
|
||||
HistoryService,
|
||||
WorkflowDocument,
|
||||
WorkflowDragService,
|
||||
WorkflowLinesManager,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeJSON,
|
||||
delay,
|
||||
useService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { LineRenderProps } from '@flowgram.ai/free-lines-plugin';
|
||||
import {
|
||||
WorkflowNodePanelService,
|
||||
WorkflowNodePanelUtils,
|
||||
} from '@flowgram.ai/free-node-panel-plugin';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const LineAddButton = (props: LineRenderProps) => {
|
||||
const { line, selected, hovered, color } = props;
|
||||
const visible = useVisible({ line, selected, hovered });
|
||||
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
|
||||
const document = useService(WorkflowDocument);
|
||||
const dragService = useService(WorkflowDragService);
|
||||
const linesManager = useService(WorkflowLinesManager);
|
||||
const historyService = useService(HistoryService);
|
||||
|
||||
const { fromPort, toPort } = line;
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
// calculate the middle point of the line - 计算线条的中点位置
|
||||
const position = {
|
||||
x: (line.position.from.x + line.position.to.x) / 2,
|
||||
y: (line.position.from.y + line.position.to.y) / 2,
|
||||
};
|
||||
|
||||
// get container node for the new node - 获取新节点的容器节点
|
||||
const containerNode = WorkflowNodePanelUtils.getContainerNode({
|
||||
fromPort,
|
||||
});
|
||||
|
||||
// show node selection panel - 显示节点选择面板
|
||||
const result = await nodePanelService.singleSelectNodePanel({
|
||||
position,
|
||||
containerNode,
|
||||
panelProps: {
|
||||
enableScrollClose: true,
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodeType, nodeJSON } = result;
|
||||
|
||||
// adjust position for the new node - 调整新节点的位置
|
||||
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
|
||||
nodeType,
|
||||
position,
|
||||
fromPort,
|
||||
toPort,
|
||||
containerNode,
|
||||
document,
|
||||
dragService,
|
||||
});
|
||||
|
||||
// create new workflow node - 创建新的工作流节点
|
||||
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
|
||||
nodeType,
|
||||
nodePosition,
|
||||
nodeJSON ?? ({} as WorkflowNodeJSON),
|
||||
containerNode?.id
|
||||
);
|
||||
|
||||
// auto offset subsequent nodes - 自动偏移后续节点
|
||||
if (fromPort && toPort) {
|
||||
WorkflowNodePanelUtils.subNodesAutoOffset({
|
||||
node,
|
||||
fromPort,
|
||||
toPort,
|
||||
containerNode,
|
||||
historyService,
|
||||
dragService,
|
||||
linesManager,
|
||||
});
|
||||
}
|
||||
|
||||
// wait for node render - 等待节点渲染
|
||||
await delay(20);
|
||||
|
||||
// build connection lines - 构建连接线
|
||||
WorkflowNodePanelUtils.buildLine({
|
||||
fromPort,
|
||||
node,
|
||||
toPort,
|
||||
linesManager,
|
||||
});
|
||||
|
||||
// remove original line - 移除原始线条
|
||||
line.dispose();
|
||||
}, []);
|
||||
|
||||
if (!visible) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="line-add-button"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
color,
|
||||
}}
|
||||
data-testid="sdk.workflow.canvas.line.add"
|
||||
data-line-id={line.id}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconPlusCircle />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import './index.scss';
|
||||
import { WorkflowLineEntity, usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export const useVisible = (params: {
|
||||
line: WorkflowLineEntity;
|
||||
selected?: boolean;
|
||||
hovered?: boolean;
|
||||
}): boolean => {
|
||||
const playground = usePlayground();
|
||||
const { line, selected = false, hovered } = params;
|
||||
if (line.disposed) {
|
||||
// 在 dispose 后,再去获取 line.to | line.from 会导致错误创建端口
|
||||
return false;
|
||||
}
|
||||
if (playground.config.readonly) {
|
||||
return false;
|
||||
}
|
||||
if (!selected && !hovered) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import type { CommentEditorModel } from '../model';
|
||||
import { DragArea } from './drag-area';
|
||||
|
||||
interface IBlankArea {
|
||||
model: CommentEditorModel;
|
||||
}
|
||||
|
||||
export const BlankArea: FC<IBlankArea> = (props) => {
|
||||
const { model } = props;
|
||||
const playground = usePlayground();
|
||||
const { selectNode } = useNodeRender();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment-blank-area h-full w-full"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
model.setFocus(false);
|
||||
selectNode(e);
|
||||
playground.node.focus(); // 防止节点无法被删除
|
||||
}}
|
||||
onClick={(e) => {
|
||||
model.setFocus(true);
|
||||
model.selectEnd();
|
||||
}}
|
||||
>
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
model={model}
|
||||
stopEvent={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,115 @@
|
||||
import { type FC } from 'react';
|
||||
|
||||
import type { CommentEditorModel } from '../model';
|
||||
import { ResizeArea } from './resize-area';
|
||||
import { DragArea } from './drag-area';
|
||||
|
||||
interface IBorderArea {
|
||||
model: CommentEditorModel;
|
||||
overflow: boolean;
|
||||
onResize?: () => {
|
||||
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
|
||||
resizeEnd: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const BorderArea: FC<IBorderArea> = (props) => {
|
||||
const { model, overflow, onResize } = props;
|
||||
|
||||
return (
|
||||
<div style={{ zIndex: 999 }}>
|
||||
{/* 左边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -10,
|
||||
top: 10,
|
||||
width: 20,
|
||||
height: 'calc(100% - 20px)',
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/* 右边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -10,
|
||||
top: 10,
|
||||
height: 'calc(100% - 20px)',
|
||||
width: overflow ? 10 : 20, // 防止遮挡滚动条
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/* 上边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
left: 10,
|
||||
width: 'calc(100% - 20px)',
|
||||
height: 20,
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/* 下边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -10,
|
||||
left: 10,
|
||||
width: 'calc(100% - 20px)',
|
||||
height: 20,
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/** 左上角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
cursor: 'nwse-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
{/** 右上角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
cursor: 'nesw-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
{/** 右下角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
cursor: 'nwse-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
{/** 左下角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
cursor: 'nesw-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
import type { CSSProperties, FC, ReactNode } from 'react';
|
||||
|
||||
interface ICommentContainer {
|
||||
focused: boolean;
|
||||
children?: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const CommentContainer: FC<ICommentContainer> = (props) => {
|
||||
const { focused, children, style } = props;
|
||||
|
||||
const scrollbarStyle = {
|
||||
// 滚动条样式
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'rgb(159 159 158 / 65%) transparent',
|
||||
// 针对 WebKit 浏览器(如 Chrome、Safari)的样式
|
||||
'&:WebkitScrollbar': {
|
||||
width: '4px',
|
||||
},
|
||||
'&::WebkitScrollbarTrack': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::WebkitScrollbarThumb': {
|
||||
backgroundColor: 'rgb(159 159 158 / 65%)',
|
||||
borderRadius: '20px',
|
||||
border: '2px solid transparent',
|
||||
},
|
||||
} as unknown as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment-container"
|
||||
data-flow-editor-selectable="false"
|
||||
style={{
|
||||
// tailwind 不支持 outline 的样式,所以这里需要使用 style 来设置
|
||||
outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',
|
||||
backgroundColor: focused ? '#FFF3EA' : '#FFFBED',
|
||||
...scrollbarStyle,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,89 @@
|
||||
import { type FC, type WheelEventHandler, useEffect, useState } from 'react';
|
||||
|
||||
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import type { CommentEditorModel } from '../model';
|
||||
import { DragArea } from './drag-area';
|
||||
|
||||
interface IContentDragArea {
|
||||
model: CommentEditorModel;
|
||||
focused: boolean;
|
||||
overflow: boolean;
|
||||
}
|
||||
|
||||
export const ContentDragArea: FC<IContentDragArea> = (props) => {
|
||||
const { model, focused, overflow } = props;
|
||||
const playground = usePlayground();
|
||||
const { selectNode } = useNodeRender();
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 当编辑器失去焦点时,取消激活状态
|
||||
if (!focused) {
|
||||
setActive(false);
|
||||
}
|
||||
}, [focused]);
|
||||
|
||||
const handleWheel: WheelEventHandler<HTMLDivElement> = (e) => {
|
||||
const editorElement = model.element;
|
||||
if (active || !overflow || !editorElement) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
const maxScroll = editorElement.scrollHeight - editorElement.clientHeight;
|
||||
const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll);
|
||||
editorElement.scroll(0, newScrollTop);
|
||||
};
|
||||
|
||||
const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
|
||||
if (active) {
|
||||
return;
|
||||
}
|
||||
mouseDownEvent.preventDefault();
|
||||
mouseDownEvent.stopPropagation();
|
||||
model.setFocus(false);
|
||||
selectNode(mouseDownEvent);
|
||||
playground.node.focus(); // 防止节点无法被删除
|
||||
|
||||
const startX = mouseDownEvent.clientX;
|
||||
const startY = mouseDownEvent.clientY;
|
||||
|
||||
const handleMouseUp = (mouseMoveEvent: MouseEvent) => {
|
||||
const deltaX = mouseMoveEvent.clientX - startX;
|
||||
const deltaY = mouseMoveEvent.clientY - startY;
|
||||
// 判断是拖拽还是点击
|
||||
const delta = 5;
|
||||
if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {
|
||||
// 点击后隐藏
|
||||
setActive(true);
|
||||
}
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('click', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('click', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment-content-drag-area"
|
||||
onMouseDown={handleMouseDown}
|
||||
onWheel={handleWheel}
|
||||
style={{
|
||||
display: active ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
model={model}
|
||||
stopEvent={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
import { CSSProperties, type FC } from 'react';
|
||||
|
||||
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { type CommentEditorModel } from '../model';
|
||||
|
||||
interface IDragArea {
|
||||
model: CommentEditorModel;
|
||||
stopEvent?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const DragArea: FC<IDragArea> = (props) => {
|
||||
const { model, stopEvent = true, style } = props;
|
||||
|
||||
const playground = usePlayground();
|
||||
|
||||
const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment-drag-area"
|
||||
data-flow-editor-selectable="false"
|
||||
draggable={true}
|
||||
style={style}
|
||||
onMouseDown={(e) => {
|
||||
if (stopEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
model.setFocus(false);
|
||||
onStartDrag(e);
|
||||
selectNode(e);
|
||||
playground.node.focus(); // 防止节点无法被删除
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,60 @@
|
||||
import { type CSSProperties, type FC, useEffect, useRef } from 'react';
|
||||
|
||||
import { usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorModel } from '../model';
|
||||
import { CommentEditorEvent } from '../constant';
|
||||
|
||||
interface ICommentEditor {
|
||||
model: CommentEditorModel;
|
||||
style?: CSSProperties;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export const CommentEditor: FC<ICommentEditor> = (props) => {
|
||||
const { model, style, onChange } = props;
|
||||
const playground = usePlayground();
|
||||
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const placeholder = model.value || model.focused ? undefined : 'Enter a comment...';
|
||||
|
||||
// 同步编辑器内部值变化
|
||||
useEffect(() => {
|
||||
const disposer = model.on((params) => {
|
||||
if (params.type !== CommentEditorEvent.Change) {
|
||||
return;
|
||||
}
|
||||
onChange?.(model.value);
|
||||
});
|
||||
return () => disposer.dispose();
|
||||
}, [model, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
model.element = editorRef.current;
|
||||
}, [editorRef]);
|
||||
|
||||
return (
|
||||
<div className="workflow-comment-editor">
|
||||
<p className="workflow-comment-editor-placeholder">{placeholder}</p>
|
||||
<textarea
|
||||
className="workflow-comment-editor-textarea"
|
||||
ref={editorRef}
|
||||
style={style}
|
||||
readOnly={playground.config.readonly}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
model.setValue(value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
model.setFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
model.setFocus(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,103 @@
|
||||
.workflow-comment {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 120px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.workflow-comment-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
outline: 1px solid;
|
||||
padding: 6px 2px 6px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workflow-comment-drag-area {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.workflow-comment-content-drag-area {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: calc(100% - 22px);
|
||||
}
|
||||
|
||||
.workflow-comment-resize-area {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.workflow-comment-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workflow-comment-editor-placeholder {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
color: rgba(55, 67, 106, 0.38);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-comment-editor-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.workflow-comment-more-button {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.workflow-comment-more-button > .ant-btn {
|
||||
color: rgba(255, 255, 255, 0);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.workflow-comment-more-button > .ant-btn:hover {
|
||||
color: #ffa100;
|
||||
background: #fbf2d2cc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.workflow-comment-more-button-focused > .ant-btn:hover {
|
||||
color: #ff811a;
|
||||
background: #ffe3cecc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.workflow-comment-more-button > .ant-btn:active {
|
||||
color: #f2b600;
|
||||
background: #ede5c7cc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.workflow-comment-more-button-focused > .ant-btn:active {
|
||||
color: #ff811a;
|
||||
background: #eed5c1cc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import './index.css';
|
||||
|
||||
export { CommentRender } from './render';
|
||||
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { FC } from 'react';
|
||||
|
||||
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { NodeMenu } from '@editor/components/node-menu';
|
||||
|
||||
interface IMoreButton {
|
||||
node: WorkflowNodeEntity;
|
||||
focused: boolean;
|
||||
deleteNode: () => void;
|
||||
}
|
||||
|
||||
export const MoreButton: FC<IMoreButton> = ({ node, focused, deleteNode }) => (
|
||||
<div
|
||||
className={`workflow-comment-more-button ${
|
||||
focused ? 'workflow-comment-more-button-focused' : ''
|
||||
}`}
|
||||
>
|
||||
<NodeMenu node={node} deleteNode={deleteNode} updateTitleEdit={() => {}} />
|
||||
</div>
|
||||
);
|
||||
@ -0,0 +1,78 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldRenderProps,
|
||||
FlowNodeFormData,
|
||||
Form,
|
||||
FormModelV2,
|
||||
WorkflowNodeEntity,
|
||||
useNodeRender,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { useOverflow } from '../hooks/use-overflow';
|
||||
import { useModel } from '../hooks/use-model';
|
||||
import { useSize } from '../hooks';
|
||||
import { CommentEditorFormField } from '../constant';
|
||||
import { MoreButton } from './more-button';
|
||||
import { CommentEditor } from './editor';
|
||||
import { ContentDragArea } from './content-drag-area';
|
||||
import { CommentContainer } from './container';
|
||||
import { BorderArea } from './border-area';
|
||||
|
||||
export const CommentRender: FC<{
|
||||
node: WorkflowNodeEntity;
|
||||
}> = (props) => {
|
||||
const { node } = props;
|
||||
const model = useModel();
|
||||
|
||||
const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();
|
||||
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
const formControl = formModel?.formControl;
|
||||
|
||||
const { width, height, onResize } = useSize();
|
||||
const { overflow, updateOverflow } = useOverflow({ model, height });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment"
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
ref={nodeRef}
|
||||
data-node-selected={String(focused)}
|
||||
onMouseEnter={updateOverflow}
|
||||
onMouseDown={(e) => {
|
||||
setTimeout(() => {
|
||||
// 防止 selectNode 拦截事件,导致 slate 编辑器无法聚焦
|
||||
selectNode(e);
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay
|
||||
}, 20);
|
||||
}}
|
||||
>
|
||||
<Form control={formControl}>
|
||||
<>
|
||||
{/* 背景 */}
|
||||
<CommentContainer focused={focused} style={{ height }}>
|
||||
<Field name={CommentEditorFormField.Note}>
|
||||
{({ field }: FieldRenderProps<string>) => (
|
||||
<>
|
||||
{/** 编辑器 */}
|
||||
<CommentEditor model={model} value={field.value} onChange={field.onChange} />
|
||||
{/* 内容拖拽区域(点击后隐藏) */}
|
||||
<ContentDragArea model={model} focused={focused} overflow={overflow} />
|
||||
{/* 更多按钮 */}
|
||||
<MoreButton node={node} focused={focused} deleteNode={deleteNode} />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</CommentContainer>
|
||||
{/* 边框 */}
|
||||
<BorderArea model={model} overflow={overflow} onResize={onResize} />
|
||||
</>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,73 @@
|
||||
import { CSSProperties, type FC } from 'react';
|
||||
|
||||
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import type { CommentEditorModel } from '../model';
|
||||
|
||||
interface IResizeArea {
|
||||
model: CommentEditorModel;
|
||||
onResize?: () => {
|
||||
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
|
||||
resizeEnd: () => void;
|
||||
};
|
||||
getDelta?: (delta: { x: number; y: number }) => {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const ResizeArea: FC<IResizeArea> = (props) => {
|
||||
const { model, onResize, getDelta, style } = props;
|
||||
|
||||
const playground = usePlayground();
|
||||
|
||||
const { selectNode } = useNodeRender();
|
||||
|
||||
const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
|
||||
mouseDownEvent.preventDefault();
|
||||
mouseDownEvent.stopPropagation();
|
||||
if (!onResize) {
|
||||
return;
|
||||
}
|
||||
const { resizing, resizeEnd } = onResize();
|
||||
model.setFocus(false);
|
||||
selectNode(mouseDownEvent);
|
||||
playground.node.focus(); // 防止节点无法被删除
|
||||
|
||||
const startX = mouseDownEvent.clientX;
|
||||
const startY = mouseDownEvent.clientY;
|
||||
|
||||
const handleMouseMove = (mouseMoveEvent: MouseEvent) => {
|
||||
const deltaX = mouseMoveEvent.clientX - startX;
|
||||
const deltaY = mouseMoveEvent.clientY - startY;
|
||||
const delta = getDelta?.({ x: deltaX, y: deltaY });
|
||||
if (!delta || !resizing) {
|
||||
return;
|
||||
}
|
||||
resizing(delta);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
resizeEnd();
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('click', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('click', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment-resize-area"
|
||||
style={style}
|
||||
data-flow-editor-selectable="false"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- enum */
|
||||
|
||||
export enum CommentEditorFormField {
|
||||
Size = 'size',
|
||||
Note = 'note',
|
||||
}
|
||||
|
||||
/** 编辑器事件 */
|
||||
export enum CommentEditorEvent {
|
||||
/** 内容变更事件 */
|
||||
Change = 'change',
|
||||
/** 多选事件 */
|
||||
MultiSelect = 'multiSelect',
|
||||
/** 单选事件 */
|
||||
Select = 'select',
|
||||
/** 失焦事件 */
|
||||
Blur = 'blur',
|
||||
}
|
||||
|
||||
export const CommentEditorDefaultValue = '';
|
||||
@ -0,0 +1 @@
|
||||
export { useSize } from './use-size';
|
||||
@ -0,0 +1,50 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
FlowNodeFormData,
|
||||
FormModelV2,
|
||||
WorkflowNodeEntity,
|
||||
useEntityFromContext,
|
||||
useNodeRender,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorModel } from '../model';
|
||||
import { CommentEditorFormField } from '../constant';
|
||||
|
||||
export const useModel = () => {
|
||||
const node = useEntityFromContext<WorkflowNodeEntity>();
|
||||
const { selected: focused } = useNodeRender();
|
||||
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
|
||||
const model = useMemo(() => new CommentEditorModel(), []);
|
||||
|
||||
// 同步失焦状态
|
||||
useEffect(() => {
|
||||
if (focused) {
|
||||
return;
|
||||
}
|
||||
model.setFocus(focused);
|
||||
}, [focused, model]);
|
||||
|
||||
// 同步表单值初始化
|
||||
useEffect(() => {
|
||||
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
|
||||
model.setValue(value); // 设置初始值
|
||||
model.selectEnd(); // 设置初始化光标位置
|
||||
}, [formModel, model]);
|
||||
|
||||
// 同步表单外部值变化:undo/redo/协同
|
||||
useEffect(() => {
|
||||
const disposer = formModel.onFormValuesChange(({ name }) => {
|
||||
if (name !== CommentEditorFormField.Note) {
|
||||
return;
|
||||
}
|
||||
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
|
||||
model.setValue(value);
|
||||
});
|
||||
return () => disposer.dispose();
|
||||
}, [formModel, model]);
|
||||
|
||||
return model;
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorModel } from '../model';
|
||||
import { CommentEditorEvent } from '../constant';
|
||||
|
||||
export const useOverflow = (params: { model: CommentEditorModel; height: number }) => {
|
||||
const { model, height } = params;
|
||||
const playground = usePlayground();
|
||||
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
|
||||
const isOverflow = useCallback((): boolean => {
|
||||
if (!model.element) {
|
||||
return false;
|
||||
}
|
||||
return model.element.scrollHeight > model.element.clientHeight;
|
||||
}, [model, height, playground]);
|
||||
|
||||
// 更新 overflow
|
||||
const updateOverflow = useCallback(() => {
|
||||
setOverflow(isOverflow());
|
||||
}, [isOverflow]);
|
||||
|
||||
// 监听高度变化
|
||||
useEffect(() => {
|
||||
updateOverflow();
|
||||
}, [height, updateOverflow]);
|
||||
|
||||
// 监听 change 事件
|
||||
useEffect(() => {
|
||||
const changeDisposer = model.on((params) => {
|
||||
if (params.type !== CommentEditorEvent.Change) {
|
||||
return;
|
||||
}
|
||||
updateOverflow();
|
||||
});
|
||||
return () => {
|
||||
changeDisposer.dispose();
|
||||
};
|
||||
}, [model, updateOverflow]);
|
||||
|
||||
return { overflow, updateOverflow };
|
||||
};
|
||||
@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
FlowNodeFormData,
|
||||
FormModelV2,
|
||||
FreeOperationType,
|
||||
HistoryService,
|
||||
TransformData,
|
||||
useCurrentEntity,
|
||||
usePlayground,
|
||||
useService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorFormField } from '../constant';
|
||||
|
||||
export const useSize = () => {
|
||||
const node = useCurrentEntity();
|
||||
const nodeMeta = node.getNodeMeta();
|
||||
const playground = usePlayground();
|
||||
const historyService = useService(HistoryService);
|
||||
const { size = { width: 240, height: 150 } } = nodeMeta;
|
||||
const transform = node.getData(TransformData);
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
const formSize = formModel.getValueIn<{ width: number; height: number }>(
|
||||
CommentEditorFormField.Size
|
||||
);
|
||||
|
||||
const [width, setWidth] = useState(formSize?.width ?? size.width);
|
||||
const [height, setHeight] = useState(formSize?.height ?? size.height);
|
||||
|
||||
// 初始化表单值
|
||||
useEffect(() => {
|
||||
const initSize = formModel.getValueIn<{ width: number; height: number }>(
|
||||
CommentEditorFormField.Size
|
||||
);
|
||||
if (!initSize) {
|
||||
formModel.setValueIn(CommentEditorFormField.Size, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}, [formModel, width, height]);
|
||||
|
||||
// 同步表单外部值变化:初始化/undo/redo/协同
|
||||
useEffect(() => {
|
||||
const disposer = formModel.onFormValuesChange(({ name }) => {
|
||||
if (name !== CommentEditorFormField.Size) {
|
||||
return;
|
||||
}
|
||||
const newSize = formModel.getValueIn<{ width: number; height: number }>(
|
||||
CommentEditorFormField.Size
|
||||
);
|
||||
if (!newSize) {
|
||||
return;
|
||||
}
|
||||
setWidth(newSize.width);
|
||||
setHeight(newSize.height);
|
||||
});
|
||||
return () => disposer.dispose();
|
||||
}, [formModel]);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
const resizeState = {
|
||||
width,
|
||||
height,
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
positionX: transform.position.x,
|
||||
positionY: transform.position.y,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {
|
||||
if (!resizeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { zoom } = playground.config;
|
||||
|
||||
const top = delta.top / zoom;
|
||||
const right = delta.right / zoom;
|
||||
const bottom = delta.bottom / zoom;
|
||||
const left = delta.left / zoom;
|
||||
|
||||
const minWidth = 120;
|
||||
const minHeight = 80;
|
||||
|
||||
const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);
|
||||
const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);
|
||||
|
||||
// 如果宽度或高度小于最小值,则不更新偏移量
|
||||
const newOffsetX =
|
||||
(left > 0 || right < 0) && newWidth <= minWidth
|
||||
? resizeState.offsetX
|
||||
: left / 2 + right / 2;
|
||||
const newOffsetY =
|
||||
(top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;
|
||||
|
||||
const newPositionX = resizeState.positionX + newOffsetX;
|
||||
const newPositionY = resizeState.positionY + newOffsetY;
|
||||
|
||||
resizeState.width = newWidth;
|
||||
resizeState.height = newHeight;
|
||||
resizeState.offsetX = newOffsetX;
|
||||
resizeState.offsetY = newOffsetY;
|
||||
|
||||
// 更新状态
|
||||
setWidth(newWidth);
|
||||
setHeight(newHeight);
|
||||
|
||||
// 更新偏移量
|
||||
transform.update({
|
||||
position: {
|
||||
x: newPositionX,
|
||||
y: newPositionY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resizeEnd = () => {
|
||||
historyService.transact(() => {
|
||||
historyService.pushOperation(
|
||||
{
|
||||
type: FreeOperationType.dragNodes,
|
||||
value: {
|
||||
ids: [node.id],
|
||||
value: [
|
||||
{
|
||||
x: resizeState.positionX + resizeState.offsetX,
|
||||
y: resizeState.positionY + resizeState.offsetY,
|
||||
},
|
||||
],
|
||||
oldValue: [
|
||||
{
|
||||
x: resizeState.positionX,
|
||||
y: resizeState.positionY,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
noApply: true,
|
||||
}
|
||||
);
|
||||
formModel.setValueIn(CommentEditorFormField.Size, {
|
||||
width: resizeState.width,
|
||||
height: resizeState.height,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
resizeEnd,
|
||||
};
|
||||
}, [node, width, height, transform, playground, formModel, historyService]);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
onResize,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export { CommentRender } from './components';
|
||||
@ -0,0 +1,106 @@
|
||||
import { Emitter } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorEventParams } from './type';
|
||||
import { CommentEditorDefaultValue, CommentEditorEvent } from './constant';
|
||||
|
||||
export class CommentEditorModel {
|
||||
private innerValue: string = CommentEditorDefaultValue;
|
||||
|
||||
private emitter: Emitter<CommentEditorEventParams> = new Emitter();
|
||||
|
||||
private editor: HTMLTextAreaElement;
|
||||
|
||||
/** 注册事件 */
|
||||
public on = this.emitter.event;
|
||||
|
||||
/** 获取当前值 */
|
||||
public get value(): string {
|
||||
return this.innerValue;
|
||||
}
|
||||
|
||||
/** 外部设置模型值 */
|
||||
public setValue(value: string = CommentEditorDefaultValue): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (value === this.innerValue) {
|
||||
return;
|
||||
}
|
||||
this.innerValue = value;
|
||||
this.syncEditorValue();
|
||||
this.emitter.fire({
|
||||
type: CommentEditorEvent.Change,
|
||||
value: this.innerValue,
|
||||
});
|
||||
}
|
||||
|
||||
public set element(el: HTMLTextAreaElement) {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.editor = el;
|
||||
}
|
||||
|
||||
/** 获取编辑器 DOM 节点 */
|
||||
public get element(): HTMLTextAreaElement {
|
||||
return this.editor;
|
||||
}
|
||||
|
||||
/** 编辑器聚焦/失焦 */
|
||||
public setFocus(focused: boolean): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (focused && !this.focused) {
|
||||
this.editor.focus();
|
||||
} else if (!focused && this.focused) {
|
||||
this.editor.blur();
|
||||
this.deselect();
|
||||
this.emitter.fire({
|
||||
type: CommentEditorEvent.Blur,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择末尾 */
|
||||
public selectEnd(): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
// 获取文本长度
|
||||
const length = this.editor.value.length;
|
||||
// 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)
|
||||
this.editor.setSelectionRange(length, length);
|
||||
}
|
||||
|
||||
/** 获取聚焦状态 */
|
||||
public get focused(): boolean {
|
||||
return document.activeElement === this.editor;
|
||||
}
|
||||
|
||||
/** 取消选择文本 */
|
||||
private deselect(): void {
|
||||
const selection: Selection | null = window.getSelection();
|
||||
|
||||
// 清除所有选择区域
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否初始化 */
|
||||
private get initialized(): boolean {
|
||||
return Boolean(this.editor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步编辑器实例内容
|
||||
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
|
||||
*/
|
||||
private syncEditorValue(): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.editor.value = this.innerValue;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import type { CommentEditorEvent } from './constant';
|
||||
|
||||
interface CommentEditorChangeEvent {
|
||||
type: CommentEditorEvent.Change;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CommentEditorMultiSelectEvent {
|
||||
type: CommentEditorEvent.MultiSelect;
|
||||
}
|
||||
|
||||
interface CommentEditorSelectEvent {
|
||||
type: CommentEditorEvent.Select;
|
||||
}
|
||||
|
||||
interface CommentEditorBlurEvent {
|
||||
type: CommentEditorEvent.Blur;
|
||||
}
|
||||
|
||||
export type CommentEditorEventParams =
|
||||
| CommentEditorChangeEvent
|
||||
| CommentEditorMultiSelectEvent
|
||||
| CommentEditorSelectEvent
|
||||
| CommentEditorBlurEvent;
|
||||
125
apps/demo-nextjs-antd/src/editor/components/node-menu/index.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { FC, type MouseEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import {
|
||||
WorkflowDragService,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowSelectService,
|
||||
delay,
|
||||
useClientContext,
|
||||
useService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
|
||||
import { EllipsisOutlined } from '@ant-design/icons';
|
||||
|
||||
import { FlowNodeRegistry } from '@editor/typings';
|
||||
import { PasteShortcut } from '@editor/shortcuts/paste';
|
||||
import { CopyShortcut } from '@editor/shortcuts/copy';
|
||||
|
||||
interface NodeMenuProps {
|
||||
node: WorkflowNodeEntity;
|
||||
updateTitleEdit: (setEditing: boolean) => void;
|
||||
deleteNode: () => void;
|
||||
}
|
||||
|
||||
export const NodeMenu: FC<NodeMenuProps> = ({ node, deleteNode, updateTitleEdit }) => {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const clientContext = useClientContext();
|
||||
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
||||
const nodeIntoContainerService = useService(NodeIntoContainerService);
|
||||
const selectService = useService(WorkflowSelectService);
|
||||
const dragService = useService(WorkflowDragService);
|
||||
const canMoveOut = nodeIntoContainerService.canMoveOutContainer(node);
|
||||
|
||||
const rerenderMenu = useCallback(() => {
|
||||
// force destroy component - 强制销毁组件触发重新渲染
|
||||
setVisible(false);
|
||||
requestAnimationFrame(() => {
|
||||
setVisible(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMoveOut = useCallback(
|
||||
async (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const sourceParent = node.parent;
|
||||
// move out of container - 移出容器
|
||||
nodeIntoContainerService.moveOutContainer({ node });
|
||||
// clear invalid lines - 清除非法线条
|
||||
await nodeIntoContainerService.clearInvalidLines({
|
||||
dragNode: node,
|
||||
sourceParent,
|
||||
});
|
||||
rerenderMenu();
|
||||
await delay(16);
|
||||
// select node - 选中节点
|
||||
selectService.selectNode(node);
|
||||
// start drag node - 开始拖拽
|
||||
dragService.startDragSelectedNodes(e);
|
||||
},
|
||||
[nodeIntoContainerService, node, rerenderMenu]
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const copyShortcut = new CopyShortcut(clientContext);
|
||||
const pasteShortcut = new PasteShortcut(clientContext);
|
||||
const data = copyShortcut.toClipboardData([node]);
|
||||
pasteShortcut.apply(data);
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
},
|
||||
[clientContext, node]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
deleteNode();
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
},
|
||||
[clientContext, node]
|
||||
);
|
||||
const handleEditTitle = useCallback(() => {
|
||||
updateTitleEdit(true);
|
||||
}, [updateTitleEdit]);
|
||||
|
||||
if (!visible) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['hover']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: <a onClick={handleEditTitle}>Edit Title</a>,
|
||||
key: 'editTitle',
|
||||
},
|
||||
{
|
||||
label: <a onClick={handleMoveOut}>Move out</a>,
|
||||
key: 'moveOut',
|
||||
disabled: !canMoveOut,
|
||||
},
|
||||
{
|
||||
label: <a onClick={handleCopy}>Create Copy</a>,
|
||||
key: 'createCopy',
|
||||
disabled: registry.meta!.copyDisable === true,
|
||||
},
|
||||
{
|
||||
label: <a onClick={handleDelete}>Delete</a>,
|
||||
key: 'delete',
|
||||
disabled: !!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EllipsisOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
.node-placeholder {
|
||||
width: 360px;
|
||||
|
||||
background-color: rgba(252, 252, 255, 1);
|
||||
border: 1px solid rgba(68, 83, 130, 0.25);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 4px 12px 0 rgba(0, 0, 0, 2%),
|
||||
0 2px 6px 0 rgba(0, 0, 0, 4%);
|
||||
}
|
||||
|
||||
.node-placeholder-skeleton {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: rgba(252, 252, 255, 1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.node-placeholder-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.node-placeholder-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.node-placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.node-placeholder-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2.5px;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import './index.scss';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Popover } from 'antd';
|
||||
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
|
||||
|
||||
import { NodePlaceholder } from './node-placeholder';
|
||||
import { NodeList } from './node-list';
|
||||
|
||||
export const NodePanel: FC<NodePanelRenderProps> = (props) => {
|
||||
const { onSelect, position, onClose, panelProps } = props;
|
||||
// @ts-ignore
|
||||
const { enableNodePlaceholder } = panelProps;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
visible={true}
|
||||
onVisibleChange={(v) => (v ? null : onClose())}
|
||||
content={<NodeList onSelect={onSelect} visibleNodeRegistries={[]} />}
|
||||
placement="right"
|
||||
// popupAlign={{ offset: [30, 0] }}
|
||||
overlayStyle={{
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
enableNodePlaceholder
|
||||
? {
|
||||
position: 'absolute',
|
||||
top: position.y - 61.5,
|
||||
left: position.x,
|
||||
width: 360,
|
||||
height: 100,
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
{enableNodePlaceholder && <NodePlaceholder />}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
|
||||
import { useClientContext } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowNodeRegistry } from '@editor/typings';
|
||||
|
||||
// import { visibleNodeRegistries } from '../../nodes';
|
||||
|
||||
const NodeWrap = styled.div`
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 19px;
|
||||
padding: 0 15px;
|
||||
&:hover {
|
||||
background-color: hsl(252deg 62% 55% / 9%);
|
||||
color: hsl(252 62% 54.9%);
|
||||
}
|
||||
`;
|
||||
|
||||
const NodeLabel = styled.div`
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
interface NodeProps {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
onClick: React.MouseEventHandler<HTMLDivElement>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function Node(props: NodeProps) {
|
||||
return (
|
||||
<NodeWrap
|
||||
data-testid={`demo-free-node-list-${props.label}`}
|
||||
onClick={props.disabled ? undefined : props.onClick}
|
||||
style={props.disabled ? { opacity: 0.3 } : {}}
|
||||
>
|
||||
<div style={{ fontSize: 14 }}>{props.icon}</div>
|
||||
<NodeLabel>{props.label}</NodeLabel>
|
||||
</NodeWrap>
|
||||
);
|
||||
}
|
||||
|
||||
const NodesWrap = styled.div`
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface NodeListProps {
|
||||
onSelect: NodePanelRenderProps['onSelect'];
|
||||
visibleNodeRegistries: FlowNodeRegistry[];
|
||||
}
|
||||
|
||||
export const NodeList: FC<NodeListProps> = (props) => {
|
||||
const { onSelect } = props;
|
||||
const context = useClientContext();
|
||||
const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
|
||||
const json = registry.onAdd?.(context);
|
||||
onSelect({
|
||||
nodeType: registry.type as string,
|
||||
selectEvent: e,
|
||||
nodeJSON: json,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<NodesWrap style={{ width: 80 * 2 + 20 }}>
|
||||
{props.visibleNodeRegistries.map((registry) => (
|
||||
<Node
|
||||
key={registry.type}
|
||||
disabled={!(registry.canAdd?.(context) ?? true)}
|
||||
icon={
|
||||
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon.src} />
|
||||
}
|
||||
label={registry.type as string}
|
||||
onClick={(e) => handleClick(e, registry)}
|
||||
/>
|
||||
))}
|
||||
</NodesWrap>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
export const NodePlaceholder = () => (
|
||||
<div className="node-placeholder" data-testid="workflow.detail.node-panel.placeholder">
|
||||
<Skeleton
|
||||
className="node-placeholder-skeleton"
|
||||
loading={true}
|
||||
active={true}
|
||||
// placeholder={
|
||||
// <div className="">
|
||||
// <div className="node-placeholder-hd">
|
||||
// <Skeleton.Avatar shape="square" className="node-placeholder-avatar" />
|
||||
// <Skeleton.Title style={{ width: 141 }} />
|
||||
// </div>
|
||||
// <div className="node-placeholder-content">
|
||||
// <div className="node-placeholder-footer">
|
||||
// <Skeleton.Title style={{ width: 85 }} />
|
||||
// <Skeleton.Title style={{ width: 241 }} />
|
||||
// </div>
|
||||
// <Skeleton.Title style={{ width: 220 }} />
|
||||
// </div>
|
||||
// </div>
|
||||
// }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
23
apps/demo-nextjs-antd/src/editor/components/node-render.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
WorkflowNodeProps,
|
||||
WorkflowNodeRenderer,
|
||||
useNodeRender,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export const NodeRender = (props: WorkflowNodeProps) => {
|
||||
const { form, selected } = useNodeRender();
|
||||
return (
|
||||
<WorkflowNodeRenderer
|
||||
className={classnames(
|
||||
'workflow-node-render min-w-[320px] p-4 bg-node-bg rounded-node-radius shadow-[var(--node-shadow)] border border-solid border-node-border',
|
||||
{
|
||||
'border-node-selected ': selected,
|
||||
}
|
||||
)}
|
||||
node={props.node}
|
||||
>
|
||||
{form?.render()}
|
||||
</WorkflowNodeRenderer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,98 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { SelectorBoxPopoverProps } from '@flowgram.ai/free-layout-editor';
|
||||
import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
|
||||
import { CopyOutlined, DeleteOutlined, ExpandAltOutlined, ShrinkOutlined } from '@ant-design/icons';
|
||||
|
||||
import { FlowCommandId } from '@editor/shortcuts/constants';
|
||||
import { IconGroup } from '../group';
|
||||
|
||||
const BUTTON_HEIGHT = 24;
|
||||
|
||||
export const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({
|
||||
bounds,
|
||||
children,
|
||||
flowSelectConfig,
|
||||
commandRegistry,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: bounds.right,
|
||||
top: bounds.top,
|
||||
transform: 'translate(-100%, -100%)',
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* <ButtonGroup
|
||||
size="small"
|
||||
style={{ display: 'flex', flexWrap: 'nowrap', height: BUTTON_HEIGHT }}
|
||||
> */}
|
||||
<Tooltip title={'Collapse'}>
|
||||
<Button
|
||||
icon={<ShrinkOutlined />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
// theme="solid"
|
||||
onMouseDown={(e) => {
|
||||
commandRegistry.executeCommand(FlowCommandId.COLLAPSE);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={'Expand'}>
|
||||
<Button
|
||||
icon={<ExpandAltOutlined />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
// theme="solid"
|
||||
onMouseDown={(e) => {
|
||||
commandRegistry.executeCommand(FlowCommandId.EXPAND);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={'Create Group'}>
|
||||
<Button
|
||||
icon={<IconGroup size={14} />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
// theme="solid"
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(WorkflowGroupCommand.Group);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={'Copy'}>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
// theme="solid"
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(FlowCommandId.COPY);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={'Delete'}>
|
||||
<Button
|
||||
type="primary"
|
||||
// theme="solid"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(FlowCommandId.DELETE);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* </ButtonGroup> */}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</>
|
||||
);
|
||||
@ -0,0 +1,2 @@
|
||||
export { SidebarProvider } from './sidebar-provider';
|
||||
export { SidebarRenderer } from './sidebar-renderer';
|
||||
@ -0,0 +1,14 @@
|
||||
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { NodeRenderContext } from '@editor/context';
|
||||
|
||||
export function SidebarNodeRenderer(props: { node: FlowNodeEntity }) {
|
||||
const { node } = props;
|
||||
const nodeRender = useNodeRender(node);
|
||||
|
||||
return (
|
||||
<NodeRenderContext.Provider value={nodeRender}>
|
||||
{nodeRender.form?.render()}
|
||||
</NodeRenderContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SidebarContext } from '@editor/context';
|
||||
|
||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [nodeId, setNodeId] = useState<string | undefined>();
|
||||
return (
|
||||
<SidebarContext.Provider value={{ visible: !!nodeId, nodeId, setNodeId }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import { Drawer } from 'antd';
|
||||
import {
|
||||
PlaygroundEntityContext,
|
||||
useClientContext,
|
||||
useRefresh,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowNodeMeta } from '@editor/typings';
|
||||
import { IsSidebarContext, SidebarContext } from '@editor/context';
|
||||
import { SidebarNodeRenderer } from './sidebar-node-renderer';
|
||||
|
||||
export const SidebarRenderer = () => {
|
||||
const { nodeId, setNodeId } = useContext(SidebarContext);
|
||||
const { selection, playground, document } = useClientContext();
|
||||
const refresh = useRefresh();
|
||||
const handleClose = useCallback(() => {
|
||||
setNodeId(undefined);
|
||||
}, []);
|
||||
const node = nodeId ? document.getNode(nodeId) : undefined;
|
||||
/**
|
||||
* Listen readonly
|
||||
*/
|
||||
useEffect(() => {
|
||||
const disposable = playground.config.onReadonlyOrDisabledChange(() => {
|
||||
handleClose();
|
||||
refresh();
|
||||
});
|
||||
return () => disposable.dispose();
|
||||
}, [playground]);
|
||||
/**
|
||||
* Listen selection
|
||||
*/
|
||||
useEffect(() => {
|
||||
const toDispose = selection.onSelectionChanged(() => {
|
||||
/**
|
||||
* 如果没有选中任何节点,则自动关闭侧边栏
|
||||
* If no node is selected, the sidebar is automatically closed
|
||||
*/
|
||||
if (selection.selection.length === 0) {
|
||||
handleClose();
|
||||
} else if (selection.selection.length === 1 && selection.selection[0] !== node) {
|
||||
handleClose();
|
||||
}
|
||||
});
|
||||
return () => toDispose.dispose();
|
||||
}, [selection, handleClose, node]);
|
||||
/**
|
||||
* Close when node disposed
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
const toDispose = node.onDispose(() => {
|
||||
setNodeId(undefined);
|
||||
});
|
||||
return () => toDispose.dispose();
|
||||
}
|
||||
return () => {};
|
||||
}, [node]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const { sidebarDisable = false } = node.getNodeMeta<FlowNodeMeta>();
|
||||
return !sidebarDisable;
|
||||
}, [node]);
|
||||
|
||||
if (playground.config.readonly) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Add "key" to rerender the sidebar when the node changes
|
||||
*/
|
||||
const content =
|
||||
node && visible ? (
|
||||
<PlaygroundEntityContext.Provider key={node.id} value={node}>
|
||||
<SidebarNodeRenderer node={node} />
|
||||
</PlaygroundEntityContext.Provider>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Drawer mask={false} open={visible} onClose={handleClose}>
|
||||
<IsSidebarContext.Provider value={true}>{content}</IsSidebarContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
47
apps/demo-nextjs-antd/src/editor/components/tools.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
/* TODO */
|
||||
}
|
||||
|
||||
// import { WorkflowDocument, useService } from '@flowgram.ai/free-layout-editor';
|
||||
// import { useState } from 'react';
|
||||
|
||||
export const Tools = () => (
|
||||
// const [isLoading, setIsLoading] = useState(false);
|
||||
// const document = useService(WorkflowDocument);
|
||||
|
||||
// const handleRun = async () => {
|
||||
// try {
|
||||
// setIsLoading(true);
|
||||
// const response = await fetch('/api/runtime', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// json: document.toJSON(),
|
||||
// }),
|
||||
// });
|
||||
// const data = await response.json();
|
||||
|
||||
// if (!data.success) {
|
||||
// throw new Error(data.error || 'process failed');
|
||||
// }
|
||||
|
||||
// console.log('run success', data.data);
|
||||
// } catch (error) {
|
||||
// console.error(error instanceof Error ? error.message : 'run failed');
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
<div className="mastra-workflow-tools absolute z-[999] bottom-4 left-1/2">
|
||||
{/* <button
|
||||
className="bg-blue-400 cursor-pointer active:bg-blue-500 p-2 rounded"
|
||||
onClick={handleRun}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<p className="text-white">TEST RUN</p>
|
||||
</button> */}
|
||||
</div>
|
||||
);
|
||||
2
apps/demo-nextjs-antd/src/editor/context/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { NodeRenderContext } from './node-render-context';
|
||||
export { SidebarContext, IsSidebarContext } from './sidebar-context';
|
||||
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { NodeRenderReturnType } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
interface INodeRenderContext extends NodeRenderReturnType {}
|
||||
|
||||
/** 业务自定义节点上下文 */
|
||||
export const NodeRenderContext = React.createContext<INodeRenderContext>({} as INodeRenderContext);
|
||||
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SidebarContext = React.createContext<{
|
||||
visible: boolean;
|
||||
nodeId?: string;
|
||||
setNodeId: (node: string | undefined) => void;
|
||||
}>({ visible: false, setNodeId: () => {} });
|
||||
|
||||
export const IsSidebarContext = React.createContext<boolean>(false);
|
||||
439
apps/demo-nextjs-antd/src/editor/data/initial-data.ts
Normal file
@ -0,0 +1,439 @@
|
||||
import { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export const initialData: WorkflowJSON = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'start_0',
|
||||
type: 'start',
|
||||
meta: {
|
||||
position: {
|
||||
x: 180,
|
||||
y: 381.75,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'Start',
|
||||
outputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
default: 'Hello Flow.',
|
||||
},
|
||||
enable: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
array_obj: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
int: {
|
||||
type: 'number',
|
||||
},
|
||||
str: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'condition_0',
|
||||
type: 'condition',
|
||||
meta: {
|
||||
position: {
|
||||
x: 640,
|
||||
y: 363.25,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'Condition',
|
||||
conditions: [
|
||||
{
|
||||
key: 'if_0',
|
||||
value: {
|
||||
left: {
|
||||
type: 'ref',
|
||||
content: ['start_0', 'query'],
|
||||
},
|
||||
operator: 'contains',
|
||||
right: {
|
||||
type: 'constant',
|
||||
content: 'Hello Flow.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'if_f0rOAt',
|
||||
value: {
|
||||
left: {
|
||||
type: 'ref',
|
||||
content: ['start_0', 'enable'],
|
||||
},
|
||||
operator: 'is_true',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'end_0',
|
||||
type: 'end',
|
||||
meta: {
|
||||
position: {
|
||||
x: 2220,
|
||||
y: 381.75,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'End',
|
||||
outputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'loop_H8M3U',
|
||||
type: 'loop',
|
||||
meta: {
|
||||
position: {
|
||||
x: 1020,
|
||||
y: 547.96875,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'Loop_2',
|
||||
batchFor: {
|
||||
type: 'ref',
|
||||
content: ['start_0', 'array_obj'],
|
||||
},
|
||||
outputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: 'llm_CBdCg',
|
||||
type: 'llm',
|
||||
meta: {
|
||||
position: {
|
||||
x: 180,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_4',
|
||||
inputsValues: {
|
||||
modelType: {
|
||||
type: 'constant',
|
||||
content: 'gpt-3.5-turbo',
|
||||
},
|
||||
temperature: {
|
||||
type: 'constant',
|
||||
content: 0.5,
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'constant',
|
||||
content: 'You are an AI assistant.',
|
||||
},
|
||||
prompt: {
|
||||
type: 'constant',
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
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: 'llm_gZafu',
|
||||
type: 'llm',
|
||||
meta: {
|
||||
position: {
|
||||
x: 640,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_5',
|
||||
inputsValues: {
|
||||
modelType: {
|
||||
type: 'constant',
|
||||
content: 'gpt-3.5-turbo',
|
||||
},
|
||||
temperature: {
|
||||
type: 'constant',
|
||||
content: 0.5,
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'constant',
|
||||
content: 'You are an AI assistant.',
|
||||
},
|
||||
prompt: {
|
||||
type: 'constant',
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
sourceNodeID: 'llm_CBdCg',
|
||||
targetNodeID: 'llm_gZafu',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '159623',
|
||||
type: 'comment',
|
||||
meta: {
|
||||
position: {
|
||||
x: 640,
|
||||
y: 522.46875,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
size: {
|
||||
width: 240,
|
||||
height: 150,
|
||||
},
|
||||
note: 'hi ~\n\nthis is a comment node\n\n- flowgram.ai',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'group_V-_st',
|
||||
type: 'group',
|
||||
meta: {
|
||||
position: {
|
||||
x: 1020,
|
||||
y: 96.25,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_Group',
|
||||
color: 'Violet',
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: 'llm_0',
|
||||
type: 'llm',
|
||||
meta: {
|
||||
position: {
|
||||
x: 640,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_0',
|
||||
inputsValues: {
|
||||
modelType: {
|
||||
type: 'constant',
|
||||
content: 'gpt-3.5-turbo',
|
||||
},
|
||||
temperature: {
|
||||
type: 'constant',
|
||||
content: 0.5,
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'constant',
|
||||
content: 'You are an AI assistant.',
|
||||
},
|
||||
prompt: {
|
||||
type: 'constant',
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
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: 'llm_l_TcE',
|
||||
type: 'llm',
|
||||
meta: {
|
||||
position: {
|
||||
x: 180,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_1',
|
||||
inputsValues: {
|
||||
modelType: {
|
||||
type: 'constant',
|
||||
content: 'gpt-3.5-turbo',
|
||||
},
|
||||
temperature: {
|
||||
type: 'constant',
|
||||
content: 0.5,
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'constant',
|
||||
content: 'You are an AI assistant.',
|
||||
},
|
||||
prompt: {
|
||||
type: 'constant',
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
sourceNodeID: 'llm_l_TcE',
|
||||
targetNodeID: 'llm_0',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'llm_0',
|
||||
targetNodeID: 'end_0',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'condition_0',
|
||||
targetNodeID: 'llm_l_TcE',
|
||||
sourcePortID: 'if_0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
sourceNodeID: 'start_0',
|
||||
targetNodeID: 'condition_0',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'condition_0',
|
||||
targetNodeID: 'llm_l_TcE',
|
||||
sourcePortID: 'if_0',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'condition_0',
|
||||
targetNodeID: 'loop_H8M3U',
|
||||
sourcePortID: 'if_f0rOAt',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'llm_0',
|
||||
targetNodeID: 'end_0',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'loop_H8M3U',
|
||||
targetNodeID: 'end_0',
|
||||
},
|
||||
],
|
||||
};
|
||||
44
apps/demo-nextjs-antd/src/editor/data/node-registries.ts
Normal file
@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { FlowNodeRegistry } from '@editor/typings';
|
||||
import { StartNodeRegistry } from '@editor/nodes/start';
|
||||
import { LoopNodeRegistry } from '@editor/nodes/loop';
|
||||
import { LLMNodeRegistry } from '@editor/nodes/llm';
|
||||
import { EndNodeRegistry } from '@editor/nodes/end';
|
||||
import { ConditionNodeRegistry } from '@editor/nodes/condition';
|
||||
import { CommentNodeRegistry } from '@editor/nodes/comment';
|
||||
|
||||
/**
|
||||
* You can customize your own node registry
|
||||
* 你可以自定义节点的注册器
|
||||
*/
|
||||
export const nodeRegistries: FlowNodeRegistry[] = [
|
||||
ConditionNodeRegistry,
|
||||
StartNodeRegistry,
|
||||
EndNodeRegistry,
|
||||
LLMNodeRegistry,
|
||||
LoopNodeRegistry,
|
||||
CommentNodeRegistry,
|
||||
{
|
||||
type: 'start2',
|
||||
meta: {
|
||||
isStart: true, // Mark as start
|
||||
deleteDisable: true, // The start node cannot be deleted
|
||||
copyDisable: true, // The start node cannot be copied
|
||||
defaultPorts: [{ type: 'output' }], // Used to define the input and output ports, the start node only has the output port
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
meta: {
|
||||
deleteDisable: true,
|
||||
copyDisable: true,
|
||||
defaultPorts: [{ type: 'input' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
meta: {},
|
||||
defaultPorts: [{ type: 'output' }, { type: 'input' }], // A normal node has two ports
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,35 @@
|
||||
import styled from 'styled-components';
|
||||
import { FieldError, FieldState, FieldWarning } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
interface StatePanelProps {
|
||||
errors?: FieldState['errors'];
|
||||
warnings?: FieldState['warnings'];
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const Error = styled.span`
|
||||
font-size: 12px;
|
||||
color: red;
|
||||
`;
|
||||
|
||||
const Warning = styled.span`
|
||||
font-size: 12px;
|
||||
color: orange;
|
||||
`;
|
||||
|
||||
export const Feedback = ({ errors, warnings, invalid }: 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { useIsSidebar, useNodeRenderContext } from '@editor/hooks';
|
||||
import { FormTitleDescription, FormWrapper } from './styles';
|
||||
|
||||
/**
|
||||
* @param props
|
||||
* @constructor
|
||||
*/
|
||||
export function FormContent(props: { children?: React.ReactNode }) {
|
||||
const { node, expanded } = useNodeRenderContext();
|
||||
const isSidebar = useIsSidebar();
|
||||
const registry = node?.getNodeRegistry<FlowNodeRegistry>();
|
||||
return (
|
||||
<FormWrapper>
|
||||
<>
|
||||
{isSidebar && <FormTitleDescription>{registry.info?.description}</FormTitleDescription>}
|
||||
{(expanded || isSidebar) && props.children}
|
||||
</>
|
||||
</FormWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
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`
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 0px 4px;
|
||||
word-break: break-all;
|
||||
white-space: break-spaces;
|
||||
`;
|
||||
@ -0,0 +1,10 @@
|
||||
.node-form-header {
|
||||
&-title {
|
||||
font-size: 20px;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
.title-text {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||