Просмотр исходного кода

feat: sub canvas demo (#1028)

* feat: add sub-canvas demo application

* feat: add block start/end and loop nodes with subcanvas support

* feat: add batch node with automatic batch-function subcanvas creation
Louis Young 3 недель назад
Родитель
Сommit
59ef0d3240
30 измененных файлов с 1496 добавлено и 0 удалено
  1. 20 0
      apps/demo-sub-canvas/.eslintrc.js
  2. 12 0
      apps/demo-sub-canvas/index.html
  3. 58 0
      apps/demo-sub-canvas/package.json
  4. 19 0
      apps/demo-sub-canvas/rsbuild.config.ts
  5. 121 0
      apps/demo-sub-canvas/src/add-node.tsx
  6. 29 0
      apps/demo-sub-canvas/src/app.tsx
  7. 282 0
      apps/demo-sub-canvas/src/initial-data.ts
  8. 35 0
      apps/demo-sub-canvas/src/minimap.tsx
  9. 46 0
      apps/demo-sub-canvas/src/node-registries.tsx
  10. 40 0
      apps/demo-sub-canvas/src/node-render.tsx
  11. 39 0
      apps/demo-sub-canvas/src/nodes/batch-function/create-batch-function-json.ts
  12. 22 0
      apps/demo-sub-canvas/src/nodes/batch-function/create-batch-function-lines.ts
  13. 31 0
      apps/demo-sub-canvas/src/nodes/batch-function/create-batch-function.ts
  14. 20 0
      apps/demo-sub-canvas/src/nodes/batch-function/form-meta.tsx
  15. 11 0
      apps/demo-sub-canvas/src/nodes/batch-function/index.ts
  16. 80 0
      apps/demo-sub-canvas/src/nodes/batch-function/registry.ts
  17. 10 0
      apps/demo-sub-canvas/src/nodes/batch-function/relation.ts
  18. 28 0
      apps/demo-sub-canvas/src/nodes/batch/index.ts
  19. 38 0
      apps/demo-sub-canvas/src/nodes/block-end/form-meta.tsx
  20. 41 0
      apps/demo-sub-canvas/src/nodes/block-end/index.ts
  21. 38 0
      apps/demo-sub-canvas/src/nodes/block-start/form-meta.tsx
  22. 41 0
      apps/demo-sub-canvas/src/nodes/block-start/index.ts
  23. 20 0
      apps/demo-sub-canvas/src/nodes/loop/form-meta.tsx
  24. 107 0
      apps/demo-sub-canvas/src/nodes/loop/index.ts
  25. 112 0
      apps/demo-sub-canvas/src/tools.tsx
  26. 97 0
      apps/demo-sub-canvas/src/use-editor-props.tsx
  27. 23 0
      apps/demo-sub-canvas/tsconfig.json
  28. 8 0
      common/config/rush/command-line.json
  29. 58 0
      common/config/rush/pnpm-lock.yaml
  30. 10 0
      rush.json

+ 20 - 0
apps/demo-sub-canvas/.eslintrc.js

@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+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 版本
+    },
+  },
+});

+ 12 - 0
apps/demo-sub-canvas/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en" data-bundler="rspack">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Flow FreeLayoutEditor Demo</title>
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>

+ 58 - 0
apps/demo-sub-canvas/package.json

@@ -0,0 +1,58 @@
+{
+  "name": "@flowgram.ai/demo-sub-canvas",
+  "version": "0.1.0",
+  "description": "",
+  "keywords": [],
+  "license": "MIT",
+  "main": "./src/index.tsx",
+  "files": [
+    "src/",
+    ".eslintrc.js",
+    ".gitignore",
+    "index.html",
+    "package.json",
+    "rsbuild.config.ts",
+    "tsconfig.json"
+  ],
+  "scripts": {
+    "build": "exit 0",
+    "build:fast": "exit 0",
+    "build:watch": "exit 0",
+    "build:prod": "cross-env MODE=app NODE_ENV=production rsbuild build",
+    "clean": "rimraf dist",
+    "dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
+    "lint": "eslint ./src --cache",
+    "lint:fix": "eslint ./src --fix",
+    "ts-check": "tsc --noEmit",
+    "start": "cross-env NODE_ENV=development rsbuild dev --open",
+    "test": "exit",
+    "test:cov": "exit",
+    "watch": "exit 0"
+  },
+  "dependencies": {
+    "@flowgram.ai/free-layout-editor": "workspace:*",
+    "@flowgram.ai/free-snap-plugin": "workspace:*",
+    "@flowgram.ai/free-container-plugin": "workspace:*",
+    "@flowgram.ai/minimap-plugin": "workspace:*",
+    "react": "^18",
+    "react-dom": "^18"
+  },
+  "devDependencies": {
+    "@flowgram.ai/ts-config": "workspace:*",
+    "@flowgram.ai/eslint-config": "workspace:*",
+    "@rsbuild/core": "^1.2.16",
+    "@rsbuild/plugin-react": "^1.1.1",
+    "@types/lodash-es": "^4.17.12",
+    "@types/node": "^18",
+    "@types/react": "^18",
+    "@types/react-dom": "^18",
+    "@types/styled-components": "^5",
+    "typescript": "^5.8.3",
+    "eslint": "^8.54.0",
+    "cross-env": "~7.0.3"
+  },
+  "publishConfig": {
+    "access": "public",
+    "registry": "https://registry.npmjs.org/"
+  }
+}

+ 19 - 0
apps/demo-sub-canvas/rsbuild.config.ts

@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { pluginReact } from '@rsbuild/plugin-react';
+import { defineConfig } from '@rsbuild/core';
+
+export default defineConfig({
+  plugins: [pluginReact()],
+  source: {
+    entry: {
+      index: './src/app.tsx',
+    },
+  },
+  html: {
+    title: 'demo-free-layout-simple',
+  },
+});

+ 121 - 0
apps/demo-sub-canvas/src/add-node.tsx

@@ -0,0 +1,121 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  getAntiOverlapPosition,
+  IPoint,
+  useService,
+  WorkflowDocument,
+  WorkflowNodeEntity,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+
+import { LoopNodeRegistry } from './nodes/loop';
+import { createBatchFunction } from './nodes/batch-function';
+
+const getNodeDefaultPosition = (document: WorkflowDocument, nodeType: string): IPoint => {
+  const { size } = document.getNodeRegistry(nodeType).meta || {};
+  // 当前可视区域的中心位置
+  let position = document.playgroundConfig.getViewport(true).center;
+  if (size) {
+    position = {
+      x: position.x,
+      y: position.y - size.height / 2,
+    };
+  }
+  // 去掉叠加的
+  return getAntiOverlapPosition(document, position);
+};
+
+export const AddNode = () => {
+  const workflowDocument = useService(WorkflowDocument);
+  const selectService = useService(WorkflowSelectService);
+
+  return (
+    <div style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 8, display: 'flex', gap: 8 }}>
+      <button
+        style={{
+          border: '1px solid #e0e0e0',
+          borderRadius: '50%',
+          cursor: 'pointer',
+          padding: '4px',
+          color: '#ffffff',
+          background: '#7e72e8',
+          width: 70,
+          height: 70,
+          fontSize: 14,
+        }}
+        onClick={() => {
+          const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
+            'custom',
+            undefined, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
+            {
+              data: {
+                title: 'New Node',
+              },
+            }
+          );
+          selectService.selectNode(node);
+        }}
+      >
+        + Node
+      </button>
+      <button
+        style={{
+          border: '1px solid #e0e0e0',
+          borderRadius: '50%',
+          cursor: 'pointer',
+          padding: '4px',
+          color: '#ffffff',
+          background: '#7e72e8',
+          width: 70,
+          height: 70,
+          fontSize: 14,
+        }}
+        onClick={() => {
+          const json = LoopNodeRegistry.onAdd();
+          const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
+            'loop',
+            undefined, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
+            json
+          );
+          selectService.selectNode(node);
+        }}
+      >
+        + Loop
+      </button>
+      <button
+        style={{
+          border: '1px solid #e0e0e0',
+          borderRadius: '50%',
+          cursor: 'pointer',
+          padding: '4px',
+          color: '#ffffff',
+          background: '#7e72e8',
+          width: 70,
+          height: 70,
+          fontSize: 14,
+        }}
+        onClick={() => {
+          const position = getNodeDefaultPosition(workflowDocument, 'batch');
+
+          const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
+            'batch',
+            position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
+            {
+              data: {
+                title: 'New batch node',
+              },
+            }
+          );
+          createBatchFunction(node, position);
+          selectService.selectNode(node);
+        }}
+      >
+        + Batch
+      </button>
+    </div>
+  );
+};

+ 29 - 0
apps/demo-sub-canvas/src/app.tsx

@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import '@flowgram.ai/free-layout-editor/index.css';
+import { createRoot } from 'react-dom/client';
+import { FreeLayoutEditorProvider, EditorRenderer } from '@flowgram.ai/free-layout-editor';
+
+import { useEditorProps } from './use-editor-props';
+import { Tools } from './tools';
+import { Minimap } from './minimap';
+import { AddNode } from './add-node';
+
+export const FlowGramApp = () => {
+  const editorProps = useEditorProps();
+  return (
+    <FreeLayoutEditorProvider {...editorProps}>
+      <EditorRenderer />
+      <Tools />
+      <Minimap />
+      <AddNode />
+    </FreeLayoutEditorProvider>
+  );
+};
+
+const app = createRoot(document.getElementById('root')!);
+
+app.render(<FlowGramApp />);

+ 282 - 0
apps/demo-sub-canvas/src/initial-data.ts

@@ -0,0 +1,282 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
+
+export const initialData: WorkflowJSON = {
+  nodes: [
+    {
+      id: '1',
+      type: 'start',
+      meta: {
+        position: {
+          x: 140,
+          y: 240.5,
+        },
+      },
+      data: {
+        title: 'Start Node',
+      },
+    },
+    {
+      id: '5',
+      type: 'end',
+      meta: {
+        position: {
+          x: 2196,
+          y: 313.75,
+        },
+      },
+      data: {
+        title: 'End Node',
+      },
+    },
+    {
+      id: 'loop_yvdFr',
+      type: 'loop',
+      meta: {
+        position: {
+          x: 840,
+          y: 52.5,
+        },
+      },
+      data: {
+        title: 'Loop_1',
+      },
+      blocks: [
+        {
+          id: 'block_start_MYgCG',
+          type: 'block_start',
+          meta: {
+            position: {
+              x: 44,
+              y: 94,
+            },
+          },
+          data: {},
+        },
+        {
+          id: 'block_end_hVfcS',
+          type: 'block_end',
+          meta: {
+            position: {
+              x: 612,
+              y: 94,
+            },
+          },
+          data: {},
+        },
+        {
+          id: '144478',
+          type: 'custom',
+          meta: {
+            position: {
+              x: 328,
+              y: 0,
+            },
+          },
+          data: {
+            title: 'New Node',
+          },
+        },
+        {
+          id: '152294',
+          type: 'custom',
+          meta: {
+            position: {
+              x: 328,
+              y: 188,
+            },
+          },
+          data: {
+            title: 'New Node',
+          },
+        },
+      ],
+      edges: [
+        {
+          sourceNodeID: 'block_start_MYgCG',
+          targetNodeID: '144478',
+        },
+        {
+          sourceNodeID: 'block_start_MYgCG',
+          targetNodeID: '152294',
+        },
+        {
+          sourceNodeID: '144478',
+          targetNodeID: 'block_end_hVfcS',
+        },
+        {
+          sourceNodeID: '152294',
+          targetNodeID: 'block_end_hVfcS',
+        },
+      ],
+    },
+    {
+      id: '190959',
+      type: 'batch',
+      meta: {
+        position: {
+          x: 1168,
+          y: 481,
+        },
+      },
+      data: {
+        title: 'New batch node',
+      },
+    },
+    {
+      id: '172297',
+      type: 'custom',
+      meta: {
+        position: {
+          x: 520,
+          y: 481,
+        },
+      },
+      data: {
+        title: 'New Node',
+      },
+    },
+    {
+      id: 'BatchFunction_190959',
+      type: 'batch_function',
+      meta: {
+        position: {
+          x: 840,
+          y: 721.5,
+        },
+      },
+      data: {},
+      blocks: [
+        {
+          id: 'block_start_fMPc6',
+          type: 'block_start',
+          meta: {
+            position: {
+              x: 44,
+              y: 0,
+            },
+          },
+          data: {},
+        },
+        {
+          id: 'block_end_vSpPm',
+          type: 'block_end',
+          meta: {
+            position: {
+              x: 612,
+              y: 0,
+            },
+          },
+          data: {},
+        },
+        {
+          id: '184839',
+          type: 'custom',
+          meta: {
+            position: {
+              x: 328,
+              y: 0,
+            },
+          },
+          data: {
+            title: 'New Node',
+          },
+        },
+      ],
+      edges: [
+        {
+          sourceNodeID: 'block_start_fMPc6',
+          targetNodeID: '184839',
+        },
+        {
+          sourceNodeID: '184839',
+          targetNodeID: 'block_end_vSpPm',
+        },
+      ],
+    },
+    {
+      id: '153487',
+      type: 'custom',
+      meta: {
+        position: {
+          x: 1816,
+          y: 134,
+        },
+      },
+      data: {
+        title: 'New Node',
+      },
+    },
+    {
+      id: '151923',
+      type: 'custom',
+      meta: {
+        position: {
+          x: 1816,
+          y: 481,
+        },
+      },
+      data: {
+        title: 'New Node',
+      },
+    },
+    {
+      id: '173026',
+      type: 'custom',
+      meta: {
+        position: {
+          x: 520,
+          y: 134,
+        },
+      },
+      data: {
+        title: 'New Node',
+      },
+    },
+  ],
+  edges: [
+    {
+      sourceNodeID: '1',
+      targetNodeID: '173026',
+    },
+    {
+      sourceNodeID: '1',
+      targetNodeID: '172297',
+    },
+    {
+      sourceNodeID: '151923',
+      targetNodeID: '5',
+    },
+    {
+      sourceNodeID: '153487',
+      targetNodeID: '5',
+    },
+    {
+      sourceNodeID: '173026',
+      targetNodeID: 'loop_yvdFr',
+    },
+    {
+      sourceNodeID: 'loop_yvdFr',
+      targetNodeID: '153487',
+    },
+    {
+      sourceNodeID: '172297',
+      targetNodeID: '190959',
+    },
+    {
+      sourceNodeID: '190959',
+      targetNodeID: '151923',
+      sourcePortID: 'batch-output',
+    },
+    {
+      sourceNodeID: '190959',
+      targetNodeID: 'BatchFunction_190959',
+      sourcePortID: 'batch-output-to-function',
+      targetPortID: 'batch-function-input',
+    },
+  ],
+};

+ 35 - 0
apps/demo-sub-canvas/src/minimap.tsx

@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { MinimapRender } from '@flowgram.ai/minimap-plugin';
+
+export const Minimap = () => (
+  <div
+    style={{
+      position: 'absolute',
+      right: 16,
+      bottom: 72,
+      zIndex: 100,
+      width: 118,
+    }}
+  >
+    <MinimapRender
+      containerStyles={{
+        pointerEvents: 'auto',
+        position: 'relative',
+        top: 'unset',
+        right: 'unset',
+        bottom: 'unset',
+        left: 'unset',
+      }}
+      inactiveStyle={{
+        opacity: 1,
+        scale: 1,
+        translateX: 0,
+        translateY: 0,
+      }}
+    />
+  </div>
+);

+ 46 - 0
apps/demo-sub-canvas/src/node-registries.tsx

@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowNodeRegistry } from '@flowgram.ai/free-layout-editor';
+
+import { LoopNodeRegistry } from './nodes/loop';
+import { BlockStartNodeRegistry } from './nodes/block-start';
+import { BlockEndNodeRegistry } from './nodes/block-end';
+import { BatchFunctionNodeRegistry } from './nodes/batch-function';
+import { BatchNodeRegistry } from './nodes/batch';
+
+/**
+ * You can customize your own node registry
+ * 你可以自定义节点的注册器
+ */
+export const nodeRegistries: WorkflowNodeRegistry[] = [
+  {
+    type: 'start',
+    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
+  },
+  LoopNodeRegistry,
+  BlockStartNodeRegistry,
+  BlockEndNodeRegistry,
+  BatchNodeRegistry,
+  BatchFunctionNodeRegistry,
+];

+ 40 - 0
apps/demo-sub-canvas/src/node-render.tsx

@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  FlowNodeMeta,
+  useNodeRender,
+  WorkflowNodeProps,
+  WorkflowNodeRenderer,
+} from '@flowgram.ai/free-layout-editor';
+
+export const NodeRender = (props: WorkflowNodeProps) => {
+  const { node, form, selected } = useNodeRender();
+  const meta = node.getNodeMeta<FlowNodeMeta>();
+  return (
+    <WorkflowNodeRenderer
+      style={{
+        minWidth: 280,
+        minHeight: 88,
+        height: 'auto',
+        background: '#fff',
+        border: '1px solid rgba(6, 7, 9, 0.15)',
+        borderColor: selected ? '#4e40e5' : 'rgba(6, 7, 9, 0.15)',
+        borderRadius: 8,
+        boxShadow: '0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02)',
+        display: 'flex',
+        flexDirection: 'column',
+        justifyContent: 'center',
+        position: 'relative',
+        padding: 12,
+        cursor: 'move',
+        ...meta.wrapperStyle,
+      }}
+      node={props.node}
+    >
+      {form?.render()}
+    </WorkflowNodeRenderer>
+  );
+};

+ 39 - 0
apps/demo-sub-canvas/src/nodes/batch-function/create-batch-function-json.ts

@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { IPoint, WorkflowNodeJSON, nanoid } from '@flowgram.ai/free-layout-editor';
+
+export const createBatchFunctionJSON = (id: string, position: IPoint): WorkflowNodeJSON => ({
+  id,
+  type: 'batch_function',
+  data: {},
+  meta: {
+    position,
+  },
+  blocks: [
+    {
+      id: `block_start_${nanoid(5)}`,
+      type: 'block_start',
+      meta: {
+        position: {
+          x: 32,
+          y: 0,
+        },
+      },
+      data: {},
+    },
+    {
+      id: `block_end_${nanoid(5)}`,
+      type: 'block_end',
+      meta: {
+        position: {
+          x: 192,
+          y: 0,
+        },
+      },
+      data: {},
+    },
+  ],
+});

+ 22 - 0
apps/demo-sub-canvas/src/nodes/batch-function/create-batch-function-lines.ts

@@ -0,0 +1,22 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowDocument, delay } from '@flowgram.ai/free-layout-editor';
+
+/** 生成连线 */
+export const createBatchFunctionLines = async (params: {
+  document: WorkflowDocument;
+  batchId: string;
+  batchFunctionId: string;
+}) => {
+  await delay(30); // 等待节点创建完毕
+  const { document, batchId, batchFunctionId } = params;
+  document.linesManager.createLine({
+    from: batchId,
+    to: batchFunctionId,
+    fromPort: 'batch-output-to-function',
+    toPort: 'batch-function-input',
+  });
+};

+ 31 - 0
apps/demo-sub-canvas/src/nodes/batch-function/create-batch-function.ts

@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowNodeEntity, WorkflowDocument, IPoint } from '@flowgram.ai/free-layout-editor';
+
+import { BatchFunctionIDPrefix } from './relation';
+import { createBatchFunctionLines } from './create-batch-function-lines';
+import { createBatchFunctionJSON } from './create-batch-function-json';
+
+/** 创建 Batch 循环体节点 */
+export const createBatchFunction = (batchNode: WorkflowNodeEntity, batchPosition: IPoint) => {
+  const document = batchNode.document as WorkflowDocument;
+  const id = `${BatchFunctionIDPrefix}${batchNode.id}`;
+  const offset: IPoint = {
+    x: -112,
+    y: 230,
+  };
+  const position = {
+    x: batchPosition.x + offset.x,
+    y: batchPosition.y + offset.y,
+  };
+  const batchFunctionJSON = createBatchFunctionJSON(id, position);
+  const batchFunctionNode = document.createWorkflowNode(batchFunctionJSON);
+  createBatchFunctionLines({
+    document,
+    batchId: batchNode.id,
+    batchFunctionId: batchFunctionNode.id,
+  });
+};

+ 20 - 0
apps/demo-sub-canvas/src/nodes/batch-function/form-meta.tsx

@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormMeta } from '@flowgram.ai/free-layout-editor';
+import { SubCanvasRender } from '@flowgram.ai/free-container-plugin';
+
+const formHeight = 48;
+
+export const BatchFunctionFormRender = () => (
+  <>
+    BATCH FUNCTION
+    <SubCanvasRender offsetY={-formHeight} />
+  </>
+);
+
+export const formMeta: FormMeta = {
+  render: BatchFunctionFormRender,
+};

+ 11 - 0
apps/demo-sub-canvas/src/nodes/batch-function/index.ts

@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { createBatchFunctionJSON } from './create-batch-function-json';
+export { createBatchFunctionLines } from './create-batch-function-lines';
+export { createBatchFunction } from './create-batch-function';
+export { BatchFunctionFormRender, formMeta } from './form-meta';
+export { BatchFunctionNodeRegistry } from './registry';
+export { BatchFunctionIDPrefix, getBatchFunctionID, getBatchID } from './relation';

+ 80 - 0
apps/demo-sub-canvas/src/nodes/batch-function/registry.ts

@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  WorkflowNodeEntity,
+  PositionSchema,
+  FlowNodeTransformData,
+  FlowNodeRegistry,
+} from '@flowgram.ai/free-layout-editor';
+
+import { getBatchID } from './relation';
+import { formMeta } from './form-meta';
+
+export const BatchFunctionNodeRegistry: FlowNodeRegistry = {
+  type: 'batch_function',
+  meta: {
+    defaultPorts: [{ type: 'input', location: 'top', portID: 'batch-function-input' }],
+    /**
+     * Mark as subcanvas
+     * 子画布标记
+     */
+    isContainer: true,
+    /**
+     * The subcanvas default size setting
+     * 子画布默认大小设置
+     */
+
+    size: {
+      width: 424,
+      height: 244,
+    },
+    // autoResizeDisable: true,
+    /**
+     * The subcanvas padding setting
+     * 子画布 padding 设置
+     */
+    padding: (transform) => {
+      if (!transform.isContainer) {
+        return {
+          top: 0,
+          bottom: 0,
+          left: 0,
+          right: 0,
+        };
+      }
+      return {
+        top: 65,
+        bottom: 40,
+        left: 80,
+        right: 80,
+      };
+    },
+    /**
+     * Controls the node selection status within the subcanvas
+     * 控制子画布内的节点选中状态
+     */
+    selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {
+      if (!mousePos) {
+        return true;
+      }
+      const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);
+      // 鼠标开始时所在位置不包括当前节点时才可选中
+      return !transform.bounds.contains(mousePos.x, mousePos.y);
+    },
+    // expandable: false, // disable expanded
+    wrapperStyle: {
+      minWidth: 'unset',
+      width: '100%',
+    },
+    // defaultPorts: [{ type: 'output', location: 'right' }, { type: 'input', location: 'left'}, { type: 'output', location: 'bottom', portID: 'bottom' }, { type: 'input', location: 'top', portID: 'top'}]
+  },
+  formMeta,
+  onCreate(node, json) {
+    node.onDispose(() => {
+      node.document.getNode(getBatchID(node.id))?.dispose();
+    });
+  },
+};

+ 10 - 0
apps/demo-sub-canvas/src/nodes/batch-function/relation.ts

@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+/* eslint-disable  @typescript-eslint/naming-convention*/
+export const BatchFunctionIDPrefix = 'BatchFunction_';
+export const getBatchFunctionID = (batchID: string) => BatchFunctionIDPrefix + batchID;
+export const getBatchID = (batchFunctionID: string) =>
+  batchFunctionID.replace(BatchFunctionIDPrefix, '');

+ 28 - 0
apps/demo-sub-canvas/src/nodes/batch/index.ts

@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';
+
+import { getBatchFunctionID } from '../batch-function';
+
+export const BatchNodeRegistry: FlowNodeRegistry = {
+  type: 'batch',
+  meta: {
+    defaultPorts: [
+      { type: 'input' },
+      { type: 'output', portID: 'batch-output' },
+      {
+        type: 'output',
+        portID: 'batch-output-to-function',
+        location: 'bottom',
+      },
+    ],
+  },
+  onCreate(node, json) {
+    node.onDispose(() => {
+      node.document.getNode(getBatchFunctionID(node.id))?.dispose();
+    });
+  },
+};

+ 38 - 0
apps/demo-sub-canvas/src/nodes/block-end/form-meta.tsx

@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormMeta } from '@flowgram.ai/free-layout-editor';
+
+export const renderForm = () => (
+  <>
+    <div
+      style={{
+        width: 60,
+        height: 60,
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+      }}
+    >
+      <div
+        style={{
+          width: 40,
+          height: 40,
+          borderRadius: '50%',
+          cursor: 'move',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+        }}
+      >
+        END
+      </div>
+    </div>
+  </>
+);
+
+export const formMeta: FormMeta = {
+  render: renderForm,
+};

+ 41 - 0
apps/demo-sub-canvas/src/nodes/block-end/index.ts

@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';
+
+import { formMeta } from './form-meta';
+
+export const BlockEndNodeRegistry: FlowNodeRegistry = {
+  type: 'block_end',
+  meta: {
+    isNodeEnd: true,
+    deleteDisable: true,
+    copyDisable: true,
+    sidebarDisabled: true,
+    nodePanelVisible: false,
+    defaultPorts: [{ type: 'input' }],
+    size: {
+      width: 100,
+      height: 100,
+    },
+    wrapperStyle: {
+      minWidth: 'unset',
+      width: '100%',
+      borderWidth: 2,
+      borderRadius: '50%',
+      cursor: 'move',
+    },
+  },
+  /**
+   * Render node via formMeta
+   */
+  formMeta,
+  /**
+   * Start Node cannot be added
+   */
+  canAdd() {
+    return false;
+  },
+};

+ 38 - 0
apps/demo-sub-canvas/src/nodes/block-start/form-meta.tsx

@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormMeta } from '@flowgram.ai/free-layout-editor';
+
+export const renderForm = () => (
+  <>
+    <div
+      style={{
+        width: 60,
+        height: 60,
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+      }}
+    >
+      <div
+        style={{
+          width: 40,
+          height: 40,
+          borderRadius: '50%',
+          cursor: 'move',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+        }}
+      >
+        START
+      </div>
+    </div>
+  </>
+);
+
+export const formMeta: FormMeta = {
+  render: renderForm,
+};

+ 41 - 0
apps/demo-sub-canvas/src/nodes/block-start/index.ts

@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';
+
+import { formMeta } from './form-meta';
+
+export const BlockStartNodeRegistry: FlowNodeRegistry = {
+  type: 'block_start',
+  meta: {
+    isStart: true,
+    deleteDisable: true,
+    copyDisable: true,
+    sidebarDisabled: true,
+    nodePanelVisible: false,
+    defaultPorts: [{ type: 'output' }],
+    size: {
+      width: 100,
+      height: 100,
+    },
+    wrapperStyle: {
+      minWidth: 'unset',
+      width: '100%',
+      borderWidth: 2,
+      borderRadius: '50%',
+      cursor: 'move',
+    },
+  },
+  /**
+   * Render node via formMeta
+   */
+  formMeta,
+  /**
+   * Start Node cannot be added
+   */
+  canAdd() {
+    return false;
+  },
+};

+ 20 - 0
apps/demo-sub-canvas/src/nodes/loop/form-meta.tsx

@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormMeta } from '@flowgram.ai/free-layout-editor';
+import { SubCanvasRender } from '@flowgram.ai/free-container-plugin';
+
+const formHeight = 48;
+
+export const LoopFormRender = () => (
+  <>
+    LOOP
+    <SubCanvasRender offsetY={-formHeight} />
+  </>
+);
+
+export const formMeta: FormMeta = {
+  render: LoopFormRender,
+};

+ 107 - 0
apps/demo-sub-canvas/src/nodes/loop/index.ts

@@ -0,0 +1,107 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  WorkflowNodeEntity,
+  PositionSchema,
+  FlowNodeTransformData,
+  FlowNodeRegistry,
+  nanoid,
+} from '@flowgram.ai/free-layout-editor';
+
+import { formMeta } from './form-meta';
+
+let index = 0;
+export const LoopNodeRegistry: FlowNodeRegistry = {
+  type: 'loop',
+  meta: {
+    /**
+     * Mark as subcanvas
+     * 子画布标记
+     */
+    isContainer: true,
+    /**
+     * The subcanvas default size setting
+     * 子画布默认大小设置
+     */
+    size: {
+      width: 424,
+      height: 244,
+    },
+    // autoResizeDisable: true,
+    /**
+     * The subcanvas padding setting
+     * 子画布 padding 设置
+     */
+    padding: (transform) => {
+      if (!transform.isContainer) {
+        return {
+          top: 0,
+          bottom: 0,
+          left: 0,
+          right: 0,
+        };
+      }
+      return {
+        top: 65,
+        bottom: 40,
+        left: 80,
+        right: 80,
+      };
+    },
+    /**
+     * Controls the node selection status within the subcanvas
+     * 控制子画布内的节点选中状态
+     */
+    selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {
+      if (!mousePos) {
+        return true;
+      }
+      const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);
+      // 鼠标开始时所在位置不包括当前节点时才可选中
+      return !transform.bounds.contains(mousePos.x, mousePos.y);
+    },
+    // expandable: false, // disable expanded
+    wrapperStyle: {
+      minWidth: 'unset',
+      width: '100%',
+    },
+    // defaultPorts: [{ type: 'output', location: 'right' }, { type: 'input', location: 'left'}, { type: 'output', location: 'bottom', portID: 'bottom' }, { type: 'input', location: 'top', portID: 'top'}]
+  },
+  onAdd() {
+    return {
+      id: `loop_${nanoid(5)}`,
+      type: 'loop',
+      data: {
+        title: `Loop_${++index}`,
+      },
+      blocks: [
+        {
+          id: `block_start_${nanoid(5)}`,
+          type: 'block_start',
+          meta: {
+            position: {
+              x: 32,
+              y: 0,
+            },
+          },
+          data: {},
+        },
+        {
+          id: `block_end_${nanoid(5)}`,
+          type: 'block_end',
+          meta: {
+            position: {
+              x: 192,
+              y: 0,
+            },
+          },
+          data: {},
+        },
+      ],
+    };
+  },
+  formMeta,
+};

+ 112 - 0
apps/demo-sub-canvas/src/tools.tsx

@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { CSSProperties, useEffect, useState } from 'react';
+
+import { usePlaygroundTools, useClientContext, LineType } from '@flowgram.ai/free-layout-editor';
+
+import { getBatchID } from './nodes/batch-function';
+
+export const Tools = () => {
+  const { history } = useClientContext();
+  const tools = usePlaygroundTools();
+  const [canUndo, setCanUndo] = useState(false);
+  const [canRedo, setCanRedo] = useState(false);
+
+  const buttonStyle: CSSProperties = {
+    border: '1px solid #e0e0e0',
+    borderRadius: '4px',
+    cursor: 'pointer',
+    padding: '4px',
+    color: '#141414',
+    background: '#e1e3e4',
+  };
+
+  useEffect(() => {
+    const disposable = history.undoRedoService.onChange(() => {
+      setCanUndo(history.canUndo());
+      setCanRedo(history.canRedo());
+    });
+    return () => disposable.dispose();
+  }, [history]);
+
+  return (
+    <div
+      style={{ position: 'absolute', zIndex: 10, bottom: 34, right: 16, display: 'flex', gap: 8 }}
+    >
+      <button style={buttonStyle} onClick={() => tools.zoomin()}>
+        ZoomIn
+      </button>
+      <button style={buttonStyle} onClick={() => tools.zoomout()}>
+        ZoomOut
+      </button>
+      <span
+        style={{
+          ...buttonStyle,
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          cursor: 'default',
+          width: 40,
+        }}
+      >
+        {Math.floor(tools.zoom * 100)}%
+      </span>
+      <button style={buttonStyle} onClick={() => tools.fitView()}>
+        FitView
+      </button>
+      <button
+        style={buttonStyle}
+        onClick={() =>
+          tools.autoLayout({
+            getFollowNode: (node, context) => {
+              if (node.entity.flowNodeType !== 'batch_function') {
+                return;
+              }
+              const batchNodeID = getBatchID(node.entity.id);
+              return {
+                followTo: batchNodeID,
+              };
+            },
+          })
+        }
+      >
+        AutoLayout
+      </button>
+      <button
+        style={buttonStyle}
+        onClick={() =>
+          tools.switchLineType(
+            tools.lineType === LineType.BEZIER ? LineType.LINE_CHART : LineType.BEZIER
+          )
+        }
+      >
+        {tools.lineType === LineType.BEZIER ? 'Bezier' : 'Fold'}
+      </button>
+      <button
+        style={{
+          ...buttonStyle,
+          cursor: canUndo ? 'pointer' : 'not-allowed',
+          color: canUndo ? '#141414' : '#b1b1b1',
+        }}
+        onClick={() => history.undo()}
+        disabled={!canUndo}
+      >
+        Undo
+      </button>
+      <button
+        style={{
+          ...buttonStyle,
+          cursor: canRedo ? 'pointer' : 'not-allowed',
+          color: canRedo ? '#141414' : '#b1b1b1',
+        }}
+        onClick={() => history.redo()}
+        disabled={!canRedo}
+      >
+        Redo
+      </button>
+    </div>
+  );
+};

+ 97 - 0
apps/demo-sub-canvas/src/use-editor-props.tsx

@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useMemo } from 'react';
+
+import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
+import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
+import { Field, FreeLayoutPluginContext, FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
+import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
+
+import { NodeRender } from './node-render';
+import { nodeRegistries } from './node-registries';
+import { initialData } from './initial-data';
+
+export const useEditorProps = () =>
+  useMemo<FreeLayoutProps>(
+    () => ({
+      plugins: () => [
+        createMinimapPlugin({
+          disableLayer: true,
+          canvasStyle: {
+            canvasWidth: 100,
+            canvasHeight: 50,
+            canvasPadding: 50,
+          },
+        }),
+        createFreeSnapPlugin({}),
+        /**
+         * This is used for the rendering of the loop node sub-canvas
+         * 这个用于 loop 节点子画布的渲染
+         */
+        createContainerNodePlugin({}),
+      ],
+      /**
+       * Content change
+       */ onContentChange: (ctx: FreeLayoutPluginContext, event) => {
+        if (ctx.document.disposed) return;
+
+        console.log('Auto Save: ', event, ctx.document.toJSON());
+      },
+      onAllLayersRendered: (ctx) => {
+        ctx.tools.fitView(false);
+      },
+      materials: {
+        renderDefaultNode: NodeRender,
+      },
+      nodeRegistries,
+      canDeleteNode: () => true,
+      canDeleteLine: (ctx, line) => {
+        if (line.from?.flowNodeType === 'batch' && line.to?.flowNodeType === 'batch_function') {
+          return false;
+        }
+        return true;
+      },
+      isHideArrowLine(ctx, line) {
+        if (line.from?.flowNodeType === 'batch' && line.to?.flowNodeType === 'batch_function') {
+          return true;
+        }
+        return false;
+      },
+      initialData,
+      /**
+       * 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
+      },
+      getNodeDefaultRegistry(type) {
+        return {
+          type,
+          meta: {
+            defaultExpanded: true,
+          },
+          formMeta: {
+            /**
+             * Render form
+             */
+            render: () => (
+              <>
+                <Field<string> name="title">{({ field }) => <div>{field.value}</div>}</Field>
+              </>
+            ),
+          },
+        };
+      },
+    }),
+    []
+  );

+ 23 - 0
apps/demo-sub-canvas/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"],
+}

+ 8 - 0
common/config/rush/command-line.json

@@ -337,6 +337,14 @@
 			"safeForSimultaneousRushProcesses": true,
 			"shellCommand": "concurrently --kill-others --raw --prefix \"{name}\" --names [watch],[demo] -c white,blue \"rush build:watch --to-except @flowgram.ai/demo-free-layout-simple\" \"cd apps/demo-free-layout-simple && rushx ts-check && rushx dev\""
 		},
+		{
+			"name": "dev:demo-sub-canvas",
+			"commandKind": "global",
+			"summary": "⭐️️ run dev in app/demo-sub-canvas",
+			"autoinstallerName": "rush-commands",
+			"safeForSimultaneousRushProcesses": true,
+			"shellCommand": "concurrently --kill-others --raw --prefix \"{name}\" --names [watch],[demo] -c white,blue \"rush build:watch --to-except @flowgram.ai/demo-sub-canvas\" \"cd apps/demo-sub-canvas && rushx ts-check && rushx dev\""
+		},
 		{
 			"name": "dev:demo-nextjs",
 			"commandKind": "global",

+ 58 - 0
common/config/rush/pnpm-lock.yaml

@@ -862,6 +862,64 @@ importers:
         specifier: ^5.8.3
         version: 5.9.2
 
+  ../../apps/demo-sub-canvas:
+    dependencies:
+      '@flowgram.ai/free-container-plugin':
+        specifier: workspace:*
+        version: link:../../packages/plugins/free-container-plugin
+      '@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)
+    devDependencies:
+      '@flowgram.ai/eslint-config':
+        specifier: workspace:*
+        version: link:../../config/eslint-config
+      '@flowgram.ai/ts-config':
+        specifier: workspace:*
+        version: link:../../config/ts-config
+      '@rsbuild/core':
+        specifier: ^1.2.16
+        version: 1.5.6
+      '@rsbuild/plugin-react':
+        specifier: ^1.1.1
+        version: 1.4.0(@rsbuild/core@1.5.6)
+      '@types/lodash-es':
+        specifier: ^4.17.12
+        version: 4.17.12
+      '@types/node':
+        specifier: ^18
+        version: 18.19.124
+      '@types/react':
+        specifier: ^18
+        version: 18.3.24
+      '@types/react-dom':
+        specifier: ^18
+        version: 18.3.7(@types/react@18.3.24)
+      '@types/styled-components':
+        specifier: ^5
+        version: 5.1.34
+      cross-env:
+        specifier: ~7.0.3
+        version: 7.0.3
+      eslint:
+        specifier: ^8.54.0
+        version: 8.57.1
+      typescript:
+        specifier: ^5.8.3
+        version: 5.9.2
+
   ../../apps/demo-vite:
     dependencies:
       '@flowgram.ai/form-materials':

+ 10 - 0
rush.json

@@ -962,6 +962,16 @@
                 "demo"
             ]
         },
+        {
+            "packageName": "@flowgram.ai/demo-sub-canvas",
+            "projectFolder": "apps/demo-sub-canvas",
+            "versionPolicyName": "appPolicy",
+            "tags": [
+                "level-1",
+                "team-flow",
+                "demo"
+            ]
+        },
         {
             "packageName": "@flowgram.ai/demo-node-form",
             "projectFolder": "apps/demo-node-form",