Browse Source

feat(fixed-layout): add input/output/multi-outputs/multi-inputs/break node (#246)

* feat(demo): add demo playground

* feat(fixed-layout): add multi-start and break node

* fix: form-model updateFormValues with formatOnInit

* feat(node-engine): formModel.values cloneDeep -> clone and add shllowEuqal checked

* feat(demo): demo-fixed-layout-simple add flow-select

* fix: use-node-render node undefined

* docs: docs error

* feat(fixed-layout): add input/output/multi-outputs/multi-inputs node
xiamidaxia 7 months ago
parent
commit
ce0c13393b
41 changed files with 894 additions and 113 deletions
  1. 7 1
      apps/demo-fixed-layout-simple/src/components/base-node.tsx
  2. 20 8
      apps/demo-fixed-layout-simple/src/components/branch-adder.tsx
  3. 49 0
      apps/demo-fixed-layout-simple/src/components/flow-select.tsx
  4. 24 4
      apps/demo-fixed-layout-simple/src/components/tools.tsx
  5. 81 0
      apps/demo-fixed-layout-simple/src/data/condition.ts
  6. 9 0
      apps/demo-fixed-layout-simple/src/data/index.ts
  7. 99 0
      apps/demo-fixed-layout-simple/src/data/mindmap.ts
  8. 0 0
      apps/demo-fixed-layout-simple/src/data/slots.ts
  9. 0 0
      apps/demo-fixed-layout-simple/src/data/trycatch.ts
  10. 2 0
      apps/demo-fixed-layout-simple/src/editor.tsx
  11. 1 1
      apps/demo-fixed-layout-simple/src/hooks/use-editor-props.tsx
  12. 1 1
      apps/demo-fixed-layout-simple/src/index.css
  13. 4 61
      apps/demo-fixed-layout-simple/src/initial-data.ts
  14. 37 1
      apps/demo-fixed-layout-simple/src/node-registries.ts
  15. 15 0
      apps/demo-playground/.eslintrc.js
  16. 12 0
      apps/demo-playground/index.html
  17. 52 0
      apps/demo-playground/package.json
  18. 14 0
      apps/demo-playground/rsbuild.config.ts
  19. 66 0
      apps/demo-playground/src/components/card.tsx
  20. 28 0
      apps/demo-playground/src/components/playground-tools.tsx
  21. 73 0
      apps/demo-playground/src/index.tsx
  22. 23 0
      apps/demo-playground/tsconfig.json
  23. 6 6
      apps/docs/src/en/guide/advanced/free-layout/line.mdx
  24. 4 4
      apps/docs/src/en/guide/advanced/lines.mdx
  25. 6 6
      apps/docs/src/zh/guide/advanced/free-layout/line.mdx
  26. 49 0
      common/config/rush/pnpm-lock.yaml
  27. 5 0
      packages/canvas-engine/document/src/flow-document.ts
  28. 5 0
      packages/canvas-engine/document/src/typings/flow.ts
  29. 9 0
      packages/canvas-engine/fixed-layout-core/src/activities/break.ts
  30. 5 0
      packages/canvas-engine/fixed-layout-core/src/activities/index.ts
  31. 77 0
      packages/canvas-engine/fixed-layout-core/src/activities/input.ts
  32. 36 0
      packages/canvas-engine/fixed-layout-core/src/activities/multi-inputs.ts
  33. 22 0
      packages/canvas-engine/fixed-layout-core/src/activities/multi-outputs.ts
  34. 13 0
      packages/canvas-engine/fixed-layout-core/src/activities/output.ts
  35. 6 14
      packages/canvas-engine/fixed-layout-core/src/activities/simple-split.ts
  36. 11 1
      packages/canvas-engine/fixed-layout-core/src/flow-registers.ts
  37. 6 2
      packages/client/fixed-layout-editor/src/hooks/use-node-render.tsx
  38. 1 0
      packages/node-engine/form/package.json
  39. 6 2
      packages/node-engine/form/src/core/form-model.ts
  40. 4 1
      packages/node-engine/node/src/form-model-v2.ts
  41. 6 0
      rush.json

+ 7 - 1
apps/demo-fixed-layout-simple/src/components/base-node.tsx

@@ -1,6 +1,8 @@
-import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/fixed-layout-editor';
+import { FlowNodeEntity, useNodeRender, useClientContext } from '@flowgram.ai/fixed-layout-editor';
+import { IconDeleteStroked } from '@douyinfe/semi-icons';
 
 export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
+  const ctx = useClientContext();
   /**
    * Provides methods related to node rendering
    * 提供节点渲染相关的方法
@@ -36,6 +38,10 @@ export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
         ...(nodeRender.isBlockOrderIcon || nodeRender.isBlockIcon ? { width: 260 } : {}),
       }}
     >
+      <IconDeleteStroked
+        style={{ position: 'absolute', right: 4, top: 4 }}
+        onClick={() => ctx.operation.deleteNode(nodeRender.node)}
+      />
       {form?.render()}
     </div>
   );

+ 20 - 8
apps/demo-fixed-layout-simple/src/components/branch-adder.tsx

@@ -15,14 +15,26 @@ export function BranchAdder(props: PropsType) {
   const { isVertical } = node;
 
   function addBranch() {
-    const block = operation.addBlock(node, {
-      id: `branch_${nanoid(5)}`,
-      type: 'block',
-      data: {
-        title: 'New Branch',
-        content: '',
-      },
-    });
+    let block: FlowNodeEntity;
+    if (node.flowNodeType === 'multiOutputs') {
+      block = operation.addBlock(node, {
+        id: `output_${nanoid(5)}`,
+        type: 'output',
+        data: {
+          title: 'New Ouput',
+          content: '',
+        },
+      });
+    } else {
+      block = operation.addBlock(node, {
+        id: `branch_${nanoid(5)}`,
+        type: 'block',
+        data: {
+          title: 'New Branch',
+          content: '',
+        },
+      });
+    }
 
     setTimeout(() => {
       playground.scrollToView({

+ 49 - 0
apps/demo-fixed-layout-simple/src/components/flow-select.tsx

@@ -0,0 +1,49 @@
+import { useEffect, useState } from 'react';
+
+import { useClientContext, FlowLayoutDefault } from '@flowgram.ai/fixed-layout-editor';
+
+import { FLOW_LIST } from '../data';
+
+const url = new window.URL(window.location.href);
+
+export function FlowSelect() {
+  const [demoKey, updateDemoKey] = useState(url.searchParams.get('demo') ?? 'condition');
+  const clientContext = useClientContext();
+  useEffect(() => {
+    const targetDemoJSON = FLOW_LIST[demoKey];
+    if (targetDemoJSON) {
+      clientContext.history.stop(); // Stop redo/undo
+      clientContext.document.fromJSON(targetDemoJSON);
+      console.log(clientContext.document.toString());
+      clientContext.history.start();
+      clientContext.document.setLayout(
+        targetDemoJSON.defaultLayout || FlowLayoutDefault.VERTICAL_FIXED_LAYOUT
+      );
+      // Update URL
+      if (url.searchParams.get('demo')) {
+        url.searchParams.set('demo', demoKey);
+        window.history.pushState({}, '', `/?${url.searchParams.toString()}`);
+      }
+      // Fit View
+      setTimeout(() => {
+        clientContext.playground.config.fitView(clientContext.document.root.bounds);
+      }, 20);
+    }
+  }, [demoKey]);
+  return (
+    <div style={{ position: 'absolute', zIndex: 100 }}>
+      <label style={{ marginRight: 12 }}>Select Demo:</label>
+      <select
+        style={{ width: '180px', height: '32px', fontSize: 16 }}
+        onChange={(e) => updateDemoKey(e.target.value)}
+        value={demoKey}
+      >
+        {Object.keys(FLOW_LIST).map((key) => (
+          <option key={key} value={key}>
+            {key}
+          </option>
+        ))}
+      </select>
+    </div>
+  );
+}

+ 24 - 4
apps/demo-fixed-layout-simple/src/components/tools.tsx

@@ -1,12 +1,17 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useCallback } from 'react';
 
 import { usePlaygroundTools, useClientContext } from '@flowgram.ai/fixed-layout-editor';
+import { IconButton, Space } from '@douyinfe/semi-ui';
+import { IconUnlock, IconLock } from '@douyinfe/semi-icons';
 
 export function Tools() {
-  const { history } = useClientContext();
+  const { history, playground } = useClientContext();
   const tools = usePlaygroundTools();
   const [canUndo, setCanUndo] = useState(false);
   const [canRedo, setCanRedo] = useState(false);
+  const toggleReadonly = useCallback(() => {
+    playground.config.readonly = !playground.config.readonly;
+  }, [playground]);
 
   useEffect(() => {
     const disposable = history.undoRedoService.onChange(() => {
@@ -17,7 +22,7 @@ export function Tools() {
   }, [history]);
 
   return (
-    <div
+    <Space
       style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 16, display: 'flex', gap: 8 }}
     >
       <button onClick={() => tools.zoomin()}>ZoomIn</button>
@@ -30,7 +35,22 @@ export function Tools() {
       <button onClick={() => history.redo()} disabled={!canRedo}>
         Redo
       </button>
+      {playground.config.readonly ? (
+        <IconButton
+          theme="borderless"
+          type="tertiary"
+          icon={<IconLock />}
+          onClick={toggleReadonly}
+        />
+      ) : (
+        <IconButton
+          theme="borderless"
+          type="tertiary"
+          icon={<IconUnlock />}
+          onClick={toggleReadonly}
+        />
+      )}
       <span>{Math.floor(tools.zoom * 100)}%</span>
-    </div>
+    </Space>
   );
 }

+ 81 - 0
apps/demo-fixed-layout-simple/src/data/condition.ts

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

+ 9 - 0
apps/demo-fixed-layout-simple/src/data/index.ts

@@ -0,0 +1,9 @@
+import { FlowDocumentJSON, FlowLayoutDefault } from '@flowgram.ai/fixed-layout-editor';
+
+import { mindmap } from './mindmap';
+import { condition } from './condition';
+
+export const FLOW_LIST: Record<string, FlowDocumentJSON & { defaultLayout?: FlowLayoutDefault }> = {
+  condition,
+  mindmap: { ...mindmap, defaultLayout: FlowLayoutDefault.HORIZONTAL_FIXED_LAYOUT },
+};

+ 99 - 0
apps/demo-fixed-layout-simple/src/data/mindmap.ts

@@ -0,0 +1,99 @@
+import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
+
+export const mindmap: FlowDocumentJSON = {
+  nodes: [
+    {
+      id: 'multiInputs_0',
+      type: 'multiInputs',
+      blocks: [
+        {
+          id: 'input_0',
+          type: 'input',
+          data: {
+            title: 'input_0',
+          },
+        },
+        {
+          id: 'input_1',
+          type: 'input',
+          data: {
+            title: 'input_1',
+          },
+        },
+        // {
+        //   id: 'multiInputs_2',
+        //   type: 'multiInputs',
+        //   blocks: [
+        //     {
+        //       id: 'input_2',
+        //       type: 'input',
+        //       data: {
+        //         title: 'input_2'
+        //       },
+        //     },
+        //     {
+        //       id: 'input',
+        //       type: 'input_3',
+        //       data: {
+        //         title: 'input_3'
+        //       },
+        //     }
+        //   ],
+        // },
+      ],
+    },
+    {
+      id: 'multiOutputs_0',
+      type: 'multiOutputs',
+      data: {
+        title: 'mindNode_0',
+      },
+      blocks: [
+        {
+          id: 'output_0',
+          type: 'output',
+          data: {
+            title: 'output_0',
+          },
+        },
+        {
+          id: 'multiOutputs_1',
+          type: 'multiOutputs',
+          data: {
+            title: 'mindNode_1',
+          },
+          blocks: [
+            {
+              id: 'output_1',
+              type: 'output',
+              data: {
+                title: 'output_1',
+              },
+            },
+            {
+              id: 'output_2',
+              type: 'output',
+              data: {
+                title: 'output_2',
+              },
+            },
+            {
+              id: 'output_3',
+              type: 'output',
+              data: {
+                title: 'output_3',
+              },
+            },
+          ],
+        },
+        {
+          id: 'output_4',
+          type: 'output',
+          data: {
+            title: 'output_4',
+          },
+        },
+      ],
+    },
+  ],
+};

+ 0 - 0
apps/demo-fixed-layout-simple/src/data/slots.ts


+ 0 - 0
apps/demo-fixed-layout-simple/src/data/trycatch.ts


+ 2 - 0
apps/demo-fixed-layout-simple/src/editor.tsx

@@ -8,6 +8,7 @@ import { initialData } from './initial-data';
 import { useEditorProps } from './hooks/use-editor-props';
 import { Tools } from './components/tools';
 import { Minimap } from './components/minimap';
+import { FlowSelect } from './components/flow-select';
 
 export const Editor = () => {
   const editorProps = useEditorProps(initialData, nodeRegistries);
@@ -17,6 +18,7 @@ export const Editor = () => {
         <EditorRenderer>{/* add child panel here */}</EditorRenderer>
       </div>
       <Tools />
+      <FlowSelect />
       <Minimap />
     </FixedLayoutEditorProvider>
   );

+ 1 - 1
apps/demo-fixed-layout-simple/src/hooks/use-editor-props.tsx

@@ -102,7 +102,7 @@ export function useEditorProps(
         enableChangeNode: true, // Listen Node engine data change
         onApply(ctx, opt) {
           // Listen change to trigger auto save
-          // console.log('auto save: ', ctx.document.toJSON(), opt);
+          console.log('auto save: ', ctx.document.toJSON(), opt);
         },
       },
       /**

+ 1 - 1
apps/demo-fixed-layout-simple/src/index.css

@@ -8,7 +8,7 @@
   flex-direction: column;
   justify-content: center;
   position: relative;
-  width: 360px;
+  width: 240px;
   transition: all 0.3s ease;
 }
 

+ 4 - 61
apps/demo-fixed-layout-simple/src/initial-data.ts

@@ -1,65 +1,8 @@
 import { FlowDocumentJSON } from '@flowgram.ai/fixed-layout-editor';
 
+import { condition as conditionDemo } from './data/condition';
+
 /**
- * 配置流程数据,数据为 blocks 嵌套的格式
+ * Initial Data
  */
-export const initialData: FlowDocumentJSON = {
-  nodes: [
-    // 开始节点
-    {
-      id: 'start_0',
-      type: 'start',
-      data: {
-        title: 'Start',
-        content: 'start content',
-      },
-      blocks: [],
-    },
-    // 分支节点
-    {
-      id: 'condition_0',
-      type: 'condition',
-      data: {
-        title: 'Condition',
-      },
-      blocks: [
-        {
-          id: 'branch_0',
-          type: 'block',
-          data: {
-            title: 'Branch 0',
-            content: 'branch 1 content',
-          },
-          blocks: [
-            {
-              id: 'custom_0',
-              type: 'custom',
-              data: {
-                title: 'Custom',
-                content: 'custrom content',
-              },
-            },
-          ],
-        },
-        {
-          id: 'branch_1',
-          type: 'block',
-          data: {
-            title: 'Branch 1',
-            content: 'branch 1 content',
-          },
-          blocks: [],
-        },
-      ],
-    },
-    // 结束节点
-    {
-      id: 'end_0',
-      type: 'end',
-      data: {
-        title: 'End',
-        content: 'end content',
-      },
-    },
-  ],
-};
+export const initialData: FlowDocumentJSON = conditionDemo;

+ 37 - 1
apps/demo-fixed-layout-simple/src/node-registries.ts

@@ -1,5 +1,9 @@
 import { nanoid } from 'nanoid';
-import { FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
+import {
+  FlowNodeRegistry,
+  FlowNodeEntity,
+  FlowNodeBaseType,
+} from '@flowgram.ai/fixed-layout-editor';
 
 /**
  * 自定义节点注册
@@ -17,6 +21,7 @@ export const nodeRegistries: FlowNodeRegistry[] = [
      *  - dynamicSplit: 扩展为分支节点
      *  - end: 扩展为结束节点
      *  - tryCatch: 扩展为 tryCatch 节点
+     *  - break: 分支断开
      *  - default: 扩展为普通节点 (默认)
      */
     extend: 'dynamicSplit',
@@ -72,4 +77,35 @@ export const nodeRegistries: FlowNodeRegistry[] = [
       };
     },
   },
+  {
+    type: 'multiStart2',
+    extend: 'dynamicSplit',
+    meta: {
+      isStart: true,
+    },
+    onCreate(node, json) {
+      const doc = node.document;
+      const addedNodes: FlowNodeEntity[] = [];
+      const blocks = json.blocks || [];
+
+      if (blocks.length > 0) {
+        // 水平布局
+        const inlineBlocksNode = doc.addNode({
+          id: `$inlineBlocks$${node.id}`,
+          type: FlowNodeBaseType.INLINE_BLOCKS,
+          originParent: node,
+          parent: node,
+        });
+        addedNodes.push(inlineBlocksNode);
+        blocks.forEach((blockData) => {
+          doc.addBlock(node, blockData, addedNodes);
+        });
+      }
+      return addedNodes;
+    },
+  },
+  {
+    type: 'tree',
+    extend: 'simpleSplit',
+  },
 ];

+ 15 - 0
apps/demo-playground/.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 版本
+    },
+  },
+});

+ 12 - 0
apps/demo-playground/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>

+ 52 - 0
apps/demo-playground/package.json

@@ -0,0 +1,52 @@
+{
+  "name": "@flowgram.ai/demo-playground",
+  "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",
+    "clean": "rimraf dist",
+    "dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
+    "lint": "eslint ./src --cache",
+    "lint:fix": "eslint ./src --fix",
+    "start": "cross-env NODE_ENV=development rsbuild dev --open",
+    "test": "exit",
+    "test:cov": "exit",
+    "watch": "exit 0"
+  },
+  "dependencies": {
+    "@flowgram.ai/playground-react": "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",
+    "eslint": "^8.54.0",
+    "cross-env": "~7.0.3"
+  },
+  "publishConfig": {
+    "access": "public",
+    "registry": "https://registry.npmjs.org/"
+  }
+}

+ 14 - 0
apps/demo-playground/rsbuild.config.ts

@@ -0,0 +1,14 @@
+import { pluginReact } from '@rsbuild/plugin-react';
+import { defineConfig } from '@rsbuild/core';
+
+export default defineConfig({
+  plugins: [pluginReact()],
+  source: {
+    entry: {
+      index: './src/index.tsx',
+    },
+  },
+  html: {
+    title: 'demo-playground',
+  },
+});

+ 66 - 0
apps/demo-playground/src/components/card.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback, useState } from 'react';
+
+import { usePlayground, usePlaygroundDrag } from '@flowgram.ai/playground-react';
+
+export function Card() {
+  return (
+    <div
+      style={{
+        width: 200,
+        height: 100,
+        position: 'absolute',
+        color: 'white',
+        backgroundColor: 'red',
+        left: 500,
+        top: 500,
+      }}
+    ></div>
+  );
+}
+
+export function DragableCard() {
+  const [pos, setPos] = useState({ x: 200, y: 100 });
+  // 用于拖拽,拖拽到边缘时候会自动滚动画布
+  const dragger = usePlaygroundDrag();
+  const playground = usePlayground();
+  const handleMouseDown = useCallback(
+    (e: React.MouseEvent) => {
+      const startPos = { x: pos.x, y: pos.y };
+      dragger.start(e, {
+        onDragStart() {
+          playground.config.grabDisable = true;
+          // start drag
+        },
+        onDrag(dragEvent) {
+          // 需要 除去当前的缩放比例
+          setPos({
+            x: startPos.x + (dragEvent.endPos.x - dragEvent.startPos.x) / dragEvent.scale,
+            y: startPos.y + (dragEvent.endPos.y - dragEvent.startPos.y) / dragEvent.scale,
+          });
+        },
+        onDragEnd() {
+          playground.config.grabDisable = false;
+          // end drag
+        },
+      });
+      // e.stopPropagation();
+      // e.preventDefault();
+    },
+    [pos]
+  );
+  return (
+    <div
+      onMouseDown={handleMouseDown}
+      style={{
+        cursor: 'move',
+        width: 200,
+        height: 100,
+        position: 'absolute',
+        color: 'white',
+        backgroundColor: 'blue',
+        left: pos.x,
+        top: pos.y,
+      }}
+    ></div>
+  );
+}

+ 28 - 0
apps/demo-playground/src/components/playground-tools.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { usePlaygroundTools } from '@flowgram.ai/playground-react';
+
+export const PlaygroundTools: React.FC<{ minZoom?: number; maxZoom?: number }> = (props) => {
+  const tools = usePlaygroundTools(props);
+  return (
+    <div
+      style={{
+        position: 'absolute',
+        zIndex: 100,
+        right: 100,
+        bottom: 100,
+        padding: 13,
+        border: '1px solid #ccc',
+        backgroundColor: 'white',
+        borderRadius: 8,
+        userSelect: 'none',
+        cursor: 'pointer',
+      }}
+    >
+      <button onClick={() => tools.toggleIneractiveType()}>{tools.interactiveType}</button>
+      <button onClick={() => tools.zoomout()}>Zoom Out</button>
+      <button onClick={() => tools.zoomin()}>Zoom In</button>
+      <span>{Math.floor(tools.zoom * 100)}%</span>
+    </div>
+  );
+};

+ 73 - 0
apps/demo-playground/src/index.tsx

@@ -0,0 +1,73 @@
+import { useMemo } from 'react';
+
+import { createRoot } from 'react-dom/client';
+import {
+  Command,
+  PlaygroundReact,
+  PlaygroundReactContent,
+  PlaygroundReactProps,
+} from '@flowgram.ai/playground-react';
+
+import { PlaygroundTools } from './components/playground-tools';
+import { Card, DragableCard } from './components/card';
+
+// 加载画布样式
+import '@flowgram.ai/playground-react/index.css';
+
+/**
+ * 用于提供纯画布缩放能力
+ */
+export function App() {
+  const playgroundProps = useMemo<PlaygroundReactProps>(
+    () => ({
+      // 是否增加背景
+      background: true,
+      playground: {
+        ineractiveType: 'MOUSE', // 鼠标模式, MOUSE | PAD
+      },
+      // 自定义快捷键
+      shortcuts(registry, ctx) {
+        registry.addHandlers(
+          /**
+           * 放大
+           */
+          {
+            commandId: Command.Default.ZOOM_IN,
+            shortcuts: ['meta =', 'ctrl ='],
+            execute: () => {
+              ctx.playground.config.zoomin();
+            },
+          },
+          /**
+           * 缩小
+           */
+          {
+            commandId: Command.Default.ZOOM_OUT,
+            shortcuts: ['meta -', 'ctrl -'],
+            execute: () => {
+              ctx.playground.config.zoomout();
+            },
+          }
+        );
+      },
+    }),
+    []
+  );
+  /*
+   * PlaygroundReact 画布 react 容器, background 属性可以关闭背景的点点点
+   * PlaygroundReactContent 画布内容,会跟着缩放
+   */
+  return (
+    <PlaygroundReact {...playgroundProps}>
+      <PlaygroundReactContent>
+        <Card />
+        <DragableCard />
+      </PlaygroundReactContent>
+      <PlaygroundTools />
+    </PlaygroundReact>
+  );
+}
+
+const app = createRoot(document.getElementById('root')!);
+
+app.render(<App />);

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

+ 6 - 6
apps/docs/src/en/guide/advanced/free-layout/line.mdx

@@ -40,17 +40,17 @@ const json = ctx.document.linesManager.toJSON()
 import { WorkflowNodeLinesData } from '@flowgram.ai/free-layout-editor'
 
 // get input nodes (calculated through connection lines)
-node.geData(WorkflowNodeLinesData).inputNodes
+node.getData(WorkflowNodeLinesData).inputNodes
 // get all input nodes (recursively gets all upstream nodes)
-node.geData(WorkflowNodeLinesData).allInputNodes
+node.getData(WorkflowNodeLinesData).allInputNodes
 // get output nodes
-node.geData(WorkflowNodeLinesData).outputNodes
+node.getData(WorkflowNodeLinesData).outputNodes
 // get all output nodes
-node.geData(WorkflowNodeLinesData).allOutputNodes
+node.getData(WorkflowNodeLinesData).allOutputNodes
 // input lines
-node.geData(WorkflowNodeLinesData).inputLines
+node.getData(WorkflowNodeLinesData).inputLines
 // output lines
-node.geData(WorkflowNodeLinesData).outputLines
+node.getData(WorkflowNodeLinesData).outputLines
 ```
 
 ## Line Configuration

+ 4 - 4
apps/docs/src/en/guide/advanced/lines.mdx

@@ -8,13 +8,13 @@ The lines in the free layout are managed by [WorkflowLinesManager](/api/core/wor
 import { WorkflowNodeLinesData } from '@flowgram.ai/free-layout-editor'
 
 // Get the input nodes of the current node (calculated through connection lines)
-node.geData(WorkflowNodeLinesData).inputNodes;
+node.getData(WorkflowNodeLinesData).inputNodes;
 // Get all input nodes (recursively get all upward)
-node.geData(WorkflowNodeLinesData).allInputNodes;
+node.getData(WorkflowNodeLinesData).allInputNodes;
 // Get the output nodes
-node.geData(WorkflowNodeLinesData).outputNodes;
+node.getData(WorkflowNodeLinesData).outputNodes;
 // Get all output nodes
-node.geData(WorkflowNodeLinesData).allOutputNodes;
+node.getData(WorkflowNodeLinesData).allOutputNodes;
 ```
 
 ## Node listens to its own connection changes and refreshes

+ 6 - 6
apps/docs/src/zh/guide/advanced/free-layout/line.mdx

@@ -43,17 +43,17 @@ const json = ctx.document.linesManager.toJSON()
 import { WorkflowNodeLinesData } from '@flowgram.ai/free-layout-editor'
 
 // 获取当前节点的输入节点(通过连接线计算)
-node.geData(WorkflowNodeLinesData).inputNodes
+node.getData(WorkflowNodeLinesData).inputNodes
 // 获取所有输入节点 (会往上递归获取所有)
-node.geData(WorkflowNodeLinesData).allInputNodes
+node.getData(WorkflowNodeLinesData).allInputNodes
 // 获取输出节点
-node.geData(WorkflowNodeLinesData).outputNodes
+node.getData(WorkflowNodeLinesData).outputNodes
 // 获取所有输出节点
-node.geData(WorkflowNodeLinesData).allOutputNodes
+node.getData(WorkflowNodeLinesData).allOutputNodes
 // 输入线条
-node.geData(WorkflowNodeLinesData).inputLines
+node.getData(WorkflowNodeLinesData).inputLines
 // 输出线条
-node.geData(WorkflowNodeLinesData).outputLines
+node.getData(WorkflowNodeLinesData).outputLines
 
 ```
 

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

@@ -480,6 +480,52 @@ importers:
         specifier: ^8.54.0
         version: 8.57.1
 
+  ../../apps/demo-playground:
+    dependencies:
+      '@flowgram.ai/playground-react':
+        specifier: workspace:*
+        version: link:../../packages/client/playground-react
+      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.2.19
+      '@rsbuild/plugin-react':
+        specifier: ^1.1.1
+        version: 1.1.1(@rsbuild/core@1.2.19)
+      '@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
+      cross-env:
+        specifier: ~7.0.3
+        version: 7.0.3
+      eslint:
+        specifier: ^8.54.0
+        version: 8.57.1
+
   ../../apps/demo-react-16:
     dependencies:
       '@flowgram.ai/free-layout-editor':
@@ -1928,6 +1974,9 @@ importers:
       '@flowgram.ai/utils':
         specifier: workspace:*
         version: link:../../common/utils
+      fast-equals:
+        specifier: ^2.0.0
+        version: 2.0.4
       lodash:
         specifier: ^4.17.21
         version: 4.17.21

+ 5 - 0
packages/canvas-engine/document/src/flow-document.ts

@@ -471,6 +471,7 @@ export class FlowDocument<T = FlowDocumentJSON> implements Disposable {
     const customDefaultRegistry = this.options.getNodeDefaultRegistry?.(type);
     let register = this.registers.get(type) || { type };
     const extendRegisters: FlowNodeRegistry[] = [];
+    const extendKey = register.extend;
     // 继承重载
     if (register.extend && this.registers.has(register.extend)) {
       register = FlowNodeRegistry.merge(
@@ -505,6 +506,10 @@ export class FlowDocument<T = FlowDocumentJSON> implements Disposable {
         ...register.meta,
       },
     } as T;
+    // Save the "extend" attribute
+    if (extendKey) {
+      res.extend = extendKey;
+    }
     this.nodeRegistryCache.set(typeKey, res);
     return res;
   }

+ 5 - 0
packages/canvas-engine/document/src/typings/flow.ts

@@ -29,8 +29,13 @@ export enum FlowNodeBaseType {
   BLOCK_ORDER_ICON = 'blockOrderIcon', // 带顺序的图标节点,一般为 block 第一个分支节点
   GROUP = 'group', // 分组节点
   END = 'end', // 结束节点
+  BREAK = 'break', // 分支结束
   CONDITION = 'condition', // 可以连接多条线的条件判断节点,目前只支持横向布局
   SUB_CANVAS = 'subCanvas', // 自由布局子画布
+  MULTI_INPUTS = 'multiInputs', // 多输入
+  MULTI_OUTPUTS = 'multiOutputs', // 多输出
+  INPUT = 'input', // 输入节点
+  OUTPUT = 'output', // 输出节点
 }
 
 export enum FlowNodeSplitType {

+ 9 - 0
packages/canvas-engine/fixed-layout-core/src/activities/break.ts

@@ -0,0 +1,9 @@
+import { FlowNodeBaseType, type FlowNodeRegistry } from '@flowgram.ai/document';
+
+/**
+ * Break 节点, 用于分支断开
+ */
+export const BreakRegistry: FlowNodeRegistry = {
+  type: FlowNodeBaseType.BREAK,
+  extend: FlowNodeBaseType.END,
+};

+ 5 - 0
packages/canvas-engine/fixed-layout-core/src/activities/index.ts

@@ -11,3 +11,8 @@ export * from './root';
 export * from './empty';
 export * from './end';
 export * from './simple-split';
+export * from './break';
+export * from './input';
+export * from './output';
+export * from './multi-outputs';
+export * from './multi-inputs';

+ 77 - 0
packages/canvas-engine/fixed-layout-core/src/activities/input.ts

@@ -0,0 +1,77 @@
+import {
+  FlowNodeBaseType,
+  type FlowNodeRegistry,
+  type FlowTransitionLine,
+  FlowTransitionLineEnum,
+  LABEL_SIDE_TYPE,
+} from '@flowgram.ai/document';
+
+/**
+ * 输入节点
+ */
+export const InputRegistry: FlowNodeRegistry = {
+  type: FlowNodeBaseType.INPUT,
+  extend: FlowNodeBaseType.BLOCK,
+  meta: {
+    hidden: false,
+  },
+  getLines(transition) {
+    const currentTransform = transition.transform;
+    const { isVertical } = transition.entity;
+    const lines: FlowTransitionLine[] = [];
+
+    const hasBranchDraggingAdder =
+      currentTransform && currentTransform.entity.isInlineBlock && transition.renderData.draggable;
+
+    // 分支拖拽场景线条 push
+    // 当有其余分支的时候,绘制一条两个分支之间的线条
+    if (hasBranchDraggingAdder) {
+      if (isVertical) {
+        const currentOffsetRightX = currentTransform.firstChild?.bounds?.right || 0;
+        const nextOffsetLeftX = currentTransform.next?.firstChild?.bounds?.left || 0;
+        const currentInputPointY = currentTransform.inputPoint.y;
+        if (currentTransform?.next) {
+          lines.push({
+            type: FlowTransitionLineEnum.DRAGGING_LINE,
+            from: currentTransform.parent!.inputPoint,
+            to: {
+              x: (currentOffsetRightX + nextOffsetLeftX) / 2,
+              y: currentInputPointY,
+            },
+            side: LABEL_SIDE_TYPE.NORMAL_BRANCH,
+          });
+        }
+      } else {
+        const currentOffsetRightY = currentTransform.firstChild?.bounds?.bottom || 0;
+        const nextOffsetLeftY = currentTransform.next?.firstChild?.bounds?.top || 0;
+        const currentInputPointX = currentTransform.inputPoint.x;
+        if (currentTransform?.next) {
+          lines.push({
+            type: FlowTransitionLineEnum.DRAGGING_LINE,
+            from: currentTransform.parent!.inputPoint,
+            to: {
+              x: currentInputPointX,
+              y: (currentOffsetRightY + nextOffsetLeftY) / 2,
+            },
+            side: LABEL_SIDE_TYPE.NORMAL_BRANCH,
+          });
+        }
+      }
+    }
+
+    // 最后一个节点是 end 节点,不绘制 mergeLine
+    if (!transition.isNodeEnd) {
+      lines.push({
+        type: FlowTransitionLineEnum.MERGE_LINE,
+        from: currentTransform.outputPoint,
+        to: currentTransform.parent!.outputPoint,
+        side: LABEL_SIDE_TYPE.NORMAL_BRANCH,
+      });
+    }
+
+    return lines;
+  },
+  getLabels() {
+    return [];
+  },
+};

+ 36 - 0
packages/canvas-engine/fixed-layout-core/src/activities/multi-inputs.ts

@@ -0,0 +1,36 @@
+import { FlowNodeBaseType, FlowNodeSplitType, type FlowNodeRegistry } from '@flowgram.ai/document';
+
+/**
+ * 多输入节点, 只能作为 开始节点
+ * - multiInputs:
+ *   - inlineBlocks
+ *     - input
+ *     - input
+ */
+export const MultiInputsRegistry: FlowNodeRegistry = {
+  type: FlowNodeBaseType.MULTI_INPUTS,
+  extend: FlowNodeSplitType.SIMPLE_SPLIT,
+  extendChildRegistries: [
+    {
+      type: FlowNodeBaseType.BLOCK_ICON,
+      meta: {
+        hidden: true,
+      },
+      getLines() {
+        return [];
+      },
+      getLabels() {
+        return [];
+      },
+    },
+    {
+      type: FlowNodeBaseType.INLINE_BLOCKS,
+      getLabels() {
+        return [];
+      },
+    },
+  ],
+  getLabels() {
+    return [];
+  },
+};

+ 22 - 0
packages/canvas-engine/fixed-layout-core/src/activities/multi-outputs.ts

@@ -0,0 +1,22 @@
+import { FlowNodeBaseType, type FlowNodeRegistry, FlowNodeSplitType } from '@flowgram.ai/document';
+
+import { BlockRegistry } from './block';
+
+/**
+ * 多输出节点
+ * - multiOutputs:
+ *  - blockIcon
+ *  - inlineBlocks
+ *    - output or multiOutputs
+ *    - output or multiOutputs
+ */
+export const MultiOuputsRegistry: FlowNodeRegistry = {
+  type: FlowNodeBaseType.MULTI_OUTPUTS,
+  extend: FlowNodeSplitType.SIMPLE_SPLIT,
+  getLines: (transition, layout) => {
+    if (transition.entity.parent?.flowNodeType === FlowNodeBaseType.INLINE_BLOCKS) {
+      return BlockRegistry.getLines!(transition, layout);
+    }
+    return [];
+  },
+};

+ 13 - 0
packages/canvas-engine/fixed-layout-core/src/activities/output.ts

@@ -0,0 +1,13 @@
+import { FlowNodeBaseType, type FlowNodeRegistry } from '@flowgram.ai/document';
+
+/**
+ * 输出节点, 一般作为 end 节点
+ */
+export const OuputRegistry: FlowNodeRegistry = {
+  type: FlowNodeBaseType.OUTPUT,
+  extend: FlowNodeBaseType.BLOCK,
+  meta: {
+    hidden: false,
+    isNodeEnd: true,
+  },
+};

+ 6 - 14
packages/canvas-engine/fixed-layout-core/src/activities/simple-split.ts

@@ -7,12 +7,11 @@ import {
 } from '@flowgram.ai/document';
 
 /**
- * 可以动态添加分支的分支节点, 无 BlockOrderIcon 节点
- * simpleSplit:  (最原始的 id)
+ * - simpleSplit:  (最原始的 id)
  *  blockIcon
  *  inlineBlocks
- *    block1
- *    block2
+ *    node1
+ *    node2
  */
 export const SimpleSplitRegistry: FlowNodeRegistry = {
   type: FlowNodeSplitType.SIMPLE_SPLIT,
@@ -24,23 +23,16 @@ export const SimpleSplitRegistry: FlowNodeRegistry = {
   ) {
     const { document } = originParent;
     const parent = document.getNode(`$inlineBlocks$${originParent.id}`);
-    // 块节点会生成一个空的 Block 节点用来切割 Block
-    const proxyBlock = document.addNode({
-      id: `$block$${blockData.id}`,
-      type: FlowNodeBaseType.BLOCK,
-      originParent,
-      parent,
-    });
     const realBlock = document.addNode(
       {
         ...blockData,
         type: blockData.type || FlowNodeBaseType.BLOCK,
-        parent: proxyBlock,
+        parent,
       },
       addedNodes
     );
-    addedNodes.push(proxyBlock, realBlock);
-    return proxyBlock;
+    addedNodes.push(realBlock);
+    return realBlock;
   },
   // addChild(node, json, options = {}) {
   //   const { index } = options;

+ 11 - 1
packages/canvas-engine/fixed-layout-core/src/flow-registers.ts

@@ -32,6 +32,11 @@ import {
   StaticSplitRegistry,
   TryCatchRegistry,
   SimpleSplitRegistry,
+  BreakRegistry,
+  MultiOuputsRegistry,
+  MultiInputsRegistry,
+  InputRegistry,
+  OuputRegistry,
 } from './activities';
 
 @injectable()
@@ -59,7 +64,12 @@ export class FlowRegisters
       TryCatchRegistry, // TryCatch
       EndRegistry, // 结束节点
       LoopRegistry, // 循环节点
-      EmptyRegistry // 占位节点
+      EmptyRegistry, // 占位节点
+      BreakRegistry, // 分支断开
+      MultiOuputsRegistry,
+      MultiInputsRegistry,
+      InputRegistry,
+      OuputRegistry
     );
     /**
      * 注册节点数据 (ECS - Component)

+ 6 - 2
packages/client/fixed-layout-editor/src/hooks/use-node-render.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useContext, useMemo } from 'react';
+import React, { useCallback, useEffect, useContext, useMemo, useRef } from 'react';
 
 import { useObserve } from '@flowgram.ai/reactive';
 import { useStartDragNode } from '@flowgram.ai/fixed-drag-plugin';
@@ -93,13 +93,17 @@ export interface NodeRenderReturnType {
  */
 export function useNodeRender(nodeFromProps?: FlowNodeEntity): NodeRenderReturnType {
   const renderNode = nodeFromProps || useContext<FlowNodeEntity>(PlaygroundEntityContext);
+  const nodeCache = useRef<FlowNodeEntity | undefined>();
   const renderData = renderNode.getData<FlowNodeRenderData>(FlowNodeRenderData)!;
   const { expanded, dragging, activated } = renderData;
   const { startDrag: startDragOrigin } = useStartDragNode();
   const playground = usePlayground();
   const isBlockOrderIcon = renderNode.flowNodeType === FlowNodeBaseType.BLOCK_ORDER_ICON;
   const isBlockIcon = renderNode.flowNodeType === FlowNodeBaseType.BLOCK_ICON;
-  const node = isBlockOrderIcon || isBlockIcon ? renderNode.parent! : renderNode;
+  // 在 BlockIcon 情况,如果在触发 fromJSON 时候更新表单数据导致刷新节点会存在 renderNode.parent 为 undefined,所以这里 nodeCache 进行缓存
+  const node =
+    (isBlockOrderIcon || isBlockIcon ? renderNode.parent! : renderNode) || nodeCache.current;
+  nodeCache.current = node;
   const operationService = useService<FlowOperationService>(FlowOperationService);
   const deleteNode = useCallback(() => {
     operationService.deleteNode(node);

+ 1 - 0
packages/node-engine/form/package.json

@@ -33,6 +33,7 @@
   "dependencies": {
     "@flowgram.ai/reactive": "workspace:*",
     "@flowgram.ai/utils": "workspace:*",
+    "fast-equals": "^2.0.0",
     "lodash": "^4.17.21",
     "nanoid": "^4.0.2"
   },

+ 6 - 2
packages/node-engine/form/src/core/form-model.ts

@@ -1,4 +1,5 @@
-import { cloneDeep, flatten, get } from 'lodash';
+import { clone, flatten, get } from 'lodash';
+import { shallowEqual } from 'fast-equals';
 import { Disposable, Emitter } from '@flowgram.ai/utils';
 import { ReactiveState } from '@flowgram.ai/reactive';
 
@@ -78,11 +79,14 @@ export class FormModel<TValues = any> implements Disposable {
   }
 
   get values() {
-    return cloneDeep(this.store.values) || cloneDeep(this.initialValues);
+    return clone(this.store.values) || clone(this.initialValues);
   }
 
   set values(v) {
     const prevValues = this.values;
+    if (shallowEqual(this.store.values || this.initialValues, v)) {
+      return;
+    }
     this.store.values = v;
     this.fireOnFormValuesChange({
       values: this.values,

+ 4 - 1
packages/node-engine/node/src/form-model-v2.ts

@@ -147,7 +147,10 @@ export class FormModelV2 extends FormModel implements Disposable {
 
   updateFormValues(value: any) {
     if (this.nativeFormModel) {
-      this.nativeFormModel.values = value;
+      const finalValue = this.formMeta.formatOnInit
+        ? this.formMeta.formatOnInit(value, this.nodeContext)
+        : value;
+      this.nativeFormModel.values = finalValue;
     }
   }
 

+ 6 - 0
rush.json

@@ -777,6 +777,12 @@
             "projectFolder": "apps/demo-vite",
             "versionPolicyName": "appPolicy",
             "tags": ["level-1", "team-flow", "demo"]
+        },
+        {
+            "packageName": "@flowgram.ai/demo-playground",
+            "projectFolder": "apps/demo-playground",
+            "versionPolicyName": "appPolicy",
+            "tags": ["level-1", "team-flow", "demo"]
         }
     ]
 }