diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index c6dd2366..b34df23a 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,6 +1,9 @@
# 文件路径与代码负责人分配
# 对整个仓库设置代码负责人
-* @xiamidaxia @luics
+* @xiamidaxia @luics @dragooncjw
# 对特定目录设置代码负责人
-/docs/ @xiamidaxia @dragooncjw
+/apps/docs/ @xiamidaxia @dragooncjw @YuanHeDx
+/apps/demo-node-form/ @YuanHeDx
+/packages/node-engine/ @YuanHeDx
+/packages/plugins/node-core-plugin/ @YuanHeDx
diff --git a/apps/demo-node-form/.eslintrc.js b/apps/demo-node-form/.eslintrc.js
new file mode 100644
index 00000000..9f8bd759
--- /dev/null
+++ b/apps/demo-node-form/.eslintrc.js
@@ -0,0 +1,15 @@
+const { defineConfig } = require('@flowgram.ai/eslint-config');
+
+module.exports = defineConfig({
+ preset: 'web',
+ packageRoot: __dirname,
+ rules: {
+ 'no-console': 'off',
+ 'react/prop-types': 'off',
+ },
+ settings: {
+ react: {
+ version: 'detect', // 自动检测 React 版本
+ },
+ },
+});
diff --git a/apps/demo-node-form/index.html b/apps/demo-node-form/index.html
new file mode 100644
index 00000000..7327a045
--- /dev/null
+++ b/apps/demo-node-form/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Flow FreeLayoutEditor Demo
+
+
+
+
+
diff --git a/apps/demo-node-form/package.json b/apps/demo-node-form/package.json
new file mode 100644
index 00000000..f4e53eb2
--- /dev/null
+++ b/apps/demo-node-form/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@flowgram.ai/demo-node-form",
+ "version": "0.1.0",
+ "description": "",
+ "keywords": [],
+ "license": "MIT",
+ "main": "./src/index.tsx",
+ "files": [
+ "src/",
+ ".eslintrc.js",
+ ".gitignore",
+ "index.html",
+ "package.json",
+ "rspack.config.js",
+ "tsconfig.json"
+ ],
+ "scripts": {
+ "build": "exit 0",
+ "build:fast": "exit 0",
+ "build:watch": "exit 0",
+ "clean": "rimraf dist",
+ "dev": "MODE=app NODE_ENV=development rspack serve",
+ "lint": "eslint ./src --cache",
+ "lint:fix": "eslint ./src --fix",
+ "start": "NODE_ENV=development rspack serve",
+ "test": "exit",
+ "test:cov": "exit",
+ "watch": "exit 0"
+ },
+ "dependencies": {
+ "@douyinfe/semi-icons": "^2.72.3",
+ "@douyinfe/semi-ui": "^2.72.3",
+ "@flowgram.ai/free-layout-editor": "workspace:*",
+ "@flowgram.ai/free-snap-plugin": "workspace:*",
+ "@flowgram.ai/minimap-plugin": "workspace:*",
+ "react": "^18",
+ "react-dom": "^18",
+ "styled-components": "^5"
+ },
+ "devDependencies": {
+ "@flowgram.ai/ts-config": "workspace:*",
+ "@flowgram.ai/eslint-config": "workspace:*",
+ "@rspack/cli": "0.2.1",
+ "@types/lodash-es": "^4.17.12",
+ "@types/node": "^18",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "@types/styled-components": "^5",
+ "@typescript-eslint/parser": "^6.10.0",
+ "eslint": "^8.54.0"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/apps/demo-node-form/rspack.config.js b/apps/demo-node-form/rspack.config.js
new file mode 100644
index 00000000..e2f05408
--- /dev/null
+++ b/apps/demo-node-form/rspack.config.js
@@ -0,0 +1,46 @@
+const path = require('path');
+
+const isCI = process.env.CI === 'true';
+const isSCM = !!process.env.BUILD_BRANCH;
+const isProd = process.env.NODE_ENV === 'production';
+/**
+ * @type {import('@rspack/cli').Configuration}
+ */
+module.exports = {
+ mode: process.env.NODE_ENV,
+ context: __dirname,
+ target: ['web'],
+ entry: {
+ main: './src/app.tsx',
+ },
+ builtins: {
+ // https://www.rspack.dev/config/builtins.html#builtinshtml
+ html: [
+ {
+ template: './index.html',
+ },
+ ],
+ progress: !isSCM ? {} : false,
+ treeShaking: isProd,
+ },
+ module: {
+ // https://www.rspack.dev/config/module.html#rule
+ rules: [
+ {
+ test: /\.(png|gif|jpg|jpeg|svg|woff2)$/,
+ type: 'asset',
+ },
+ ],
+ },
+ plugins: [],
+ /** module is too large now, we may need better way to tackle this in the future */
+ stats: isCI
+ ? { all: false, modules: true, assets: true, chunks: true, warnings: true, errors: true }
+ : {
+ modules: false,
+ all: false,
+ warnings: false,
+ errors: true,
+ timings: true,
+ },
+};
diff --git a/apps/demo-node-form/src/app.tsx b/apps/demo-node-form/src/app.tsx
new file mode 100644
index 00000000..150c56b5
--- /dev/null
+++ b/apps/demo-node-form/src/app.tsx
@@ -0,0 +1,7 @@
+import { createRoot } from 'react-dom/client';
+
+import { Editor } from './editor';
+
+const app = createRoot(document.getElementById('root')!);
+
+app.render();
diff --git a/apps/demo-node-form/src/components/field-title.tsx b/apps/demo-node-form/src/components/field-title.tsx
new file mode 100644
index 00000000..3bc680d9
--- /dev/null
+++ b/apps/demo-node-form/src/components/field-title.tsx
@@ -0,0 +1,5 @@
+import styled from 'styled-components';
+
+export const FieldTitle = styled.div`
+ padding-bottom: 4px;
+`;
diff --git a/apps/demo-node-form/src/components/field-wrapper.css b/apps/demo-node-form/src/components/field-wrapper.css
new file mode 100644
index 00000000..0963e991
--- /dev/null
+++ b/apps/demo-node-form/src/components/field-wrapper.css
@@ -0,0 +1,24 @@
+.error-message {
+ color: #f5222d !important;
+}
+
+.required {
+ color: #f5222d !important;
+ padding-left: 4px
+}
+
+.field-wrapper {
+ width: 100%;
+ margin-bottom: 12px;
+}
+
+.field-title {
+ margin-bottom: 6px;
+}
+
+.field-note{
+ color: #a3a0a0 !important;
+ font-size: 12px;
+ margin: 6px 0;
+}
+
diff --git a/apps/demo-node-form/src/components/field-wrapper.tsx b/apps/demo-node-form/src/components/field-wrapper.tsx
new file mode 100644
index 00000000..69f33c58
--- /dev/null
+++ b/apps/demo-node-form/src/components/field-wrapper.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import './field-wrapper.css';
+
+interface FieldWrapperProps {
+ required?: boolean;
+ title: string;
+ children?: React.ReactNode;
+ error?: string;
+ note?: string;
+}
+
+export const FieldWrapper = ({ required, title, children, error, note }: FieldWrapperProps) => (
+
+
+ {title}
+ {note ?
{note}
: null}
+ {required ?
* : null}
+
+ {children}
+
{error}
+ {note ?
: null}
+
+);
diff --git a/apps/demo-node-form/src/components/index.ts b/apps/demo-node-form/src/components/index.ts
new file mode 100644
index 00000000..f04829e6
--- /dev/null
+++ b/apps/demo-node-form/src/components/index.ts
@@ -0,0 +1,2 @@
+export { FieldTitle } from './field-title';
+export { FieldWrapper } from './field-wrapper';
diff --git a/apps/demo-node-form/src/constant.ts b/apps/demo-node-form/src/constant.ts
new file mode 100644
index 00000000..12ed43f2
--- /dev/null
+++ b/apps/demo-node-form/src/constant.ts
@@ -0,0 +1,65 @@
+export const fieldWrapperTs = `import React from 'react';
+
+import './field-wrapper.css';
+
+interface FieldWrapperProps {
+ required?: boolean;
+ title: string;
+ children?: React.ReactNode;
+ error?: string;
+ note?: string;
+}
+
+export const FieldWrapper = ({ required, title, children, error, note }: FieldWrapperProps) => (
+
+
+ {title}
+ {note ?
{note}
: null}
+ {required ?
* : null}
+
+ {children}
+
{error}
+ {note ?
: null}
+
+);
+`;
+
+export const fieldWrapperCss = `.error-message {
+ color: #f5222d !important;
+}
+
+.required {
+ color: #f5222d !important;
+ padding-left: 4px
+}
+
+.field-wrapper {
+ width: 100%;
+ margin-bottom: 12px;
+}
+
+.field-title {
+ margin-bottom: 6px;
+}
+
+.field-note{
+ color: #a3a0a0 !important;
+ font-size: 12px;
+ margin: 6px 0;
+}
+`;
+
+export const defaultInitialDataTs = `import { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
+
+export const DEFAULT_INITIAL_DATA: WorkflowJSON = {
+ nodes: [
+ {
+ id: 'node_0',
+ type: 'custom',
+ meta: {
+ position: { x: 400, y: 0 },
+ },
+ },
+ ],
+ edges: [],
+};`;
diff --git a/apps/demo-node-form/src/editor.tsx b/apps/demo-node-form/src/editor.tsx
new file mode 100644
index 00000000..ad395ec6
--- /dev/null
+++ b/apps/demo-node-form/src/editor.tsx
@@ -0,0 +1,27 @@
+import {
+ EditorRenderer,
+ FlowNodeRegistry,
+ FreeLayoutEditorProvider,
+ WorkflowJSON,
+} from '@flowgram.ai/free-layout-editor';
+
+import { useEditorProps } from './hooks/use-editor-props';
+import '@flowgram.ai/free-layout-editor/index.css';
+import './index.css';
+interface EditorProps {
+ registry: FlowNodeRegistry;
+ initialData: WorkflowJSON;
+}
+
+export const Editor = ({ registry, initialData }: EditorProps) => {
+ const editorProps = useEditorProps({ registries: [registry], initialData });
+ return (
+
+
+
+ );
+};
diff --git a/apps/demo-node-form/src/form-meta.tsx b/apps/demo-node-form/src/form-meta.tsx
new file mode 100644
index 00000000..8cf213e0
--- /dev/null
+++ b/apps/demo-node-form/src/form-meta.tsx
@@ -0,0 +1,51 @@
+import {
+ Field,
+ FieldRenderProps,
+ FormMeta,
+ ValidateTrigger,
+} from '@flowgram.ai/free-layout-editor';
+import { Input } from '@douyinfe/semi-ui';
+
+// FieldWrapper is not provided by sdk, and can be customized
+import { FieldWrapper } from './components';
+
+const render = () => (
+
+);
+
+const formMeta: FormMeta = {
+ render,
+ defaultValues: { name: 'Tina', city: 'Hangzhou' },
+ validateTrigger: ValidateTrigger.onChange,
+ validate: {
+ name: ({ value }) => {
+ if (!value) {
+ return 'Name is required';
+ }
+ },
+ city: ({ value }) => {
+ if (!value) {
+ return 'City is required';
+ }
+ },
+ },
+};
+
+export const DEFAULT_FORM_META = formMeta;
diff --git a/apps/demo-node-form/src/hooks/use-editor-props.tsx b/apps/demo-node-form/src/hooks/use-editor-props.tsx
new file mode 100644
index 00000000..5184355b
--- /dev/null
+++ b/apps/demo-node-form/src/hooks/use-editor-props.tsx
@@ -0,0 +1,165 @@
+import { useMemo } from 'react';
+
+import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
+import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
+import {
+ FreeLayoutProps,
+ WorkflowNodeProps,
+ WorkflowNodeRenderer,
+ Field,
+ useNodeRender,
+ FlowNodeRegistry,
+ WorkflowJSON,
+} from '@flowgram.ai/free-layout-editor';
+
+import { DEFAULT_DEMO_REGISTRY } from '../node-registries';
+import { DEFAULT_INITIAL_DATA } from '../initial-data';
+
+interface EditorProps {
+ registries: FlowNodeRegistry[];
+ initialData: WorkflowJSON;
+}
+
+export const useEditorProps = ({
+ registries = [DEFAULT_DEMO_REGISTRY],
+ initialData = DEFAULT_INITIAL_DATA,
+}: EditorProps) =>
+ useMemo(
+ () => ({
+ /**
+ * Whether to enable the background
+ */
+ background: true,
+ /**
+ * Whether it is read-only or not, the node cannot be dragged in read-only mode
+ */
+ readonly: false,
+ /**
+ * Initial data
+ * 初始化数据
+ */
+ initialData,
+ /**
+ * Node registries
+ * 节点注册
+ */
+ nodeRegistries: registries,
+ /**
+ * Get the default node registry, which will be merged with the 'nodeRegistries'
+ * 提供默认的节点注册,这个会和 nodeRegistries 做合并
+ */
+ getNodeDefaultRegistry(type) {
+ return {
+ type,
+ meta: {
+ defaultExpanded: true,
+ },
+ formMeta: {
+ /**
+ * Render form
+ */
+ render: () => (
+ <>
+ name="title">
+ {({ field }) => {field.value}
}
+
+
+ name="content">
+
+
+
+ >
+ ),
+ },
+ };
+ },
+ materials: {
+ /**
+ * Render Node
+ */
+ renderDefaultNode: (props: WorkflowNodeProps) => {
+ const { form } = useNodeRender();
+ return (
+
+ {form?.render()}
+
+ );
+ },
+ },
+ /**
+ * Content change
+ */
+ onContentChange(ctx, event) {
+ // console.log('Auto Save: ', event, ctx.document.toJSON());
+ },
+ // /**
+ // * Node engine enable, you can configure formMeta in the FlowNodeRegistry
+ // */
+ nodeEngine: {
+ enable: true,
+ },
+ /**
+ * Redo/Undo enable
+ */
+ history: {
+ enable: true,
+ enableChangeNode: true, // Listen Node engine data change
+ },
+ /**
+ * Playground init
+ */
+ onInit: (ctx) => {},
+ /**
+ * Playground render
+ */
+ onAllLayersRendered(ctx) {
+ // Fitview
+ ctx.document.fitView(false);
+ },
+ /**
+ * Playground dispose
+ */
+ onDispose() {
+ console.log('---- Playground Dispose ----');
+ },
+ plugins: () => [
+ /**
+ * Minimap plugin
+ * 缩略图插件
+ */
+ createMinimapPlugin({
+ disableLayer: true,
+ canvasStyle: {
+ canvasWidth: 182,
+ canvasHeight: 102,
+ canvasPadding: 50,
+ canvasBackground: 'rgba(245, 245, 245, 1)',
+ canvasBorderRadius: 10,
+ viewportBackground: 'rgba(235, 235, 235, 1)',
+ viewportBorderRadius: 4,
+ viewportBorderColor: 'rgba(201, 201, 201, 1)',
+ viewportBorderWidth: 1,
+ viewportBorderDashLength: 2,
+ nodeColor: 'rgba(255, 255, 255, 1)',
+ nodeBorderRadius: 2,
+ nodeBorderWidth: 0.145,
+ nodeBorderColor: 'rgba(6, 7, 9, 0.10)',
+ overlayColor: 'rgba(255, 255, 255, 0)',
+ },
+ inactiveDebounceTime: 1,
+ }),
+ /**
+ * Snap plugin
+ * 自动对齐及辅助线插件
+ */
+ createFreeSnapPlugin({
+ edgeColor: '#00B2B2',
+ alignColor: '#00B2B2',
+ edgeLineWidth: 1,
+ alignLineWidth: 1,
+ alignCrossWidth: 8,
+ }),
+ ],
+ }),
+ []
+ );
diff --git a/apps/demo-node-form/src/index.css b/apps/demo-node-form/src/index.css
new file mode 100644
index 00000000..15728111
--- /dev/null
+++ b/apps/demo-node-form/src/index.css
@@ -0,0 +1,113 @@
+.demo-free-node {
+ display: flex;
+ min-width: 300px;
+ min-height: 100px;
+ flex-direction: column;
+ align-items: flex-start;
+ box-sizing: border-box;
+ border-radius: 8px;
+ border: 1px solid var(--light-usage-border-color-border, rgba(28, 31, 35, 0.08));
+ background: #fff;
+ box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.1);
+}
+
+.demo-node-content {
+ padding: 8px 12px;
+ flex-grow: 1;
+ width: 100%;
+}
+
+.demo-node-title {
+ font-weight: 500;
+ font-size: 14px;
+ width: 100%;
+ margin: 4px 0px 12px 0px;
+}
+.demo-free-node-content {
+ padding: 4px 12px;
+ flex-grow: 1;
+ width: 100%;
+}
+.demo-free-node::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: -1;
+ background-color: white;
+ border-radius: 7px;
+}
+
+.demo-free-node:hover:before {
+ -webkit-filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1));
+ filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1));
+}
+
+.demo-free-node.activated:before,
+.demo-free-node.selected:before {
+ outline: 2px solid var(--light-usage-primary-color-primary, #4d53e8);
+ -webkit-filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1));
+ filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1));
+}
+
+.demo-free-sidebar {
+ height: 100%;
+ overflow-y: auto;
+ padding: 12px 16px 0;
+ box-sizing: border-box;
+ background: #f7f7fa;
+ border-right: 1px solid rgba(29, 28, 35, 0.08);
+}
+
+.demo-free-right-top-panel {
+ position: fixed;
+ right: 10px;
+ top: 70px;
+ width: 300px;
+ z-index: 999;
+}
+
+.demo-free-card {
+ width: 140px;
+ height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 6px 8px 0 rgba(28, 31, 35, 0.03);
+ cursor: -webkit-grab;
+ cursor: grab;
+ line-height: 16px;
+ margin-bottom: 12px;
+ overflow: hidden;
+ padding: 16px;
+ position: relative;
+ color: black;
+}
+
+.demo-free-layout {
+ display: flex;
+ flex-direction: row;
+ flex-grow: 1;
+}
+
+.demo-free-editor {
+ flex-grow: 1;
+ position: relative;
+ height: 100%;
+}
+
+.demo-free-container {
+ position: absolute;
+ left: 0;
+ top: 0;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ flex-direction: column;
+}
+
diff --git a/apps/demo-node-form/src/index.tsx b/apps/demo-node-form/src/index.tsx
new file mode 100644
index 00000000..ecd5a5c2
--- /dev/null
+++ b/apps/demo-node-form/src/index.tsx
@@ -0,0 +1,6 @@
+export { Editor } from './editor';
+export { FieldTitle, FieldWrapper } from './components';
+export { DEFAULT_FORM_META } from './form-meta';
+export { DEFAULT_DEMO_REGISTRY } from './node-registries';
+export { DEFAULT_INITIAL_DATA } from './initial-data';
+export { fieldWrapperTs, fieldWrapperCss, defaultInitialDataTs } from './constant';
diff --git a/apps/demo-node-form/src/initial-data.ts b/apps/demo-node-form/src/initial-data.ts
new file mode 100644
index 00000000..0a0c8cdb
--- /dev/null
+++ b/apps/demo-node-form/src/initial-data.ts
@@ -0,0 +1,14 @@
+import { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
+
+export const DEFAULT_INITIAL_DATA: WorkflowJSON = {
+ nodes: [
+ {
+ id: 'node_0',
+ type: 'custom',
+ meta: {
+ position: { x: 400, y: 0 },
+ },
+ },
+ ],
+ edges: [],
+};
diff --git a/apps/demo-node-form/src/node-registries.tsx b/apps/demo-node-form/src/node-registries.tsx
new file mode 100644
index 00000000..1afa4cab
--- /dev/null
+++ b/apps/demo-node-form/src/node-registries.tsx
@@ -0,0 +1,10 @@
+import { WorkflowNodeRegistry } from '@flowgram.ai/free-layout-editor';
+
+import { DEFAULT_FORM_META } from './form-meta';
+
+export const DEFAULT_DEMO_REGISTRY: WorkflowNodeRegistry = {
+ type: 'custom',
+ meta: {},
+ defaultPorts: [{ type: 'output' }, { type: 'input' }],
+ formMeta: DEFAULT_FORM_META,
+};
diff --git a/apps/demo-node-form/tsconfig.json b/apps/demo-node-form/tsconfig.json
new file mode 100644
index 00000000..171a60f5
--- /dev/null
+++ b/apps/demo-node-form/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./dist",
+ "experimentalDecorators": true,
+ "target": "es2020",
+ "module": "esnext",
+ "strictPropertyInitialization": false,
+ "strict": true,
+ "esModuleInterop": true,
+ "moduleResolution": "node",
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noImplicitAny": true,
+ "allowJs": true,
+ "resolveJsonModule": true,
+ "types": ["node"],
+ "jsx": "react-jsx",
+ "lib": ["es6", "dom", "es2020", "es2019.Array"]
+ },
+ "include": ["./src"],
+}
diff --git a/apps/docs/components/index.ts b/apps/docs/components/index.ts
index 1ef1ecbd..371c2dd9 100644
--- a/apps/docs/components/index.ts
+++ b/apps/docs/components/index.ts
@@ -5,3 +5,4 @@ export { FreeLayoutSimple } from './free-layout-simple';
export { FreeLayoutSimplePreview } from './free-layout-simple/preview';
export { FixedLayoutSimple } from './fixed-layout-simple';
export { FixedLayoutSimplePreview } from './fixed-layout-simple/preview';
+export { NodeFormBasicPreview, NodeFormEffectPreview } from './node-form';
diff --git a/apps/docs/components/node-form/basic-preview.tsx b/apps/docs/components/node-form/basic-preview.tsx
new file mode 100644
index 00000000..e31724c4
--- /dev/null
+++ b/apps/docs/components/node-form/basic-preview.tsx
@@ -0,0 +1,87 @@
+import {
+ DEFAULT_DEMO_REGISTRY,
+ DEFAULT_INITIAL_DATA,
+ defaultInitialDataTs,
+ fieldWrapperCss,
+ fieldWrapperTs,
+} from '@flowgram.ai/demo-node-form';
+
+import { PreviewEditor } from '../preview-editor';
+import { Editor } from './editor';
+
+const registryCode = {
+ code: `import {
+ Field,
+ FieldRenderProps,
+ FormMeta,
+ ValidateTrigger,
+} from '@flowgram.ai/free-layout-editor';
+import { Input } from '@douyinfe/semi-ui';
+
+// FieldWrapper is not provided by sdk, it can be customized
+import { FieldWrapper } from './components';
+
+const render = () => (
+
+);
+
+const formMeta: FormMeta = {
+ render,
+ defaultValues: { name: 'Tina', city: 'Hangzhou' },
+ validateTrigger: ValidateTrigger.onChange,
+ validate: {
+ name: ({ value }) => {
+ if (!value) {
+ return 'Name is required';
+ }
+ },
+ city: ({ value }) => {
+ if (!value) {
+ return 'City is required';
+ }
+ }
+ }
+};
+
+
+
+export const nodeRegistry: WorkflowNodeRegistry = {
+ type: 'custom',
+ meta: {},
+ defaultPorts: [{ type: 'output' }, { type: 'input' }],
+ formMeta
+};
+`,
+ active: true,
+};
+
+export const NodeFormBasicPreview = () => {
+ const files = {
+ 'node-registry.tsx': registryCode,
+ 'initial-data.ts': { code: defaultInitialDataTs, active: true },
+ 'field-wrapper.tsx': { code: fieldWrapperTs, active: true },
+ 'field-wrapper.css': { code: fieldWrapperCss, active: true },
+ };
+ return (
+
+
+
+ );
+};
diff --git a/apps/docs/components/node-form/editor.tsx b/apps/docs/components/node-form/editor.tsx
new file mode 100644
index 00000000..24153819
--- /dev/null
+++ b/apps/docs/components/node-form/editor.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+
+// https://github.com/web-infra-dev/rspress/issues/553
+const Editor = React.lazy(() =>
+ import('@flowgram.ai/demo-node-form').then((module) => ({
+ default: module.Editor,
+ }))
+);
+
+export { Editor };
diff --git a/apps/docs/components/node-form/effect/node-registry.tsx b/apps/docs/components/node-form/effect/node-registry.tsx
new file mode 100644
index 00000000..004f0d73
--- /dev/null
+++ b/apps/docs/components/node-form/effect/node-registry.tsx
@@ -0,0 +1,82 @@
+import {
+ DataEvent,
+ EffectFuncProps,
+ Field,
+ FieldRenderProps,
+ FormMeta,
+ ValidateTrigger,
+ WorkflowNodeRegistry,
+} from '@flowgram.ai/free-layout-editor';
+import { FieldWrapper } from '@flowgram.ai/demo-node-form';
+import { Input } from '@douyinfe/semi-ui';
+import '../index.css';
+
+const render = () => (
+
+);
+
+interface FormData {
+ field1: string;
+ field2: string;
+ field3: string;
+}
+
+const formMeta: FormMeta = {
+ render,
+ validateTrigger: ValidateTrigger.onChange,
+ effect: {
+ field1: [
+ {
+ event: DataEvent.onValueChange,
+ effect: ({ value }: EffectFuncProps) => {
+ console.log('field1 value:', value);
+ },
+ },
+ ],
+ field2: [
+ {
+ event: DataEvent.onValueChange,
+ effect: ({ value, form }: EffectFuncProps) => {
+ form.setValueIn('field3', 'field2 value is ' + value);
+ },
+ },
+ ],
+ },
+};
+
+export const nodeRegistry: WorkflowNodeRegistry = {
+ type: 'custom',
+ meta: {},
+ defaultPorts: [{ type: 'output' }, { type: 'input' }],
+ formMeta,
+};
diff --git a/apps/docs/components/node-form/effect/preview.tsx b/apps/docs/components/node-form/effect/preview.tsx
new file mode 100644
index 00000000..45b02e60
--- /dev/null
+++ b/apps/docs/components/node-form/effect/preview.tsx
@@ -0,0 +1,112 @@
+import {
+ DEFAULT_INITIAL_DATA,
+ defaultInitialDataTs,
+ fieldWrapperCss,
+ fieldWrapperTs,
+} from '@flowgram.ai/demo-node-form';
+
+import { Editor } from '../editor.tsx';
+import { PreviewEditor } from '../../preview-editor.tsx';
+import { nodeRegistry } from './node-registry.tsx';
+
+const nodeRegistryFile = {
+ code: `import {
+ DataEvent,
+ EffectFuncProps,
+ Field,
+ FieldRenderProps,
+ FormMeta,
+ ValidateTrigger,
+ WorkflowNodeRegistry,
+} from '@flowgram.ai/free-layout-editor';
+import { FieldWrapper } from '@flowgram.ai/demo-node-form';
+import { Input } from '@douyinfe/semi-ui';
+import '../index.css';
+
+const render = () => (
+
+);
+
+interface FormData {
+ field1: string;
+ field2: string;
+ field3: string;
+}
+
+const formMeta: FormMeta = {
+ render,
+ validateTrigger: ValidateTrigger.onChange,
+ effect: {
+ field1: [
+ {
+ event: DataEvent.onValueChange,
+ effect: ({ value }: EffectFuncProps) => {
+ console.log('field1 value:', value);
+ },
+ },
+ ],
+ field2: [
+ {
+ event: DataEvent.onValueChange,
+ effect: ({ value, form }: EffectFuncProps) => {
+ form.setValueIn('field3', 'field2 value is ' + value);
+ },
+ },
+ ],
+ },
+};
+
+export const nodeRegistry: WorkflowNodeRegistry = {
+ type: 'custom',
+ meta: {},
+ defaultPorts: [{ type: 'output' }, { type: 'input' }],
+ formMeta,
+};
+
+`,
+ active: true,
+};
+
+export const NodeFormEffectPreview = () => {
+ const files = {
+ 'node-registry.tsx': nodeRegistryFile,
+ 'initial-data.ts': { code: defaultInitialDataTs, active: true },
+ 'field-wrapper.tsx': { code: fieldWrapperTs, active: true },
+ 'field-wrapper.css': { code: fieldWrapperCss, active: true },
+ };
+ return (
+
+
+
+ );
+};
diff --git a/apps/docs/components/node-form/index.css b/apps/docs/components/node-form/index.css
new file mode 100644
index 00000000..c9f4fd27
--- /dev/null
+++ b/apps/docs/components/node-form/index.css
@@ -0,0 +1,12 @@
+.demo-node-content {
+ padding: 8px 12px;
+ flex-grow: 1;
+ width: 100%;
+}
+
+.demo-node-title {
+ font-weight: 500;
+ font-size: 14px;
+ width: 100%;
+ margin: 4px 0px 12px 0px;
+}
diff --git a/apps/docs/components/node-form/index.ts b/apps/docs/components/node-form/index.ts
new file mode 100644
index 00000000..6b593144
--- /dev/null
+++ b/apps/docs/components/node-form/index.ts
@@ -0,0 +1,2 @@
+export { NodeFormBasicPreview } from './basic-preview';
+export { NodeFormEffectPreview } from './effect/preview';
diff --git a/apps/docs/package.json b/apps/docs/package.json
index b9a4f66a..cab3a7ef 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -19,6 +19,7 @@
"@flowgram.ai/demo-free-layout": "workspace:*",
"@flowgram.ai/demo-free-layout-simple": "workspace:*",
"@flowgram.ai/demo-fixed-layout-simple": "workspace:*",
+ "@flowgram.ai/demo-node-form": "workspace:*",
"@flowgram.ai/fixed-layout-editor": "workspace:*",
"@flowgram.ai/fixed-semi-materials": "workspace:*",
"@flowgram.ai/free-layout-editor": "workspace:*",
diff --git a/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx b/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx
index 7d18036c..43025e1e 100644
--- a/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx
+++ b/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx
@@ -40,7 +40,7 @@ Canvas configuration is declarative, providing data, rendering, event, and plugi
```tsx pure title="use-editor-props.tsx"
import { useMemo } from 'react';
-import { type FixedLayoutProps } from '@flowgram.ai/free-layout-editor';
+import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { intialData } from './initial-data' // 初始化数据
@@ -48,8 +48,8 @@ import { nodeRegistries } from './node-registries' // 节点声明配置
import { BaseNode } from './base-node' // 节点渲染
export function useEditorProps(
-): FixedLayoutProps {
- return useMemo(
+): FreeLayoutProps {
+ return useMemo(
() => ({
/**
* Initialize data
@@ -271,7 +271,7 @@ export const BaseNode = () => {
*/
const { form } = useNodeRender()
/**
- * WorkflowNodeRenderer will add node drag events and port rendering, for deep customization,
+ * WorkflowNodeRenderer will add node drag events and port rendering, for deep customization,
* you can check the component source code at:
* https://github.com/bytedance/flowgram.ai/blob/main/packages/client/free-layout-editor/src/components/workflow-node-renderer.tsx
*/
diff --git a/apps/docs/src/zh/examples/_meta.json b/apps/docs/src/zh/examples/_meta.json
index 4bed1a45..009f1f8b 100644
--- a/apps/docs/src/zh/examples/_meta.json
+++ b/apps/docs/src/zh/examples/_meta.json
@@ -13,5 +13,10 @@
"type": "dir",
"name": "free-layout",
"label": "自由布局"
+ },
+ {
+ "type": "dir",
+ "name": "node-form",
+ "label": "节点表单"
}
]
diff --git a/apps/docs/src/zh/examples/node-form/_meta.json b/apps/docs/src/zh/examples/node-form/_meta.json
new file mode 100644
index 00000000..aa824cf5
--- /dev/null
+++ b/apps/docs/src/zh/examples/node-form/_meta.json
@@ -0,0 +1,4 @@
+[
+ "basic",
+ "effect"
+]
diff --git a/apps/docs/src/zh/examples/node-form/basic.mdx b/apps/docs/src/zh/examples/node-form/basic.mdx
new file mode 100644
index 00000000..07d8cc30
--- /dev/null
+++ b/apps/docs/src/zh/examples/node-form/basic.mdx
@@ -0,0 +1,10 @@
+---
+outline: false
+---
+
+
+# 基础用法
+
+import { NodeFormBasicPreview } from '../../../../components';
+
+
diff --git a/apps/docs/src/zh/examples/node-form/effect.mdx b/apps/docs/src/zh/examples/node-form/effect.mdx
new file mode 100644
index 00000000..f6142722
--- /dev/null
+++ b/apps/docs/src/zh/examples/node-form/effect.mdx
@@ -0,0 +1,10 @@
+---
+outline: false
+---
+
+
+# 表单项变更副作用 ( effect)
+
+import { NodeFormEffectPreview } from '../../../../components';
+
+
diff --git a/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx b/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx
index d4a0eb59..d2ffc176 100644
--- a/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx
+++ b/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx
@@ -42,7 +42,7 @@ function App() {
```tsx pure title="use-editor-props.tsx"
import { useMemo } from 'react';
-import { type FixedLayoutProps } from '@flowgram.ai/free-layout-editor';
+import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { intialData } from './initial-data' // 初始化数据
@@ -50,8 +50,8 @@ import { nodeRegistries } from './node-registries' // 节点声明配置
import { BaseNode } from './base-node' // 节点渲染
export function useEditorProps(
-): FixedLayoutProps {
- return useMemo(
+): FreeLayoutProps {
+ return useMemo(
() => ({
/**
* 初始化数据
diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml
index 10981df2..42c28623 100644
--- a/common/config/rush/pnpm-lock.yaml
+++ b/common/config/rush/pnpm-lock.yaml
@@ -98,7 +98,7 @@ importers:
version: link:../../config/ts-config
'@rspack/cli':
specifier: 0.2.1
- version: 0.2.1(react-refresh@0.14.0)(webpack@5.76.0)
+ version: 0.2.1(react-refresh@0.16.0)(webpack@5.76.0)
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
@@ -232,7 +232,7 @@ importers:
version: link:../../config/ts-config
'@rspack/cli':
specifier: 0.2.1
- version: 0.2.1(react-refresh@0.16.0)(webpack@5.76.0)
+ version: 0.2.1(react-refresh@0.14.0)(webpack@5.76.0)
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
@@ -310,6 +310,64 @@ importers:
specifier: ^8.54.0
version: 8.57.1
+ ../../apps/demo-node-form:
+ dependencies:
+ '@douyinfe/semi-icons':
+ specifier: ^2.72.3
+ version: 2.72.3(react@18.3.1)
+ '@douyinfe/semi-ui':
+ specifier: ^2.72.3
+ version: 2.72.3(acorn@8.14.0)(react-dom@18.3.1)(react@18.3.1)
+ '@flowgram.ai/free-layout-editor':
+ specifier: workspace:*
+ version: link:../../packages/client/free-layout-editor
+ '@flowgram.ai/free-snap-plugin':
+ specifier: workspace:*
+ version: link:../../packages/plugins/free-snap-plugin
+ '@flowgram.ai/minimap-plugin':
+ specifier: workspace:*
+ version: link:../../packages/plugins/minimap-plugin
+ react:
+ specifier: ^18
+ version: 18.3.1
+ react-dom:
+ specifier: ^18
+ version: 18.3.1(react@18.3.1)
+ styled-components:
+ specifier: ^5
+ version: 5.3.11(@babel/core@7.26.0)(react-dom@18.3.1)(react-is@18.3.1)(react@18.3.1)
+ devDependencies:
+ '@flowgram.ai/eslint-config':
+ specifier: workspace:*
+ version: link:../../config/eslint-config
+ '@flowgram.ai/ts-config':
+ specifier: workspace:*
+ version: link:../../config/ts-config
+ '@rspack/cli':
+ specifier: 0.2.1
+ version: 0.2.1(react-refresh@0.16.0)(webpack@5.76.0)
+ '@types/lodash-es':
+ specifier: ^4.17.12
+ version: 4.17.12
+ '@types/node':
+ specifier: ^18
+ version: 18.19.68
+ '@types/react':
+ specifier: ^18
+ version: 18.3.16
+ '@types/react-dom':
+ specifier: ^18
+ version: 18.3.5(@types/react@18.3.16)
+ '@types/styled-components':
+ specifier: ^5
+ version: 5.1.34
+ '@typescript-eslint/parser':
+ specifier: ^6.10.0
+ version: 6.21.0(eslint@8.57.1)(typescript@5.0.4)
+ eslint:
+ specifier: ^8.54.0
+ version: 8.57.1
+
../../apps/docs:
dependencies:
'@codesandbox/sandpack-react':
@@ -333,6 +391,9 @@ importers:
'@flowgram.ai/demo-free-layout-simple':
specifier: workspace:*
version: link:../demo-free-layout-simple
+ '@flowgram.ai/demo-node-form':
+ specifier: workspace:*
+ version: link:../demo-node-form
'@flowgram.ai/fixed-layout-editor':
specifier: workspace:*
version: link:../../packages/client/fixed-layout-editor
diff --git a/packages/node-engine/form/__tests__/validate.test.ts b/packages/node-engine/form/__tests__/validate.test.ts
new file mode 100644
index 00000000..b1230d33
--- /dev/null
+++ b/packages/node-engine/form/__tests__/validate.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+
+import { hasError } from '../src/utils/validate';
+import { FeedbackLevel, FieldError } from '../src/types';
+
+describe('utils/validate', () => {
+ describe('hasError', () => {
+ it('should return false when errors is empty', () => {
+ expect(hasError({ xxx: [] })).toBe(false);
+ expect(hasError({ xxx: undefined })).toBe(false);
+ expect(hasError({})).toBe(false);
+ expect(hasError({ aaa: [], bbb: [] })).toBe(false);
+ expect(hasError({ aaa: undefined, bbb: [] })).toBe(false);
+ });
+ it('should return true when errors is not empty', () => {
+ const mockError: FieldError = { name: 'xxx', level: FeedbackLevel.Error, message: 'err' };
+ expect(hasError({ xxx: [mockError] })).toBe(true);
+ expect(hasError({ aaa: [mockError], bbb: [mockError] })).toBe(true);
+ expect(hasError({ aaa: undefined, bbb: [mockError] })).toBe(true);
+ });
+ });
+});
diff --git a/packages/node-engine/form/src/utils/validate.ts b/packages/node-engine/form/src/utils/validate.ts
index 117f87c2..dd25a5f2 100644
--- a/packages/node-engine/form/src/utils/validate.ts
+++ b/packages/node-engine/form/src/utils/validate.ts
@@ -33,11 +33,5 @@ export function feedbackToFieldErrorsOrWarnings(name: string, feedback?: Feed
} as T;
}
-export const hasError = (errors: Errors) => {
- for (let fieldErrors in errors) {
- if (fieldErrors.length) {
- return true;
- }
- }
- return false;
-};
+export const hasError = (errors: Errors) =>
+ Object.keys(errors).some((key) => errors[key]?.length > 0);
diff --git a/packages/node-engine/node/src/form-model-v2.ts b/packages/node-engine/node/src/form-model-v2.ts
index 7c457131..5f66b32e 100644
--- a/packages/node-engine/node/src/form-model-v2.ts
+++ b/packages/node-engine/node/src/form-model-v2.ts
@@ -21,6 +21,7 @@ import {
Glob,
IField,
IFieldArray,
+ toForm,
} from '@flowgram.ai/form';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext } from '@flowgram.ai/core';
@@ -256,6 +257,7 @@ export class FormModelV2 extends FormModel implements Disposable {
value: get(values, currentName),
prevValue: get(prevValues, currentName),
formValues: values,
+ form: toForm(this.nativeFormModel!),
context: this.nodeContext,
});
@@ -298,6 +300,7 @@ export class FormModelV2 extends FormModel implements Disposable {
value: get(values, path),
formValues: values,
prevValue: get(prevValues, path),
+ form: toForm(this.nativeFormModel!),
context: this.nodeContext,
});
@@ -332,6 +335,7 @@ export class FormModelV2 extends FormModel implements Disposable {
effect({
...props,
formValues: nativeFormModel.values,
+ form: toForm(this.nativeFormModel!),
context: this.nodeContext,
})
);
diff --git a/packages/node-engine/node/src/types.ts b/packages/node-engine/node/src/types.ts
index e885ac5d..e7fcf474 100644
--- a/packages/node-engine/node/src/types.ts
+++ b/packages/node-engine/node/src/types.ts
@@ -2,7 +2,12 @@ import * as React from 'react';
import { FormModel, IFormMeta, NodeFormContext } from '@flowgram.ai/form-core';
import { FieldName, FieldValue } from '@flowgram.ai/form/src/types';
-import { FormRenderProps, Validate as FormValidate, ValidateTrigger } from '@flowgram.ai/form';
+import {
+ FormRenderProps,
+ IForm,
+ Validate as FormValidate,
+ ValidateTrigger,
+} from '@flowgram.ai/form';
import { FormPlugin } from './form-plugin';
import { FormModelV2 } from './form-model-v2';
@@ -46,19 +51,25 @@ export enum DataEvent {
export type EffectReturn = () => void;
-export type Effect = (props: {
+export interface EffectFuncProps {
name: FieldName;
value: TFieldValue;
prevValue?: TFieldValue;
formValues: TFormValues;
+ form: IForm;
context: NodeContext;
-}) => void | EffectReturn;
+}
+
+export type Effect = (
+ props: EffectFuncProps
+) => void | EffectReturn;
export type ArrayAppendEffect = (props: {
index: number;
value: TFieldValue;
arrayValues: Array;
formValues: TFormValues;
+ form: IForm;
context: NodeContext;
}) => void | EffectReturn;
@@ -66,6 +77,7 @@ export type ArrayDeleteEffect = (props: {
index: number;
arrayValue: Array;
formValues: TFormValues;
+ form: IForm;
context: NodeContext;
}) => void | EffectReturn;
diff --git a/rush.json b/rush.json
index 866c9a58..95dc775c 100644
--- a/rush.json
+++ b/rush.json
@@ -774,6 +774,15 @@
"team-flow",
"demo"
]
+ },
+ {
+ "packageName": "@flowgram.ai/demo-node-form",
+ "projectFolder": "apps/demo-node-form",
+ "tags": [
+ "level-1",
+ "team-flow",
+ "demo"
+ ]
}
]
}