Browse Source

first commit

shaogen1995 6 months ago
commit
6873c75e21
100 changed files with 17829 additions and 0 deletions
  1. 15 0
      .eslintrc.js
  2. 23 0
      .gitignore
  3. 12 0
      index.html
  4. 13070 0
      package-lock.json
  5. 67 0
      package.json
  6. 21 0
      rsbuild.config.ts
  7. 7 0
      src/app.tsx
  8. 8 0
      src/assets/icon-auto-layout.tsx
  9. 19 0
      src/assets/icon-comment.tsx
  10. 9 0
      src/assets/icon-condition.svg
  11. BIN
      src/assets/icon-end.jpg
  12. BIN
      src/assets/icon-llm.jpg
  13. BIN
      src/assets/icon-loop.jpg
  14. 19 0
      src/assets/icon-minimap.tsx
  15. 36 0
      src/assets/icon-mouse.tsx
  16. 51 0
      src/assets/icon-pad.tsx
  17. BIN
      src/assets/icon-start.jpg
  18. 10 0
      src/assets/icon-switch-line.tsx
  19. 23 0
      src/components/add-node/index.tsx
  20. 85 0
      src/components/add-node/use-add-node.ts
  21. 40 0
      src/components/base-node/index.tsx
  22. 65 0
      src/components/base-node/node-wrapper.tsx
  23. 35 0
      src/components/base-node/styles.tsx
  24. 18 0
      src/components/base-node/utils.ts
  25. 43 0
      src/components/comment/components/blank-area.tsx
  26. 115 0
      src/components/comment/components/border-area.tsx
  27. 45 0
      src/components/comment/components/container.tsx
  28. 89 0
      src/components/comment/components/content-drag-area.tsx
  29. 40 0
      src/components/comment/components/drag-area.tsx
  30. 60 0
      src/components/comment/components/editor.tsx
  31. 103 0
      src/components/comment/components/index.css
  32. 3 0
      src/components/comment/components/index.ts
  33. 21 0
      src/components/comment/components/more-button.tsx
  34. 78 0
      src/components/comment/components/render.tsx
  35. 73 0
      src/components/comment/components/resize-area.tsx
  36. 20 0
      src/components/comment/constant.ts
  37. 1 0
      src/components/comment/hooks/index.ts
  38. 50 0
      src/components/comment/hooks/use-model.ts
  39. 45 0
      src/components/comment/hooks/use-overflow.ts
  40. 163 0
      src/components/comment/hooks/use-size.ts
  41. 1 0
      src/components/comment/index.ts
  42. 106 0
      src/components/comment/model.ts
  43. 24 0
      src/components/comment/type.ts
  44. 100 0
      src/components/group/color.ts
  45. 49 0
      src/components/group/components/background.tsx
  46. 45 0
      src/components/group/components/color.tsx
  47. 41 0
      src/components/group/components/header.tsx
  48. 47 0
      src/components/group/components/icon-group.tsx
  49. 2 0
      src/components/group/components/index.ts
  50. 75 0
      src/components/group/components/node-render.tsx
  51. 33 0
      src/components/group/components/tips/global-store.ts
  52. 9 0
      src/components/group/components/tips/icon-close.tsx
  53. 36 0
      src/components/group/components/tips/index.tsx
  54. 1 0
      src/components/group/components/tips/is-mac-os.ts
  55. 74 0
      src/components/group/components/tips/style.ts
  56. 66 0
      src/components/group/components/tips/use-control.ts
  57. 33 0
      src/components/group/components/title.tsx
  58. 14 0
      src/components/group/components/tools.tsx
  59. 31 0
      src/components/group/components/ungroup.tsx
  60. 7 0
      src/components/group/constant.ts
  61. 109 0
      src/components/group/index.css
  62. 4 0
      src/components/group/index.ts
  63. 5 0
      src/components/index.ts
  64. 26 0
      src/components/line-add-button/button.tsx
  65. 8 0
      src/components/line-add-button/index.less
  66. 126 0
      src/components/line-add-button/index.tsx
  67. 23 0
      src/components/line-add-button/use-visible.ts
  68. 118 0
      src/components/node-menu/index.tsx
  69. 55 0
      src/components/node-panel/index.less
  70. 49 0
      src/components/node-panel/index.tsx
  71. 88 0
      src/components/node-panel/node-list.tsx
  72. 26 0
      src/components/node-panel/node-placeholder.tsx
  73. 98 0
      src/components/selector-box-popover/index.tsx
  74. 2 0
      src/components/sidebar/index.tsx
  75. 14 0
      src/components/sidebar/sidebar-node-renderer.tsx
  76. 12 0
      src/components/sidebar/sidebar-provider.tsx
  77. 88 0
      src/components/sidebar/sidebar-renderer.tsx
  78. 8 0
      src/components/testrun/node-status-bar/group/index.css
  79. 61 0
      src/components/testrun/node-status-bar/group/index.tsx
  80. 52 0
      src/components/testrun/node-status-bar/header/index.tsx
  81. 56 0
      src/components/testrun/node-status-bar/header/style.ts
  82. 32 0
      src/components/testrun/node-status-bar/icon/success.tsx
  83. 22 0
      src/components/testrun/node-status-bar/icon/warning.tsx
  84. 42 0
      src/components/testrun/node-status-bar/index.tsx
  85. 14 0
      src/components/testrun/node-status-bar/render/index.css
  86. 233 0
      src/components/testrun/node-status-bar/render/index.tsx
  87. 137 0
      src/components/testrun/node-status-bar/viewer/index.css
  88. 154 0
      src/components/testrun/node-status-bar/viewer/index.tsx
  89. 78 0
      src/components/testrun/testrun-button/index.tsx
  90. 138 0
      src/components/testrun/testrun-sidesheet/index.tsx
  91. 26 0
      src/components/tools/auto-layout.tsx
  92. 76 0
      src/components/tools/comment.tsx
  93. 17 0
      src/components/tools/fit-view.tsx
  94. 77 0
      src/components/tools/index.tsx
  95. 96 0
      src/components/tools/interactive.tsx
  96. 21 0
      src/components/tools/minimap-switch.tsx
  97. 33 0
      src/components/tools/minimap.tsx
  98. 112 0
      src/components/tools/mouse-pad-selector.less
  99. 117 0
      src/components/tools/mouse-pad-selector.tsx
  100. 0 0
      src/components/tools/readonly.tsx

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

+ 23 - 0
.gitignore

@@ -0,0 +1,23 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*

+ 12 - 0
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>四维时代_思维图</title>
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>

File diff suppressed because it is too large
+ 13070 - 0
package-lock.json


+ 67 - 0
package.json

@@ -0,0 +1,67 @@
+{
+  "name": "@flowgram.ai/demo-free-layout",
+  "version": "0.1.48",
+  "description": "",
+  "keywords": [],
+  "license": "MIT",
+  "main": "./src/index.ts",
+  "files": [
+    "src/",
+    ".eslintrc.js",
+    ".gitignore",
+    "index.html",
+    "package.json",
+    "rsbuild.config.ts",
+    "tsconfig.json"
+  ],
+  "dependencies": {
+    "@douyinfe/semi-icons": "^2.80.0",
+    "@douyinfe/semi-ui": "^2.80.0",
+    "lodash-es": "^4.17.21",
+    "nanoid": "^4.0.2",
+    "react": "^18",
+    "react-dom": "^18",
+    "styled-components": "^5",
+    "@flowgram.ai/runtime-interface": "0.2.9",
+    "@flowgram.ai/free-layout-editor": "0.2.9",
+    "@flowgram.ai/free-snap-plugin": "0.2.9",
+    "@flowgram.ai/free-lines-plugin": "0.2.9",
+    "@flowgram.ai/free-node-panel-plugin": "0.2.9",
+    "@flowgram.ai/minimap-plugin": "0.2.9",
+    "@flowgram.ai/free-container-plugin": "0.2.9",
+    "@flowgram.ai/free-group-plugin": "0.2.9",
+    "@flowgram.ai/form-materials": "0.2.9",
+    "@flowgram.ai/runtime-js": "0.2.9"
+  },
+  "devDependencies": {
+    "@rsbuild/core": "^1.2.16",
+    "@rsbuild/plugin-react": "^1.1.1",
+    "@rsbuild/plugin-less": "^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",
+    "@flowgram.ai/ts-config": "0.2.9",
+    "@flowgram.ai/eslint-config": "0.2.9"
+  },
+  "publishConfig": {
+    "access": "public",
+    "registry": "https://registry.npmjs.org/"
+  },
+  "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"
+  }
+}

+ 21 - 0
rsbuild.config.ts

@@ -0,0 +1,21 @@
+import { pluginReact } from '@rsbuild/plugin-react';
+import { pluginLess } from '@rsbuild/plugin-less';
+import { defineConfig } from '@rsbuild/core';
+
+export default defineConfig({
+  plugins: [pluginReact(), pluginLess()],
+  source: {
+    entry: {
+      index: './src/app.tsx',
+    },
+    /**
+     * support inversify @injectable() and @inject decorators
+     */
+    decorators: {
+      version: 'legacy',
+    },
+  },
+  html: {
+    title: '四维时代_思维图',
+  },
+});

+ 7 - 0
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(<Editor />);

File diff suppressed because it is too large
+ 8 - 0
src/assets/icon-auto-layout.tsx


+ 19 - 0
src/assets/icon-comment.tsx

@@ -0,0 +1,19 @@
+import { CSSProperties, FC } from 'react';
+
+interface IconCommentProps {
+  style?: CSSProperties;
+}
+
+export const IconComment: FC<IconCommentProps> = ({ style }) => (
+  <svg
+    width="1em"
+    height="1em"
+    viewBox="0 0 24 24"
+    fill="currentColor"
+    xmlns="http://www.w3.org/2000/svg"
+    style={style}
+  >
+    <path d="M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z"></path>
+    <path d="M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z"></path>
+  </svg>
+);

File diff suppressed because it is too large
+ 9 - 0
src/assets/icon-condition.svg


BIN
src/assets/icon-end.jpg


BIN
src/assets/icon-llm.jpg


BIN
src/assets/icon-loop.jpg


File diff suppressed because it is too large
+ 19 - 0
src/assets/icon-minimap.tsx


File diff suppressed because it is too large
+ 36 - 0
src/assets/icon-mouse.tsx


+ 51 - 0
src/assets/icon-pad.tsx

@@ -0,0 +1,51 @@
+export function IconPad(props: { width?: number; height?: number }) {
+  const { width, height } = props;
+  return (
+    <svg
+      width={width || 48}
+      height={height || 38}
+      viewBox="0 0 48 38"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <rect
+        x="1.83317"
+        y="1.49998"
+        width="44.3333"
+        height="35"
+        rx="3.5"
+        stroke="currentColor"
+        strokeOpacity="0.8"
+        strokeWidth="2.33333"
+      />
+      <path
+        d="M14.6665 30.6667H33.3332"
+        stroke="currentColor"
+        strokeOpacity="0.8"
+        strokeWidth="2.33333"
+        strokeLinecap="round"
+      />
+    </svg>
+  );
+}
+
+export const IconPadTool = () => (
+  <svg
+    width="1em"
+    height="1em"
+    viewBox="0 0 24 24"
+    fill="currentColor"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z"
+    ></path>
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z"
+    ></path>
+  </svg>
+);

BIN
src/assets/icon-start.jpg


File diff suppressed because it is too large
+ 10 - 0
src/assets/icon-switch-line.tsx


+ 23 - 0
src/components/add-node/index.tsx

@@ -0,0 +1,23 @@
+import { Button } from '@douyinfe/semi-ui';
+import { IconPlus } from '@douyinfe/semi-icons';
+
+import { useAddNode } from './use-add-node';
+
+export const AddNode = (props: { disabled: boolean }) => {
+  const addNode = useAddNode();
+  return (
+    <Button
+      data-testid="demo.free-layout.add-node"
+      icon={<IconPlus />}
+      color="highlight"
+      style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
+      disabled={props.disabled}
+      onClick={(e) => {
+        const rect = e.currentTarget.getBoundingClientRect();
+        addNode(rect);
+      }}
+    >
+      Add Node
+    </Button>
+  );
+};

+ 85 - 0
src/components/add-node/use-add-node.ts

@@ -0,0 +1,85 @@
+import { useCallback } from 'react';
+
+import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
+import {
+  useService,
+  WorkflowDocument,
+  usePlayground,
+  PositionSchema,
+  WorkflowNodeEntity,
+  WorkflowSelectService,
+  WorkflowNodeJSON,
+} from '@flowgram.ai/free-layout-editor';
+
+// hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook
+const useGetPanelPosition = () => {
+  const playground = usePlayground();
+
+  return useCallback(
+    (targetBoundingRect: DOMRect): PositionSchema =>
+      // convert mouse position to canvas position - 将鼠标位置转换为画布位置
+      playground.config.getPosFromMouseEvent({
+        clientX: targetBoundingRect.left + 64,
+        clientY: targetBoundingRect.top - 7,
+      }),
+    [playground]
+  );
+};
+
+// hook to handle node selection - 处理节点选择的 hook
+const useSelectNode = () => {
+  const selectService = useService(WorkflowSelectService);
+  return useCallback(
+    (node?: WorkflowNodeEntity) => {
+      if (!node) {
+        return;
+      }
+      // select the target node - 选择目标节点
+      selectService.selectNode(node);
+    },
+    [selectService]
+  );
+};
+
+// main hook for adding new nodes - 添加新节点的主 hook
+export const useAddNode = () => {
+  const workflowDocument = useService(WorkflowDocument);
+  const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
+  const playground = usePlayground();
+  const getPanelPosition = useGetPanelPosition();
+  const select = useSelectNode();
+
+  return useCallback(
+    async (targetBoundingRect: DOMRect): Promise<void> => {
+      // calculate panel position based on target element - 根据目标元素计算面板位置
+      const panelPosition = getPanelPosition(targetBoundingRect);
+      await new Promise<void>((resolve) => {
+        // call the node panel service to show the panel - 调用节点面板服务来显示面板
+        nodePanelService.callNodePanel({
+          position: panelPosition,
+          enableMultiAdd: true,
+          panelProps: {},
+          // handle node selection from panel - 处理从面板中选择节点
+          onSelect: async (panelParams?: NodePanelResult) => {
+            if (!panelParams) {
+              return;
+            }
+            const { nodeType, nodeJSON } = panelParams;
+            // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
+            const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
+              nodeType,
+              undefined, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
+              nodeJSON ?? ({} as WorkflowNodeJSON)
+            );
+            select(node); // select the newly created node - 选择新创建的节点
+          },
+          // handle panel close - 处理面板关闭
+          onClose: () => {
+            resolve();
+          },
+        });
+      });
+    },
+    [getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select]
+  );
+};

+ 40 - 0
src/components/base-node/index.tsx

@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+
+import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor';
+import { ConfigProvider } from '@douyinfe/semi-ui';
+
+import { NodeStatusBar } from '../testrun/node-status-bar';
+import { NodeRenderContext } from '../../context';
+import { ErrorIcon } from './styles';
+import { NodeWrapper } from './node-wrapper';
+
+export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
+  /**
+   * Provides methods related to node rendering
+   * 提供节点渲染相关的方法
+   */
+  const nodeRender = useNodeRender();
+  /**
+   * It can only be used when nodeEngine is enabled
+   * 只有在节点引擎开启时候才能使用表单
+   */
+  const form = nodeRender.form;
+
+  /**
+   * Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library
+   * 用于让 Tooltip 跟随节点缩放, 这个可以根据不同的 ui 库自己实现
+   */
+  const getPopupContainer = useCallback(() => node.renderData.node || document.body, []);
+
+  return (
+    <ConfigProvider getPopupContainer={getPopupContainer}>
+      <NodeRenderContext.Provider value={nodeRender}>
+        <NodeWrapper>
+          {form?.state.invalid && <ErrorIcon />}
+          {form?.render()}
+        </NodeWrapper>
+        <NodeStatusBar />
+      </NodeRenderContext.Provider>
+    </ConfigProvider>
+  );
+};

+ 65 - 0
src/components/base-node/node-wrapper.tsx

@@ -0,0 +1,65 @@
+import React, { useState, useContext } from 'react';
+
+import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
+import { useClientContext } from '@flowgram.ai/free-layout-editor';
+
+import { useNodeRenderContext } from '../../hooks';
+import { SidebarContext } from '../../context';
+import { scrollToView } from './utils';
+import { NodeWrapperStyle } from './styles';
+
+export interface NodeWrapperProps {
+  isScrollToView?: boolean;
+  children: React.ReactNode;
+}
+
+/**
+ * Used for drag-and-drop/click events and ports rendering of nodes
+ * 用于节点的拖拽/点击事件和点位渲染
+ */
+export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
+  const { children, isScrollToView = false } = props;
+  const nodeRender = useNodeRenderContext();
+  const { selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur } = nodeRender;
+  const [isDragging, setIsDragging] = useState(false);
+  const sidebar = useContext(SidebarContext);
+  const form = nodeRender.form;
+  const ctx = useClientContext();
+
+  const portsRender = ports.map((p) => <WorkflowPortRender key={p.id} entity={p} />);
+
+  return (
+    <>
+      <NodeWrapperStyle
+        className={selected ? 'selected' : ''}
+        ref={nodeRef}
+        draggable
+        onDragStart={(e) => {
+          startDrag(e);
+          setIsDragging(true);
+        }}
+        onClick={(e) => {
+          selectNode(e);
+          if (!isDragging) {
+            sidebar.setNodeId(nodeRender.node.id);
+            // 可选:将 isScrollToView 设为 true,可以让节点选中后滚动到画布中间
+            // Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected.
+            if (isScrollToView) {
+              scrollToView(ctx, nodeRender.node);
+            }
+          }
+        }}
+        onMouseUp={() => setIsDragging(false)}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        data-node-selected={String(selected)}
+        style={{
+          outline: form?.state.invalid ? '1px solid red' : 'none',
+        }}
+      >
+        {children}
+      </NodeWrapperStyle>
+      {portsRender}
+    </>
+  );
+};

+ 35 - 0
src/components/base-node/styles.tsx

@@ -0,0 +1,35 @@
+import styled from 'styled-components';
+import { IconInfoCircle } from '@douyinfe/semi-icons';
+
+export const NodeWrapperStyle = styled.div`
+  align-items: flex-start;
+  background-color: #fff;
+  border: 1px solid rgba(6, 7, 9, 0.15);
+  border-radius: 8px;
+  box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  position: relative;
+  min-width: 360px;
+  width: 100%;
+  height: auto;
+
+  &.selected {
+    border: 1px solid #4e40e5;
+  }
+`;
+
+export const ErrorIcon = () => (
+  <IconInfoCircle
+    style={{
+      position: 'absolute',
+      color: 'red',
+      left: -6,
+      top: -6,
+      zIndex: 1,
+      background: 'white',
+      borderRadius: 8,
+    }}
+  />
+);

+ 18 - 0
src/components/base-node/utils.ts

@@ -0,0 +1,18 @@
+import { FreeLayoutPluginContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
+
+export function scrollToView(
+  ctx: FreeLayoutPluginContext,
+  node: FlowNodeEntity,
+  sidebarWidth = 448
+) {
+  const bounds = node.transform.bounds;
+  ctx.playground.scrollToView({
+    bounds,
+    scrollDelta: {
+      x: sidebarWidth / 2,
+      y: 0,
+    },
+    zoom: 1,
+    scrollToCenter: true,
+  });
+}

+ 43 - 0
src/components/comment/components/blank-area.tsx

@@ -0,0 +1,43 @@
+import type { FC } from 'react';
+
+import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
+
+import type { CommentEditorModel } from '../model';
+import { DragArea } from './drag-area';
+
+interface IBlankArea {
+  model: CommentEditorModel;
+}
+
+export const BlankArea: FC<IBlankArea> = (props) => {
+  const { model } = props;
+  const playground = usePlayground();
+  const { selectNode } = useNodeRender();
+
+  return (
+    <div
+      className="workflow-comment-blank-area h-full w-full"
+      onMouseDown={(e) => {
+        e.preventDefault();
+        e.stopPropagation();
+        model.setFocus(false);
+        selectNode(e);
+        playground.node.focus(); // 防止节点无法被删除
+      }}
+      onClick={(e) => {
+        model.setFocus(true);
+        model.selectEnd();
+      }}
+    >
+      <DragArea
+        style={{
+          position: 'relative',
+          width: '100%',
+          height: '100%',
+        }}
+        model={model}
+        stopEvent={false}
+      />
+    </div>
+  );
+};

+ 115 - 0
src/components/comment/components/border-area.tsx

@@ -0,0 +1,115 @@
+import { type FC } from 'react';
+
+import type { CommentEditorModel } from '../model';
+import { ResizeArea } from './resize-area';
+import { DragArea } from './drag-area';
+
+interface IBorderArea {
+  model: CommentEditorModel;
+  overflow: boolean;
+  onResize?: () => {
+    resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
+    resizeEnd: () => void;
+  };
+}
+
+export const BorderArea: FC<IBorderArea> = (props) => {
+  const { model, overflow, onResize } = props;
+
+  return (
+    <div style={{ zIndex: 999 }}>
+      {/* 左边 */}
+      <DragArea
+        style={{
+          position: 'absolute',
+          left: -10,
+          top: 10,
+          width: 20,
+          height: 'calc(100% - 20px)',
+        }}
+        model={model}
+      />
+      {/* 右边 */}
+      <DragArea
+        style={{
+          position: 'absolute',
+          right: -10,
+          top: 10,
+          height: 'calc(100% - 20px)',
+          width: overflow ? 10 : 20, // 防止遮挡滚动条
+        }}
+        model={model}
+      />
+      {/* 上边 */}
+      <DragArea
+        style={{
+          position: 'absolute',
+          top: -10,
+          left: 10,
+          width: 'calc(100% - 20px)',
+          height: 20,
+        }}
+        model={model}
+      />
+      {/* 下边 */}
+      <DragArea
+        style={{
+          position: 'absolute',
+          bottom: -10,
+          left: 10,
+          width: 'calc(100% - 20px)',
+          height: 20,
+        }}
+        model={model}
+      />
+      {/** 左上角 */}
+      <ResizeArea
+        style={{
+          position: 'absolute',
+          left: 0,
+          top: 0,
+          cursor: 'nwse-resize',
+        }}
+        model={model}
+        getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}
+        onResize={onResize}
+      />
+      {/** 右上角 */}
+      <ResizeArea
+        style={{
+          position: 'absolute',
+          right: 0,
+          top: 0,
+          cursor: 'nesw-resize',
+        }}
+        model={model}
+        getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}
+        onResize={onResize}
+      />
+      {/** 右下角 */}
+      <ResizeArea
+        style={{
+          position: 'absolute',
+          right: 0,
+          bottom: 0,
+          cursor: 'nwse-resize',
+        }}
+        model={model}
+        getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}
+        onResize={onResize}
+      />
+      {/** 左下角 */}
+      <ResizeArea
+        style={{
+          position: 'absolute',
+          left: 0,
+          bottom: 0,
+          cursor: 'nesw-resize',
+        }}
+        model={model}
+        getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}
+        onResize={onResize}
+      />
+    </div>
+  );
+};

+ 45 - 0
src/components/comment/components/container.tsx

@@ -0,0 +1,45 @@
+import type { ReactNode, FC, CSSProperties } from 'react';
+
+interface ICommentContainer {
+  focused: boolean;
+  children?: ReactNode;
+  style?: React.CSSProperties;
+}
+
+export const CommentContainer: FC<ICommentContainer> = (props) => {
+  const { focused, children, style } = props;
+
+  const scrollbarStyle = {
+    // 滚动条样式
+    scrollbarWidth: 'thin',
+    scrollbarColor: 'rgb(159 159 158 / 65%) transparent',
+    // 针对 WebKit 浏览器(如 Chrome、Safari)的样式
+    '&:WebkitScrollbar': {
+      width: '4px',
+    },
+    '&::WebkitScrollbarTrack': {
+      background: 'transparent',
+    },
+    '&::WebkitScrollbarThumb': {
+      backgroundColor: 'rgb(159 159 158 / 65%)',
+      borderRadius: '20px',
+      border: '2px solid transparent',
+    },
+  } as unknown as CSSProperties;
+
+  return (
+    <div
+      className="workflow-comment-container"
+      data-flow-editor-selectable="false"
+      style={{
+        // tailwind 不支持 outline 的样式,所以这里需要使用 style 来设置
+        outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',
+        backgroundColor: focused ? '#FFF3EA' : '#FFFBED',
+        ...scrollbarStyle,
+        ...style,
+      }}
+    >
+      {children}
+    </div>
+  );
+};

+ 89 - 0
src/components/comment/components/content-drag-area.tsx

@@ -0,0 +1,89 @@
+import { type FC, useState, useEffect, type WheelEventHandler } from 'react';
+
+import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
+
+import type { CommentEditorModel } from '../model';
+import { DragArea } from './drag-area';
+
+interface IContentDragArea {
+  model: CommentEditorModel;
+  focused: boolean;
+  overflow: boolean;
+}
+
+export const ContentDragArea: FC<IContentDragArea> = (props) => {
+  const { model, focused, overflow } = props;
+  const playground = usePlayground();
+  const { selectNode } = useNodeRender();
+
+  const [active, setActive] = useState(false);
+
+  useEffect(() => {
+    // 当编辑器失去焦点时,取消激活状态
+    if (!focused) {
+      setActive(false);
+    }
+  }, [focused]);
+
+  const handleWheel: WheelEventHandler<HTMLDivElement> = (e) => {
+    const editorElement = model.element;
+    if (active || !overflow || !editorElement) {
+      return;
+    }
+    e.stopPropagation();
+    const maxScroll = editorElement.scrollHeight - editorElement.clientHeight;
+    const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll);
+    editorElement.scroll(0, newScrollTop);
+  };
+
+  const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
+    if (active) {
+      return;
+    }
+    mouseDownEvent.preventDefault();
+    mouseDownEvent.stopPropagation();
+    model.setFocus(false);
+    selectNode(mouseDownEvent);
+    playground.node.focus(); // 防止节点无法被删除
+
+    const startX = mouseDownEvent.clientX;
+    const startY = mouseDownEvent.clientY;
+
+    const handleMouseUp = (mouseMoveEvent: MouseEvent) => {
+      const deltaX = mouseMoveEvent.clientX - startX;
+      const deltaY = mouseMoveEvent.clientY - startY;
+      // 判断是拖拽还是点击
+      const delta = 5;
+      if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {
+        // 点击后隐藏
+        setActive(true);
+      }
+      document.removeEventListener('mouseup', handleMouseUp);
+      document.removeEventListener('click', handleMouseUp);
+    };
+
+    document.addEventListener('mouseup', handleMouseUp);
+    document.addEventListener('click', handleMouseUp);
+  };
+
+  return (
+    <div
+      className="workflow-comment-content-drag-area"
+      onMouseDown={handleMouseDown}
+      onWheel={handleWheel}
+      style={{
+        display: active ? 'none' : undefined,
+      }}
+    >
+      <DragArea
+        style={{
+          position: 'relative',
+          width: '100%',
+          height: '100%',
+        }}
+        model={model}
+        stopEvent={false}
+      />
+    </div>
+  );
+};

+ 40 - 0
src/components/comment/components/drag-area.tsx

@@ -0,0 +1,40 @@
+import { CSSProperties, type FC } from 'react';
+
+import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
+
+import { type CommentEditorModel } from '../model';
+
+interface IDragArea {
+  model: CommentEditorModel;
+  stopEvent?: boolean;
+  style?: CSSProperties;
+}
+
+export const DragArea: FC<IDragArea> = (props) => {
+  const { model, stopEvent = true, style } = props;
+
+  const playground = usePlayground();
+
+  const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();
+
+  return (
+    <div
+      className="workflow-comment-drag-area"
+      data-flow-editor-selectable="false"
+      draggable={true}
+      style={style}
+      onMouseDown={(e) => {
+        if (stopEvent) {
+          e.preventDefault();
+          e.stopPropagation();
+        }
+        model.setFocus(false);
+        onStartDrag(e);
+        selectNode(e);
+        playground.node.focus(); // 防止节点无法被删除
+      }}
+      onFocus={onFocus}
+      onBlur={onBlur}
+    />
+  );
+};

+ 60 - 0
src/components/comment/components/editor.tsx

@@ -0,0 +1,60 @@
+import { type FC, type CSSProperties, useEffect, useRef, useState, useMemo } from 'react';
+
+import { usePlayground } from '@flowgram.ai/free-layout-editor';
+
+import { CommentEditorModel } from '../model';
+import { CommentEditorEvent } from '../constant';
+
+interface ICommentEditor {
+  model: CommentEditorModel;
+  style?: CSSProperties;
+  value?: string;
+  onChange?: (value: string) => void;
+}
+
+export const CommentEditor: FC<ICommentEditor> = (props) => {
+  const { model, style, onChange } = props;
+  const playground = usePlayground();
+  const editorRef = useRef<HTMLTextAreaElement | null>(null);
+  const placeholder = model.value || model.focused ? undefined : 'Enter a comment...';
+
+  // 同步编辑器内部值变化
+  useEffect(() => {
+    const disposer = model.on((params) => {
+      if (params.type !== CommentEditorEvent.Change) {
+        return;
+      }
+      onChange?.(model.value);
+    });
+    return () => disposer.dispose();
+  }, [model, onChange]);
+
+  useEffect(() => {
+    if (!editorRef.current) {
+      return;
+    }
+    model.element = editorRef.current;
+  }, [editorRef]);
+
+  return (
+    <div className="workflow-comment-editor">
+      <p className="workflow-comment-editor-placeholder">{placeholder}</p>
+      <textarea
+        className="workflow-comment-editor-textarea"
+        ref={editorRef}
+        style={style}
+        readOnly={playground.config.readonly}
+        onChange={(e) => {
+          const { value } = e.target;
+          model.setValue(value);
+        }}
+        onFocus={() => {
+          model.setFocus(true);
+        }}
+        onBlur={() => {
+          model.setFocus(false);
+        }}
+      />
+    </div>
+  );
+};

+ 103 - 0
src/components/comment/components/index.css

@@ -0,0 +1,103 @@
+.workflow-comment {
+    width: auto;
+    height: auto;
+    min-width: 120px;
+    min-height: 80px;
+}
+
+.workflow-comment-container {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    justify-content: flex-start;
+    width: 100%;
+    height: 100%;
+    border-radius: 8px;
+    outline: 1px solid;
+    padding: 6px 2px 6px 10px;
+    overflow: hidden;
+}
+
+.workflow-comment-drag-area {
+    position: absolute;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: move;
+}
+
+.workflow-comment-content-drag-area {
+    position: absolute;
+    height: 100%;
+    width: calc(100% - 22px);
+}
+
+.workflow-comment-resize-area {
+    position: absolute;
+    width: 10px;
+    height: 10px;
+}
+
+.workflow-comment-editor {
+    width: 100%;
+    height: 100%;
+}
+
+.workflow-comment-editor-placeholder {
+    margin: 0;
+    position: absolute;
+    pointer-events: none;
+    color: rgba(55, 67, 106, 0.38);
+    font-weight: 500;
+}
+
+.workflow-comment-editor-textarea {
+    width: 100%;
+    height: 100%;
+    box-sizing: border-box;
+    appearance: none;
+    border: none;
+    margin: 0;
+    padding: 0;
+    width: 100%;
+    background: none;
+    color: inherit;
+    font-family: inherit;
+    font-size: 16px;
+    resize: none;
+    outline: none;
+}
+
+.workflow-comment-more-button {
+    position: absolute;
+    right: 6px;
+}
+
+.workflow-comment-more-button > .semi-button {
+    color: rgba(255, 255, 255, 0);
+    background: none;
+}
+
+.workflow-comment-more-button > .semi-button:hover {
+    color: #ffa100;
+    background: #fbf2d2cc;
+    backdrop-filter: blur(1px);
+}
+
+.workflow-comment-more-button-focused > .semi-button:hover {
+    color: #ff811a;
+    background: #ffe3cecc;
+    backdrop-filter: blur(1px);
+}
+
+.workflow-comment-more-button > .semi-button:active {
+    color: #f2b600;
+    background: #ede5c7cc;
+    backdrop-filter: blur(1px);
+}
+
+.workflow-comment-more-button-focused > .semi-button:active {
+    color: #ff811a;
+    background: #eed5c1cc;
+    backdrop-filter: blur(1px);
+}

+ 3 - 0
src/components/comment/components/index.ts

@@ -0,0 +1,3 @@
+import './index.css';
+
+export { CommentRender } from './render';

+ 21 - 0
src/components/comment/components/more-button.tsx

@@ -0,0 +1,21 @@
+import { FC } from 'react';
+
+import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
+
+import { NodeMenu } from '../../node-menu';
+
+interface IMoreButton {
+  node: WorkflowNodeEntity;
+  focused: boolean;
+  deleteNode: () => void;
+}
+
+export const MoreButton: FC<IMoreButton> = ({ node, focused, deleteNode }) => (
+  <div
+    className={`workflow-comment-more-button ${
+      focused ? 'workflow-comment-more-button-focused' : ''
+    }`}
+  >
+    <NodeMenu node={node} deleteNode={deleteNode} />
+  </div>
+);

+ 78 - 0
src/components/comment/components/render.tsx

@@ -0,0 +1,78 @@
+import { FC } from 'react';
+
+import {
+  Field,
+  FieldRenderProps,
+  FlowNodeFormData,
+  Form,
+  FormModelV2,
+  useNodeRender,
+  WorkflowNodeEntity,
+} from '@flowgram.ai/free-layout-editor';
+
+import { useOverflow } from '../hooks/use-overflow';
+import { useModel } from '../hooks/use-model';
+import { useSize } from '../hooks';
+import { CommentEditorFormField } from '../constant';
+import { MoreButton } from './more-button';
+import { CommentEditor } from './editor';
+import { ContentDragArea } from './content-drag-area';
+import { CommentContainer } from './container';
+import { BorderArea } from './border-area';
+
+export const CommentRender: FC<{
+  node: WorkflowNodeEntity;
+}> = (props) => {
+  const { node } = props;
+  const model = useModel();
+
+  const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();
+
+  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
+  const formControl = formModel?.formControl;
+
+  const { width, height, onResize } = useSize();
+  const { overflow, updateOverflow } = useOverflow({ model, height });
+
+  return (
+    <div
+      className="workflow-comment"
+      style={{
+        width,
+        height,
+      }}
+      ref={nodeRef}
+      data-node-selected={String(focused)}
+      onMouseEnter={updateOverflow}
+      onMouseDown={(e) => {
+        setTimeout(() => {
+          // 防止 selectNode 拦截事件,导致 slate 编辑器无法聚焦
+          selectNode(e);
+          // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay
+        }, 20);
+      }}
+    >
+      <Form control={formControl}>
+        <>
+          {/* 背景 */}
+          <CommentContainer focused={focused} style={{ height }}>
+            <Field name={CommentEditorFormField.Note}>
+              {({ field }: FieldRenderProps<string>) => (
+                <>
+                  {/** 编辑器 */}
+                  <CommentEditor model={model} value={field.value} onChange={field.onChange} />
+                  {/* 内容拖拽区域(点击后隐藏) */}
+                  <ContentDragArea model={model} focused={focused} overflow={overflow} />
+                  {/* 更多按钮 */}
+                  <MoreButton node={node} focused={focused} deleteNode={deleteNode} />
+                </>
+              )}
+            </Field>
+          </CommentContainer>
+          {/* 边框 */}
+          <BorderArea model={model} overflow={overflow} onResize={onResize} />
+        </>
+      </Form>
+    </div>
+  );
+};

+ 73 - 0
src/components/comment/components/resize-area.tsx

@@ -0,0 +1,73 @@
+import { CSSProperties, type FC } from 'react';
+
+import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
+
+import type { CommentEditorModel } from '../model';
+
+interface IResizeArea {
+  model: CommentEditorModel;
+  onResize?: () => {
+    resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
+    resizeEnd: () => void;
+  };
+  getDelta?: (delta: { x: number; y: number }) => {
+    top: number;
+    right: number;
+    bottom: number;
+    left: number;
+  };
+  style?: CSSProperties;
+}
+
+export const ResizeArea: FC<IResizeArea> = (props) => {
+  const { model, onResize, getDelta, style } = props;
+
+  const playground = usePlayground();
+
+  const { selectNode } = useNodeRender();
+
+  const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
+    mouseDownEvent.preventDefault();
+    mouseDownEvent.stopPropagation();
+    if (!onResize) {
+      return;
+    }
+    const { resizing, resizeEnd } = onResize();
+    model.setFocus(false);
+    selectNode(mouseDownEvent);
+    playground.node.focus(); // 防止节点无法被删除
+
+    const startX = mouseDownEvent.clientX;
+    const startY = mouseDownEvent.clientY;
+
+    const handleMouseMove = (mouseMoveEvent: MouseEvent) => {
+      const deltaX = mouseMoveEvent.clientX - startX;
+      const deltaY = mouseMoveEvent.clientY - startY;
+      const delta = getDelta?.({ x: deltaX, y: deltaY });
+      if (!delta || !resizing) {
+        return;
+      }
+      resizing(delta);
+    };
+
+    const handleMouseUp = () => {
+      resizeEnd();
+      document.removeEventListener('mousemove', handleMouseMove);
+      document.removeEventListener('mouseup', handleMouseUp);
+      document.removeEventListener('click', handleMouseUp);
+    };
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+    document.addEventListener('click', handleMouseUp);
+  };
+
+  return (
+    <div
+      className="workflow-comment-resize-area"
+      style={style}
+      data-flow-editor-selectable="false"
+      onMouseDown={handleMouseDown}
+    />
+  );
+};

+ 20 - 0
src/components/comment/constant.ts

@@ -0,0 +1,20 @@
+/* eslint-disable @typescript-eslint/naming-convention -- enum */
+
+export enum CommentEditorFormField {
+  Size = 'size',
+  Note = 'note',
+}
+
+/** 编辑器事件 */
+export enum CommentEditorEvent {
+  /** 内容变更事件 */
+  Change = 'change',
+  /** 多选事件 */
+  MultiSelect = 'multiSelect',
+  /** 单选事件 */
+  Select = 'select',
+  /** 失焦事件 */
+  Blur = 'blur',
+}
+
+export const CommentEditorDefaultValue = '';

+ 1 - 0
src/components/comment/hooks/index.ts

@@ -0,0 +1 @@
+export { useSize } from './use-size';

+ 50 - 0
src/components/comment/hooks/use-model.ts

@@ -0,0 +1,50 @@
+import { useEffect, useMemo } from 'react';
+
+import {
+  FlowNodeFormData,
+  FormModelV2,
+  useEntityFromContext,
+  useNodeRender,
+  WorkflowNodeEntity,
+} from '@flowgram.ai/free-layout-editor';
+
+import { CommentEditorModel } from '../model';
+import { CommentEditorFormField } from '../constant';
+
+export const useModel = () => {
+  const node = useEntityFromContext<WorkflowNodeEntity>();
+  const { selected: focused } = useNodeRender();
+
+  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
+
+  const model = useMemo(() => new CommentEditorModel(), []);
+
+  // 同步失焦状态
+  useEffect(() => {
+    if (focused) {
+      return;
+    }
+    model.setFocus(focused);
+  }, [focused, model]);
+
+  // 同步表单值初始化
+  useEffect(() => {
+    const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
+    model.setValue(value); // 设置初始值
+    model.selectEnd(); // 设置初始化光标位置
+  }, [formModel, model]);
+
+  // 同步表单外部值变化:undo/redo/协同
+  useEffect(() => {
+    const disposer = formModel.onFormValuesChange(({ name }) => {
+      if (name !== CommentEditorFormField.Note) {
+        return;
+      }
+      const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
+      model.setValue(value);
+    });
+    return () => disposer.dispose();
+  }, [formModel, model]);
+
+  return model;
+};

+ 45 - 0
src/components/comment/hooks/use-overflow.ts

@@ -0,0 +1,45 @@
+import { useCallback, useState, useEffect } from 'react';
+
+import { usePlayground } from '@flowgram.ai/free-layout-editor';
+
+import { CommentEditorModel } from '../model';
+import { CommentEditorEvent } from '../constant';
+
+export const useOverflow = (params: { model: CommentEditorModel; height: number }) => {
+  const { model, height } = params;
+  const playground = usePlayground();
+
+  const [overflow, setOverflow] = useState(false);
+
+  const isOverflow = useCallback((): boolean => {
+    if (!model.element) {
+      return false;
+    }
+    return model.element.scrollHeight > model.element.clientHeight;
+  }, [model, height, playground]);
+
+  // 更新 overflow
+  const updateOverflow = useCallback(() => {
+    setOverflow(isOverflow());
+  }, [isOverflow]);
+
+  // 监听高度变化
+  useEffect(() => {
+    updateOverflow();
+  }, [height, updateOverflow]);
+
+  // 监听 change 事件
+  useEffect(() => {
+    const changeDisposer = model.on((params) => {
+      if (params.type !== CommentEditorEvent.Change) {
+        return;
+      }
+      updateOverflow();
+    });
+    return () => {
+      changeDisposer.dispose();
+    };
+  }, [model, updateOverflow]);
+
+  return { overflow, updateOverflow };
+};

+ 163 - 0
src/components/comment/hooks/use-size.ts

@@ -0,0 +1,163 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import {
+  FlowNodeFormData,
+  FormModelV2,
+  FreeOperationType,
+  HistoryService,
+  TransformData,
+  useCurrentEntity,
+  usePlayground,
+  useService,
+} from '@flowgram.ai/free-layout-editor';
+
+import { CommentEditorFormField } from '../constant';
+
+export const useSize = () => {
+  const node = useCurrentEntity();
+  const nodeMeta = node.getNodeMeta();
+  const playground = usePlayground();
+  const historyService = useService(HistoryService);
+  const { size = { width: 240, height: 150 } } = nodeMeta;
+  const transform = node.getData(TransformData);
+  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
+  const formSize = formModel.getValueIn<{ width: number; height: number }>(
+    CommentEditorFormField.Size
+  );
+
+  const [width, setWidth] = useState(formSize?.width ?? size.width);
+  const [height, setHeight] = useState(formSize?.height ?? size.height);
+
+  // 初始化表单值
+  useEffect(() => {
+    const initSize = formModel.getValueIn<{ width: number; height: number }>(
+      CommentEditorFormField.Size
+    );
+    if (!initSize) {
+      formModel.setValueIn(CommentEditorFormField.Size, {
+        width,
+        height,
+      });
+    }
+  }, [formModel, width, height]);
+
+  // 同步表单外部值变化:初始化/undo/redo/协同
+  useEffect(() => {
+    const disposer = formModel.onFormValuesChange(({ name }) => {
+      if (name !== CommentEditorFormField.Size) {
+        return;
+      }
+      const newSize = formModel.getValueIn<{ width: number; height: number }>(
+        CommentEditorFormField.Size
+      );
+      if (!newSize) {
+        return;
+      }
+      setWidth(newSize.width);
+      setHeight(newSize.height);
+    });
+    return () => disposer.dispose();
+  }, [formModel]);
+
+  const onResize = useCallback(() => {
+    const resizeState = {
+      width,
+      height,
+      originalWidth: width,
+      originalHeight: height,
+      positionX: transform.position.x,
+      positionY: transform.position.y,
+      offsetX: 0,
+      offsetY: 0,
+    };
+    const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {
+      if (!resizeState) {
+        return;
+      }
+
+      const { zoom } = playground.config;
+
+      const top = delta.top / zoom;
+      const right = delta.right / zoom;
+      const bottom = delta.bottom / zoom;
+      const left = delta.left / zoom;
+
+      const minWidth = 120;
+      const minHeight = 80;
+
+      const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);
+      const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);
+
+      // 如果宽度或高度小于最小值,则不更新偏移量
+      const newOffsetX =
+        (left > 0 || right < 0) && newWidth <= minWidth
+          ? resizeState.offsetX
+          : left / 2 + right / 2;
+      const newOffsetY =
+        (top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;
+
+      const newPositionX = resizeState.positionX + newOffsetX;
+      const newPositionY = resizeState.positionY + newOffsetY;
+
+      resizeState.width = newWidth;
+      resizeState.height = newHeight;
+      resizeState.offsetX = newOffsetX;
+      resizeState.offsetY = newOffsetY;
+
+      // 更新状态
+      setWidth(newWidth);
+      setHeight(newHeight);
+
+      // 更新偏移量
+      transform.update({
+        position: {
+          x: newPositionX,
+          y: newPositionY,
+        },
+      });
+    };
+
+    const resizeEnd = () => {
+      historyService.transact(() => {
+        historyService.pushOperation(
+          {
+            type: FreeOperationType.dragNodes,
+            value: {
+              ids: [node.id],
+              value: [
+                {
+                  x: resizeState.positionX + resizeState.offsetX,
+                  y: resizeState.positionY + resizeState.offsetY,
+                },
+              ],
+              oldValue: [
+                {
+                  x: resizeState.positionX,
+                  y: resizeState.positionY,
+                },
+              ],
+            },
+          },
+          {
+            noApply: true,
+          }
+        );
+        formModel.setValueIn(CommentEditorFormField.Size, {
+          width: resizeState.width,
+          height: resizeState.height,
+        });
+      });
+    };
+
+    return {
+      resizing,
+      resizeEnd,
+    };
+  }, [node, width, height, transform, playground, formModel, historyService]);
+
+  return {
+    width,
+    height,
+    onResize,
+  };
+};

+ 1 - 0
src/components/comment/index.ts

@@ -0,0 +1 @@
+export { CommentRender } from './components';

+ 106 - 0
src/components/comment/model.ts

@@ -0,0 +1,106 @@
+import { Emitter } from '@flowgram.ai/free-layout-editor';
+
+import { CommentEditorEventParams } from './type';
+import { CommentEditorDefaultValue, CommentEditorEvent } from './constant';
+
+export class CommentEditorModel {
+  private innerValue: string = CommentEditorDefaultValue;
+
+  private emitter: Emitter<CommentEditorEventParams> = new Emitter();
+
+  private editor: HTMLTextAreaElement;
+
+  /** 注册事件 */
+  public on = this.emitter.event;
+
+  /** 获取当前值 */
+  public get value(): string {
+    return this.innerValue;
+  }
+
+  /** 外部设置模型值 */
+  public setValue(value: string = CommentEditorDefaultValue): void {
+    if (!this.initialized) {
+      return;
+    }
+    if (value === this.innerValue) {
+      return;
+    }
+    this.innerValue = value;
+    this.syncEditorValue();
+    this.emitter.fire({
+      type: CommentEditorEvent.Change,
+      value: this.innerValue,
+    });
+  }
+
+  public set element(el: HTMLTextAreaElement) {
+    if (this.initialized) {
+      return;
+    }
+    this.editor = el;
+  }
+
+  /** 获取编辑器 DOM 节点 */
+  public get element(): HTMLTextAreaElement | null {
+    return this.editor;
+  }
+
+  /** 编辑器聚焦/失焦 */
+  public setFocus(focused: boolean): void {
+    if (!this.initialized) {
+      return;
+    }
+    if (focused && !this.focused) {
+      this.editor.focus();
+    } else if (!focused && this.focused) {
+      this.editor.blur();
+      this.deselect();
+      this.emitter.fire({
+        type: CommentEditorEvent.Blur,
+      });
+    }
+  }
+
+  /** 选择末尾 */
+  public selectEnd(): void {
+    if (!this.initialized) {
+      return;
+    }
+    // 获取文本长度
+    const length = this.editor.value.length;
+    // 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)
+    this.editor.setSelectionRange(length, length);
+  }
+
+  /** 获取聚焦状态 */
+  public get focused(): boolean {
+    return document.activeElement === this.editor;
+  }
+
+  /** 取消选择文本 */
+  private deselect(): void {
+    const selection: Selection | null = window.getSelection();
+
+    // 清除所有选择区域
+    if (selection) {
+      selection.removeAllRanges();
+    }
+  }
+
+  /** 是否初始化 */
+  private get initialized(): boolean {
+    return Boolean(this.editor);
+  }
+
+  /**
+   * 同步编辑器实例内容
+   * > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
+   */
+  private syncEditorValue(): void {
+    if (!this.initialized) {
+      return;
+    }
+    this.editor.value = this.innerValue;
+  }
+}

+ 24 - 0
src/components/comment/type.ts

@@ -0,0 +1,24 @@
+import type { CommentEditorEvent } from './constant';
+
+interface CommentEditorChangeEvent {
+  type: CommentEditorEvent.Change;
+  value: string;
+}
+
+interface CommentEditorMultiSelectEvent {
+  type: CommentEditorEvent.MultiSelect;
+}
+
+interface CommentEditorSelectEvent {
+  type: CommentEditorEvent.Select;
+}
+
+interface CommentEditorBlurEvent {
+  type: CommentEditorEvent.Blur;
+}
+
+export type CommentEditorEventParams =
+  | CommentEditorChangeEvent
+  | CommentEditorMultiSelectEvent
+  | CommentEditorSelectEvent
+  | CommentEditorBlurEvent;

+ 100 - 0
src/components/group/color.ts

@@ -0,0 +1,100 @@
+type GroupColor = {
+  '50': string;
+  '300': string;
+  '400': string;
+};
+
+export const defaultColor = 'Blue';
+
+export const groupColors: Record<string, GroupColor> = {
+  Red: {
+    '50': '#fef2f2',
+    '300': '#fca5a5',
+    '400': '#f87171',
+  },
+  Orange: {
+    '50': '#fff7ed',
+    '300': '#fdba74',
+    '400': '#fb923c',
+  },
+  Amber: {
+    '50': '#fffbeb',
+    '300': '#fcd34d',
+    '400': '#fbbf24',
+  },
+  Yellow: {
+    '50': '#fef9c3',
+    '300': '#fde047',
+    '400': '#facc15',
+  },
+  Lime: {
+    '50': '#f7fee7',
+    '300': '#bef264',
+    '400': '#a3e635',
+  },
+  Green: {
+    '50': '#f0fdf4',
+    '300': '#86efac',
+    '400': '#4ade80',
+  },
+  Emerald: {
+    '50': '#ecfdf5',
+    '300': '#6ee7b7',
+    '400': '#34d399',
+  },
+  Teal: {
+    '50': '#f0fdfa',
+    '300': '#5eead4',
+    '400': '#2dd4bf',
+  },
+  Cyan: {
+    '50': '#ecfeff',
+    '300': '#67e8f9',
+    '400': '#22d3ee',
+  },
+  Sky: {
+    '50': '#ecfeff',
+    '300': '#7dd3fc',
+    '400': '#38bdf8',
+  },
+  Blue: {
+    '50': '#eff6ff',
+    '300': '#93c5fd',
+    '400': '#60a5fa',
+  },
+  Indigo: {
+    '50': '#eef2ff',
+    '300': '#a5b4fc',
+    '400': '#818cf8',
+  },
+  Violet: {
+    '50': '#f5f3ff',
+    '300': '#c4b5fd',
+    '400': '#a78bfa',
+  },
+  Purple: {
+    '50': '#faf5ff',
+    '300': '#d8b4fe',
+    '400': '#c084fc',
+  },
+  Fuchsia: {
+    '50': '#fdf4ff',
+    '300': '#f0abfc',
+    '400': '#e879f9',
+  },
+  Pink: {
+    '50': '#fdf2f8',
+    '300': '#f9a8d4',
+    '400': '#f472b6',
+  },
+  Rose: {
+    '50': '#fff1f2',
+    '300': '#fda4af',
+    '400': '#fb7185',
+  },
+  Gray: {
+    '50': '#f9fafb',
+    '300': '#d1d5db',
+    '400': '#9ca3af',
+  },
+};

+ 49 - 0
src/components/group/components/background.tsx

@@ -0,0 +1,49 @@
+import { CSSProperties, FC, useEffect } from 'react';
+
+import { useWatch, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
+
+import { GroupField } from '../constant';
+import { defaultColor, groupColors } from '../color';
+
+interface GroupBackgroundProps {
+  node: WorkflowNodeEntity;
+  style?: CSSProperties;
+}
+
+export const GroupBackground: FC<GroupBackgroundProps> = ({ node, style }) => {
+  const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
+  const color = groupColors[colorName];
+
+  useEffect(() => {
+    const styleElement = document.createElement('style');
+
+    // 使用独特的选择器
+    const styleContent = `
+      .workflow-group-render[data-group-id="${node.id}"] .workflow-group-background {
+        border: 1px solid ${color['300']};
+      }
+
+      .workflow-group-render.selected[data-group-id="${node.id}"] .workflow-group-background {
+        border: 1px solid ${color['400']};
+      }
+    `;
+
+    styleElement.textContent = styleContent;
+    document.head.appendChild(styleElement);
+
+    return () => {
+      styleElement.remove();
+    };
+  }, [color]);
+
+  return (
+    <div
+      className="workflow-group-background"
+      data-flow-editor-selectable="true"
+      style={{
+        ...style,
+        backgroundColor: `${color['300']}29`,
+      }}
+    />
+  );
+};

+ 45 - 0
src/components/group/components/color.tsx

@@ -0,0 +1,45 @@
+import { FC } from 'react';
+
+import { Field } from '@flowgram.ai/free-layout-editor';
+import { Popover, Tooltip } from '@douyinfe/semi-ui';
+
+import { GroupField } from '../constant';
+import { defaultColor, groupColors } from '../color';
+
+export const GroupColor: FC = () => (
+  <Field<string> name={GroupField.Color}>
+    {({ field }) => {
+      const colorName = field.value ?? defaultColor;
+      return (
+        <Popover
+          position="top"
+          mouseLeaveDelay={300}
+          content={
+            <div className="workflow-group-color-palette">
+              {Object.entries(groupColors).map(([name, color]) => (
+                <Tooltip content={name} key={name} mouseEnterDelay={300}>
+                  <span
+                    className="workflow-group-color-item"
+                    key={name}
+                    style={{
+                      backgroundColor: color['300'],
+                      borderColor: name === colorName ? color['400'] : '#fff',
+                    }}
+                    onClick={() => field.onChange(name)}
+                  />
+                </Tooltip>
+              ))}
+            </div>
+          }
+        >
+          <span
+            className="workflow-group-color"
+            style={{
+              backgroundColor: groupColors[colorName]['300'],
+            }}
+          />
+        </Popover>
+      );
+    }}
+  </Field>
+);

+ 41 - 0
src/components/group/components/header.tsx

@@ -0,0 +1,41 @@
+import type { FC, ReactNode, MouseEvent, CSSProperties } from 'react';
+
+import { useWatch } from '@flowgram.ai/free-layout-editor';
+
+import { GroupField } from '../constant';
+import { defaultColor, groupColors } from '../color';
+
+interface GroupHeaderProps {
+  onMouseDown: (e: MouseEvent) => void;
+  onFocus: () => void;
+  onBlur: () => void;
+  children: ReactNode;
+  style?: CSSProperties;
+}
+
+export const GroupHeader: FC<GroupHeaderProps> = ({
+  onMouseDown,
+  onFocus,
+  onBlur,
+  children,
+  style,
+}) => {
+  const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
+  const color = groupColors[colorName];
+  return (
+    <div
+      className="workflow-group-header"
+      data-flow-editor-selectable="false"
+      onMouseDown={onMouseDown}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      style={{
+        ...style,
+        backgroundColor: color['50'],
+        borderColor: color['300'],
+      }}
+    >
+      {children}
+    </div>
+  );
+};

File diff suppressed because it is too large
+ 47 - 0
src/components/group/components/icon-group.tsx


+ 2 - 0
src/components/group/components/index.ts

@@ -0,0 +1,2 @@
+export { GroupNodeRender } from './node-render';
+export { IconGroup } from './icon-group';

+ 75 - 0
src/components/group/components/node-render.tsx

@@ -0,0 +1,75 @@
+import { useEffect } from 'react';
+
+import {
+  FlowNodeFormData,
+  Form,
+  FormModelV2,
+  useNodeRender,
+} from '@flowgram.ai/free-layout-editor';
+import { useNodeSize } from '@flowgram.ai/free-container-plugin';
+
+import { HEADER_HEIGHT, HEADER_PADDING } from '../constant';
+import { UngroupButton } from './ungroup';
+import { GroupTools } from './tools';
+import { GroupTips } from './tips';
+import { GroupHeader } from './header';
+import { GroupBackground } from './background';
+
+export const GroupNodeRender = () => {
+  const { node, selected, selectNode, nodeRef, startDrag, onFocus, onBlur } = useNodeRender();
+  const nodeSize = useNodeSize();
+  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
+  const formControl = formModel?.formControl;
+
+  const { height, width } = nodeSize ?? {};
+  const nodeHeight = height ?? 0;
+
+  useEffect(() => {
+    // prevent lines in outside cannot be selected - 防止外层线条不可选中
+    const element = node.renderData.node;
+    element.style.pointerEvents = 'none';
+  }, [node]);
+
+  return (
+    <div
+      className={`workflow-group-render ${selected ? 'selected' : ''}`}
+      ref={nodeRef}
+      data-group-id={node.id}
+      data-node-selected={String(selected)}
+      onMouseDown={selectNode}
+      onClick={(e) => {
+        selectNode(e);
+      }}
+      style={{
+        width,
+        height,
+      }}
+    >
+      <Form control={formControl}>
+        <>
+          <GroupHeader
+            onMouseDown={(e) => {
+              startDrag(e);
+            }}
+            onFocus={onFocus}
+            onBlur={onBlur}
+            style={{
+              height: HEADER_HEIGHT,
+            }}
+          >
+            <GroupTools />
+          </GroupHeader>
+          <GroupTips />
+          <UngroupButton node={node} />
+          <GroupBackground
+            node={node}
+            style={{
+              top: HEADER_HEIGHT + HEADER_PADDING,
+              height: nodeHeight - HEADER_HEIGHT - HEADER_PADDING,
+            }}
+          />
+        </>
+      </Form>
+    </div>
+  );
+};

+ 33 - 0
src/components/group/components/tips/global-store.ts

@@ -0,0 +1,33 @@
+/* eslint-disable @typescript-eslint/naming-convention -- no need */
+
+const STORAGE_KEY = 'workflow-move-into-group-tip-visible';
+const STORAGE_VALUE = 'false';
+
+export class TipsGlobalStore {
+  private static _instance?: TipsGlobalStore;
+
+  public static get instance(): TipsGlobalStore {
+    if (!this._instance) {
+      this._instance = new TipsGlobalStore();
+    }
+    return this._instance;
+  }
+
+  private closed = false;
+
+  public isClosed(): boolean {
+    return this.isCloseForever() || this.closed;
+  }
+
+  public close(): void {
+    this.closed = true;
+  }
+
+  public isCloseForever(): boolean {
+    return localStorage.getItem(STORAGE_KEY) === STORAGE_VALUE;
+  }
+
+  public closeForever(): void {
+    localStorage.setItem(STORAGE_KEY, STORAGE_VALUE);
+  }
+}

+ 9 - 0
src/components/group/components/tips/icon-close.tsx

@@ -0,0 +1,9 @@
+export const IconClose = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
+    <path
+      fill="#060709"
+      fillOpacity="0.5"
+      d="M12.13 12.128a.5.5 0 0 0 .001-.706L8.71 8l3.422-3.423a.5.5 0 0 0-.001-.705.5.5 0 0 0-.706-.002L8.002 7.293 4.579 3.87a.5.5 0 0 0-.705.002.5.5 0 0 0-.002.705L7.295 8l-3.423 3.422a.5.5 0 0 0 .002.706c.195.195.51.197.705.001l3.423-3.422 3.422 3.422c.196.196.51.194.706-.001"
+    ></path>
+  </svg>
+);

+ 36 - 0
src/components/group/components/tips/index.tsx

@@ -0,0 +1,36 @@
+import { useControlTips } from './use-control';
+import { GroupTipsStyle } from './style';
+import { isMacOS } from './is-mac-os';
+import { IconClose } from './icon-close';
+
+export const GroupTips = () => {
+  const { visible, close, closeForever } = useControlTips();
+
+  if (!visible) {
+    return null;
+  }
+
+  return (
+    <GroupTipsStyle className={'workflow-group-tips'}>
+      <div className="container">
+        <div className="content">
+          <p className="text">{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}</p>
+          <div
+            className="space"
+            style={{
+              width: 0,
+            }}
+          />
+        </div>
+        <div className="actions">
+          <p className="close-forever" onClick={closeForever}>
+            Never Remind
+          </p>
+          <div className="close" onClick={close}>
+            <IconClose />
+          </div>
+        </div>
+      </div>
+    </GroupTipsStyle>
+  );
+};

+ 1 - 0
src/components/group/components/tips/is-mac-os.ts

@@ -0,0 +1 @@
+export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);

+ 74 - 0
src/components/group/components/tips/style.ts

@@ -0,0 +1,74 @@
+import styled from 'styled-components';
+
+export const GroupTipsStyle = styled.div`
+  position: absolute;
+  top: 35px;
+
+  width: 100%;
+  height: 28px;
+  white-space: nowrap;
+  pointer-events: auto;
+
+  .container {
+    display: inline-flex;
+    justify-content: center;
+    height: 100%;
+    width: 100%;
+    background-color: rgb(255 255 255);
+    border-radius: 8px 8px 0 0;
+
+    .content {
+      overflow: hidden;
+      display: inline-flex;
+      align-items: center;
+      justify-content: flex-start;
+
+      width: fit-content;
+      height: 100%;
+      padding: 0 12px;
+
+      .text {
+        font-size: 14px;
+        font-weight: 400;
+        font-style: normal;
+        line-height: 20px;
+        color: rgba(15, 21, 40, 82%);
+        text-overflow: ellipsis;
+        margin: 0;
+      }
+
+      .space {
+        width: 128px;
+      }
+    }
+
+    .actions {
+      display: flex;
+      gap: 8px;
+      align-items: center;
+
+      height: 28px;
+      padding: 0 12px;
+
+      .close-forever {
+        cursor: pointer;
+
+        padding: 0 3px;
+
+        font-size: 12px;
+        font-weight: 400;
+        font-style: normal;
+        line-height: 12px;
+        color: rgba(32, 41, 69, 62%);
+        margin: 0;
+      }
+
+      .close {
+        display: flex;
+        cursor: pointer;
+        height: 100%;
+        align-items: center;
+      }
+    }
+  }
+`;

+ 66 - 0
src/components/group/components/tips/use-control.ts

@@ -0,0 +1,66 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
+import {
+  NodeIntoContainerService,
+  NodeIntoContainerType,
+} from '@flowgram.ai/free-container-plugin';
+
+import { TipsGlobalStore } from './global-store';
+
+export const useControlTips = () => {
+  const node = useCurrentEntity();
+  const [visible, setVisible] = useState(false);
+  const globalStore = TipsGlobalStore.instance;
+
+  const nodeIntoContainerService = useService<NodeIntoContainerService>(NodeIntoContainerService);
+
+  const show = useCallback(() => {
+    if (globalStore.isClosed()) {
+      return;
+    }
+
+    setVisible(true);
+  }, [globalStore]);
+
+  const close = useCallback(() => {
+    globalStore.close();
+    setVisible(false);
+  }, [globalStore]);
+
+  const closeForever = useCallback(() => {
+    globalStore.closeForever();
+    close();
+  }, [close, globalStore]);
+
+  useEffect(() => {
+    // 监听移入
+    const inDisposer = nodeIntoContainerService.on((e) => {
+      if (e.type !== NodeIntoContainerType.In) {
+        return;
+      }
+      if (e.targetContainer === node) {
+        show();
+      }
+    });
+    // 监听移出事件
+    const outDisposer = nodeIntoContainerService.on((e) => {
+      if (e.type !== NodeIntoContainerType.Out) {
+        return;
+      }
+      if (e.sourceContainer === node && !node.blocks.length) {
+        setVisible(false);
+      }
+    });
+    return () => {
+      inDisposer.dispose();
+      outDisposer.dispose();
+    };
+  }, [nodeIntoContainerService, node, show, close, visible]);
+
+  return {
+    visible,
+    close,
+    closeForever,
+  };
+};

+ 33 - 0
src/components/group/components/title.tsx

@@ -0,0 +1,33 @@
+import { FC, useState } from 'react';
+
+import { Field } from '@flowgram.ai/free-layout-editor';
+import { Input } from '@douyinfe/semi-ui';
+
+import { GroupField } from '../constant';
+
+export const GroupTitle: FC = () => {
+  const [inputting, setInputting] = useState(false);
+  return (
+    <Field<string> name={GroupField.Title}>
+      {({ field }) =>
+        inputting ? (
+          <Input
+            autoFocus
+            className="workflow-group-title-input"
+            size="small"
+            value={field.value}
+            onChange={field.onChange}
+            onMouseDown={(e) => e.stopPropagation()}
+            onBlur={() => setInputting(false)}
+            draggable={false}
+            onEnterPress={() => setInputting(false)}
+          />
+        ) : (
+          <p className="workflow-group-title" onDoubleClick={() => setInputting(true)}>
+            {field.value ?? 'Group'}
+          </p>
+        )
+      }
+    </Field>
+  );
+};

+ 14 - 0
src/components/group/components/tools.tsx

@@ -0,0 +1,14 @@
+import { FC } from 'react';
+
+import { IconHandle } from '@douyinfe/semi-icons';
+
+import { GroupTitle } from './title';
+import { GroupColor } from './color';
+
+export const GroupTools: FC = () => (
+  <div className="workflow-group-tools">
+    <IconHandle className="workflow-group-tools-drag" />
+    <GroupTitle />
+    <GroupColor />
+  </div>
+);

+ 31 - 0
src/components/group/components/ungroup.tsx

@@ -0,0 +1,31 @@
+import { CSSProperties, FC } from 'react';
+
+import { CommandRegistry, useService, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
+import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
+import { Button, Tooltip } from '@douyinfe/semi-ui';
+
+import { IconUngroup } from './icon-group';
+
+interface UngroupButtonProps {
+  node: WorkflowNodeEntity;
+  style?: CSSProperties;
+}
+
+export const UngroupButton: FC<UngroupButtonProps> = ({ node, style }) => {
+  const commandRegistry = useService(CommandRegistry);
+  return (
+    <Tooltip content="Ungroup">
+      <div className="workflow-group-ungroup" style={style}>
+        <Button
+          icon={<IconUngroup size={14} />}
+          style={{ height: 30, width: 30 }}
+          theme="borderless"
+          type="tertiary"
+          onClick={() => {
+            commandRegistry.executeCommand(WorkflowGroupCommand.Ungroup, node);
+          }}
+        />
+      </div>
+    </Tooltip>
+  );
+};

+ 7 - 0
src/components/group/constant.ts

@@ -0,0 +1,7 @@
+export const HEADER_HEIGHT = 30;
+export const HEADER_PADDING = 5;
+
+export enum GroupField {
+  Title = 'title',
+  Color = 'color',
+}

+ 109 - 0
src/components/group/index.css

@@ -0,0 +1,109 @@
+.workflow-group-render {
+    border-radius: 8px;
+    pointer-events: none;
+}
+
+.workflow-group-header {
+    height: 30px;
+    width: fit-content;
+    background-color: #fefce8;
+    border: 1px solid #facc15;
+    border-radius: 8px;
+    padding-right: 8px;
+    pointer-events: auto;
+}
+
+.workflow-group-ungroup {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 30px;
+    width: 30px;
+    position: absolute;
+    top: 35px;
+    right: 0;
+    border-radius: 8px;
+    cursor: pointer;
+    pointer-events: auto;
+}
+
+.workflow-group-ungroup .semi-button {
+    color: #9ca3af;
+}
+
+.workflow-group-ungroup:hover .semi-button {
+    color: #374151;
+}
+
+.workflow-group-background {
+    position: absolute;
+    pointer-events: none;
+    top: 0;
+    background-color: #fddf4729;
+    border: 1px solid #fde047;
+    border-radius: 8px;
+    width: 100%;
+}
+
+.workflow-group-render.selected .workflow-group-background {
+    border: 1px solid #facc15;
+}
+
+.workflow-group-tools {
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    gap: 4px;
+    height: 100%;
+    cursor: move;
+    color: oklch(44.6% 0.043 257.281);
+    font-size: 14px;
+}
+.workflow-group-title {
+    margin: 0;
+    max-width: 242px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    font-weight: 500;
+}
+
+.workflow-group-tools-drag {
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding-left: 4px;
+}
+
+.workflow-group-color {
+    width: 16px;
+    height: 16px;
+    border-radius: 8px;
+    background-color: #fde047;
+    margin-left: 4px;
+    cursor: pointer;
+}
+
+.workflow-group-title-input {
+    width: 242px;
+    border: none;
+    color: #374151;
+}
+
+.workflow-group-color-palette {
+    display: grid;
+    grid-template-columns: repeat(6, 24px);
+    gap: 12px;
+    margin: 8px;
+    padding: 8px;
+}
+
+.workflow-group-color-item {
+    width: 24px;
+    height: 24px;
+    border-radius: 50%;
+    background-color: #fde047;
+    cursor: pointer;
+    border: 3px solid;
+}

+ 4 - 0
src/components/group/index.ts

@@ -0,0 +1,4 @@
+import './index.css';
+
+export { GroupNodeRender } from './components';
+export { IconGroup } from './components';

+ 5 - 0
src/components/index.ts

@@ -0,0 +1,5 @@
+export * from './base-node';
+export * from './line-add-button';
+export * from './node-panel';
+export * from './comment';
+export * from './group';

File diff suppressed because it is too large
+ 26 - 0
src/components/line-add-button/button.tsx


+ 8 - 0
src/components/line-add-button/index.less

@@ -0,0 +1,8 @@
+.line-add-button {
+  position: absolute;
+  transform: translate(-50%, -60%);
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
+  color: inherit;
+}

+ 126 - 0
src/components/line-add-button/index.tsx

@@ -0,0 +1,126 @@
+import { useCallback } from 'react';
+
+import {
+  WorkflowNodePanelService,
+  WorkflowNodePanelUtils,
+} from '@flowgram.ai/free-node-panel-plugin';
+import { LineRenderProps } from '@flowgram.ai/free-lines-plugin';
+import {
+  delay,
+  HistoryService,
+  useService,
+  WorkflowDocument,
+  WorkflowDragService,
+  WorkflowLinesManager,
+  WorkflowNodeEntity,
+  WorkflowNodeJSON,
+} from '@flowgram.ai/free-layout-editor';
+
+import './index.less';
+import { useVisible } from './use-visible';
+import { IconPlusCircle } from './button';
+
+export const LineAddButton = (props: LineRenderProps) => {
+  const { line, selected, hovered, color } = props;
+  const visible = useVisible({ line, selected, hovered });
+  const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
+  const document = useService(WorkflowDocument);
+  const dragService = useService(WorkflowDragService);
+  const linesManager = useService(WorkflowLinesManager);
+  const historyService = useService(HistoryService);
+
+  const { fromPort, toPort } = line;
+
+  const onClick = useCallback(async () => {
+    // calculate the middle point of the line - 计算线条的中点位置
+    const position = {
+      x: (line.position.from.x + line.position.to.x) / 2,
+      y: (line.position.from.y + line.position.to.y) / 2,
+    };
+
+    // get container node for the new node - 获取新节点的容器节点
+    const containerNode = WorkflowNodePanelUtils.getContainerNode({
+      fromPort,
+    });
+
+    // show node selection panel - 显示节点选择面板
+    const result = await nodePanelService.singleSelectNodePanel({
+      position,
+      containerNode,
+      panelProps: {
+        enableScrollClose: true,
+      },
+    });
+    if (!result) {
+      return;
+    }
+
+    const { nodeType, nodeJSON } = result;
+
+    // adjust position for the new node - 调整新节点的位置
+    const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
+      nodeType,
+      position,
+      fromPort,
+      toPort,
+      containerNode,
+      document,
+      dragService,
+    });
+
+    // create new workflow node - 创建新的工作流节点
+    const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
+      nodeType,
+      nodePosition,
+      nodeJSON ?? ({} as WorkflowNodeJSON),
+      containerNode?.id
+    );
+
+    // auto offset subsequent nodes - 自动偏移后续节点
+    if (fromPort && toPort) {
+      WorkflowNodePanelUtils.subNodesAutoOffset({
+        node,
+        fromPort,
+        toPort,
+        containerNode,
+        historyService,
+        dragService,
+        linesManager,
+      });
+    }
+
+    // wait for node render - 等待节点渲染
+    await delay(20);
+
+    // build connection lines - 构建连接线
+    WorkflowNodePanelUtils.buildLine({
+      fromPort,
+      node,
+      toPort,
+      linesManager,
+    });
+
+    // remove original line - 移除原始线条
+    line.dispose();
+  }, []);
+
+  if (!visible) {
+    return <></>;
+  }
+
+  return (
+    <div
+      className="line-add-button"
+      style={{
+        left: '50%',
+        top: '50%',
+        color,
+      }}
+      data-testid="sdk.workflow.canvas.line.add"
+      data-line-id={line.id}
+      onClick={onClick}
+    >
+      <IconPlusCircle />
+    </div>
+  );
+};

+ 23 - 0
src/components/line-add-button/use-visible.ts

@@ -0,0 +1,23 @@
+import { usePlayground, WorkflowLineEntity } from '@flowgram.ai/free-layout-editor';
+
+import './index.less';
+
+export const useVisible = (params: {
+  line: WorkflowLineEntity;
+  selected?: boolean;
+  hovered?: boolean;
+}): boolean => {
+  const playground = usePlayground();
+  const { line, selected = false, hovered } = params;
+  if (line.disposed) {
+    // 在 dispose 后,再去获取 line.to | line.from 会导致错误创建端口
+    return false;
+  }
+  if (playground.config.readonly) {
+    return false;
+  }
+  if (!selected && !hovered) {
+    return false;
+  }
+  return true;
+};

+ 118 - 0
src/components/node-menu/index.tsx

@@ -0,0 +1,118 @@
+import { FC, useCallback, useState, type MouseEvent } from 'react';
+
+import {
+  delay,
+  useClientContext,
+  useService,
+  WorkflowDragService,
+  WorkflowNodeEntity,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
+import { IconButton, Dropdown } from '@douyinfe/semi-ui';
+import { IconMore } from '@douyinfe/semi-icons';
+
+import { FlowNodeRegistry } from '../../typings';
+import { PasteShortcut } from '../../shortcuts/paste';
+import { CopyShortcut } from '../../shortcuts/copy';
+
+interface NodeMenuProps {
+  node: WorkflowNodeEntity;
+  updateTitleEdit: (setEditing: boolean) => void;
+  deleteNode: () => void;
+}
+
+export const NodeMenu: FC<NodeMenuProps> = ({ node, deleteNode, updateTitleEdit }) => {
+  const [visible, setVisible] = useState(true);
+  const clientContext = useClientContext();
+  const registry = node.getNodeRegistry<FlowNodeRegistry>();
+  const nodeIntoContainerService = useService(NodeIntoContainerService);
+  const selectService = useService(WorkflowSelectService);
+  const dragService = useService(WorkflowDragService);
+  const canMoveOut = nodeIntoContainerService.canMoveOutContainer(node);
+
+  const rerenderMenu = useCallback(() => {
+    // force destroy component - 强制销毁组件触发重新渲染
+    setVisible(false);
+    requestAnimationFrame(() => {
+      setVisible(true);
+    });
+  }, []);
+
+  const handleMoveOut = useCallback(
+    async (e: MouseEvent) => {
+      e.stopPropagation();
+      const sourceParent = node.parent;
+      // move out of container - 移出容器
+      nodeIntoContainerService.moveOutContainer({ node });
+      // clear invalid lines - 清除非法线条
+      await nodeIntoContainerService.clearInvalidLines({
+        dragNode: node,
+        sourceParent,
+      });
+      rerenderMenu();
+      await delay(16);
+      // select node - 选中节点
+      selectService.selectNode(node);
+      // start drag node - 开始拖拽
+      dragService.startDragSelectedNodes(e);
+    },
+    [nodeIntoContainerService, node, rerenderMenu]
+  );
+
+  const handleCopy = useCallback(
+    (e: React.MouseEvent) => {
+      const copyShortcut = new CopyShortcut(clientContext);
+      const pasteShortcut = new PasteShortcut(clientContext);
+      const data = copyShortcut.toClipboardData([node]);
+      pasteShortcut.apply(data);
+      e.stopPropagation(); // Disable clicking prevents the sidebar from opening
+    },
+    [clientContext, node]
+  );
+
+  const handleDelete = useCallback(
+    (e: React.MouseEvent) => {
+      deleteNode();
+      e.stopPropagation(); // Disable clicking prevents the sidebar from opening
+    },
+    [clientContext, node]
+  );
+  const handleEditTitle = useCallback(() => {
+    updateTitleEdit(true);
+  }, [updateTitleEdit]);
+
+  if (!visible) {
+    return <></>;
+  }
+
+  return (
+    <Dropdown
+      trigger="hover"
+      position="bottomRight"
+      render={
+        <Dropdown.Menu>
+          <Dropdown.Item onClick={handleEditTitle}>Edit Title</Dropdown.Item>
+          {canMoveOut && <Dropdown.Item onClick={handleMoveOut}>Move out</Dropdown.Item>}
+          <Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>
+            Create Copy
+          </Dropdown.Item>
+          <Dropdown.Item
+            onClick={handleDelete}
+            disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}
+          >
+            Delete
+          </Dropdown.Item>
+        </Dropdown.Menu>
+      }
+    >
+      <IconButton
+        color="secondary"
+        size="small"
+        theme="borderless"
+        icon={<IconMore />}
+        onClick={(e) => e.stopPropagation()}
+      />
+    </Dropdown>
+  );
+};

+ 55 - 0
src/components/node-panel/index.less

@@ -0,0 +1,55 @@
+.node-placeholder {
+  width: 360px;
+
+  background-color: rgba(252, 252, 255, 1);
+  border: 1px solid rgba(68, 83, 130, 0.25);
+  border-radius: 8px;
+  box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 2%), 0 2px 6px 0 rgba(0, 0, 0, 4%);
+}
+
+
+.node-placeholder-skeleton {
+  width: 100%;
+  padding: 12px;
+  background-color: rgba(252, 252, 255, 1);
+  border-radius: 8px;
+
+
+  .semi-skeleton-avatar {
+    background-color: rgba(68, 83, 130, 0.25);
+  }
+
+  .semi-skeleton-title {
+    height: 16px;
+    background-color: rgba(82, 100, 154, 0.13);
+    border-radius: 4px;
+  }
+}
+
+
+.node-placeholder-hd {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.node-placeholder-avatar {
+  width: 24px;
+  height: 24px;
+  margin-right: 8px;
+  border-radius: 6px;
+}
+
+.node-placeholder-content {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 3px;
+}
+
+.node-placeholder-footer {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 2.5px;
+}

+ 49 - 0
src/components/node-panel/index.tsx

@@ -0,0 +1,49 @@
+import { FC } from 'react';
+
+import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
+import { Popover } from '@douyinfe/semi-ui';
+
+import { NodePlaceholder } from './node-placeholder';
+import { NodeList } from './node-list';
+import './index.less';
+
+export const NodePanel: FC<NodePanelRenderProps> = (props) => {
+  const { onSelect, position, onClose, panelProps } = props;
+  const { enableNodePlaceholder } = panelProps;
+
+  return (
+    <Popover
+      trigger="click"
+      visible={true}
+      onVisibleChange={(v) => (v ? null : onClose())}
+      content={<NodeList onSelect={onSelect} />}
+      placement="right"
+      popupAlign={{ offset: [30, 0] }}
+      overlayStyle={{
+        padding: 0,
+      }}
+    >
+      <div
+        style={
+          enableNodePlaceholder
+            ? {
+                position: 'absolute',
+                top: position.y - 61.5,
+                left: position.x,
+                width: 360,
+                height: 100,
+              }
+            : {
+                position: 'absolute',
+                top: position.y,
+                left: position.x,
+                width: 0,
+                height: 0,
+              }
+        }
+      >
+        {enableNodePlaceholder && <NodePlaceholder />}
+      </div>
+    </Popover>
+  );
+};

+ 88 - 0
src/components/node-panel/node-list.tsx

@@ -0,0 +1,88 @@
+import React, { FC } from 'react';
+
+import styled from 'styled-components';
+import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
+import { useClientContext } from '@flowgram.ai/free-layout-editor';
+
+import { FlowNodeRegistry } from '../../typings';
+import { visibleNodeRegistries } from '../../nodes';
+
+const NodeWrap = styled.div`
+  width: 100%;
+  height: 32px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  font-size: 19px;
+  padding: 0 15px;
+  &:hover {
+    background-color: hsl(252deg 62% 55% / 9%);
+    color: hsl(252 62% 54.9%);
+  }
+`;
+
+const NodeLabel = styled.div`
+  font-size: 12px;
+  margin-left: 10px;
+`;
+
+interface NodeProps {
+  label: string;
+  icon: JSX.Element;
+  onClick: React.MouseEventHandler<HTMLDivElement>;
+  disabled: boolean;
+}
+
+function Node(props: NodeProps) {
+  return (
+    <NodeWrap
+      data-testid={`demo-free-node-list-${props.label}`}
+      onClick={props.disabled ? undefined : props.onClick}
+      style={props.disabled ? { opacity: 0.3 } : {}}
+    >
+      <div style={{ fontSize: 14 }}>{props.icon}</div>
+      <NodeLabel>{props.label}</NodeLabel>
+    </NodeWrap>
+  );
+}
+
+const NodesWrap = styled.div`
+  max-height: 500px;
+  overflow: auto;
+  &::-webkit-scrollbar {
+    display: none;
+  }
+`;
+
+interface NodeListProps {
+  onSelect: NodePanelRenderProps['onSelect'];
+}
+
+export const NodeList: FC<NodeListProps> = (props) => {
+  const { onSelect } = props;
+  const context = useClientContext();
+  const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
+    const json = registry.onAdd?.(context);
+    onSelect({
+      nodeType: registry.type as string,
+      selectEvent: e,
+      nodeJSON: json,
+    });
+  };
+  return (
+    <NodesWrap style={{ width: 80 * 2 + 20 }}>
+      {visibleNodeRegistries.map((registry) => (
+        <Node
+          key={registry.type}
+          disabled={!(registry.canAdd?.(context) ?? true)}
+          icon={
+            <img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
+          }
+          label={registry.type as string}
+          onClick={(e) => handleClick(e, registry)}
+        />
+      ))}
+    </NodesWrap>
+  );
+};

+ 26 - 0
src/components/node-panel/node-placeholder.tsx

@@ -0,0 +1,26 @@
+import { Skeleton } from '@douyinfe/semi-ui';
+
+export const NodePlaceholder = () => (
+  <div className="node-placeholder" data-testid="workflow.detail.node-panel.placeholder">
+    <Skeleton
+      className="node-placeholder-skeleton"
+      loading={true}
+      active={true}
+      placeholder={
+        <div className="">
+          <div className="node-placeholder-hd">
+            <Skeleton.Avatar shape="square" className="node-placeholder-avatar" />
+            <Skeleton.Title style={{ width: 141 }} />
+          </div>
+          <div className="node-placeholder-content">
+            <div className="node-placeholder-footer">
+              <Skeleton.Title style={{ width: 85 }} />
+              <Skeleton.Title style={{ width: 241 }} />
+            </div>
+            <Skeleton.Title style={{ width: 220 }} />
+          </div>
+        </div>
+      }
+    />
+  </div>
+);

+ 98 - 0
src/components/selector-box-popover/index.tsx

@@ -0,0 +1,98 @@
+import { FunctionComponent } from 'react';
+
+import { SelectorBoxPopoverProps } from '@flowgram.ai/free-layout-editor';
+import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
+import { Button, ButtonGroup, Tooltip } from '@douyinfe/semi-ui';
+import { IconCopy, IconDeleteStroked, IconExpand, IconShrink } from '@douyinfe/semi-icons';
+
+import { IconGroup } from '../group';
+import { FlowCommandId } from '../../shortcuts/constants';
+
+const BUTTON_HEIGHT = 24;
+
+export const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({
+  bounds,
+  children,
+  flowSelectConfig,
+  commandRegistry,
+}) => (
+  <>
+    <div
+      style={{
+        position: 'absolute',
+        left: bounds.right,
+        top: bounds.top,
+        transform: 'translate(-100%, -100%)',
+      }}
+      onMouseDown={(e) => {
+        e.stopPropagation();
+      }}
+    >
+      <ButtonGroup
+        size="small"
+        style={{ display: 'flex', flexWrap: 'nowrap', height: BUTTON_HEIGHT }}
+      >
+        <Tooltip content={'Collapse'}>
+          <Button
+            icon={<IconShrink />}
+            style={{ height: BUTTON_HEIGHT }}
+            type="primary"
+            theme="solid"
+            onMouseDown={(e) => {
+              commandRegistry.executeCommand(FlowCommandId.COLLAPSE);
+            }}
+          />
+        </Tooltip>
+
+        <Tooltip content={'Expand'}>
+          <Button
+            icon={<IconExpand />}
+            style={{ height: BUTTON_HEIGHT }}
+            type="primary"
+            theme="solid"
+            onMouseDown={(e) => {
+              commandRegistry.executeCommand(FlowCommandId.EXPAND);
+            }}
+          />
+        </Tooltip>
+
+        <Tooltip content={'Create Group'}>
+          <Button
+            icon={<IconGroup size={14} />}
+            style={{ height: BUTTON_HEIGHT }}
+            type="primary"
+            theme="solid"
+            onClick={() => {
+              commandRegistry.executeCommand(WorkflowGroupCommand.Group);
+            }}
+          />
+        </Tooltip>
+
+        <Tooltip content={'Copy'}>
+          <Button
+            icon={<IconCopy />}
+            style={{ height: BUTTON_HEIGHT }}
+            type="primary"
+            theme="solid"
+            onClick={() => {
+              commandRegistry.executeCommand(FlowCommandId.COPY);
+            }}
+          />
+        </Tooltip>
+
+        <Tooltip content={'Delete'}>
+          <Button
+            type="primary"
+            theme="solid"
+            icon={<IconDeleteStroked />}
+            style={{ height: BUTTON_HEIGHT }}
+            onClick={() => {
+              commandRegistry.executeCommand(FlowCommandId.DELETE);
+            }}
+          />
+        </Tooltip>
+      </ButtonGroup>
+    </div>
+    <div>{children}</div>
+  </>
+);

+ 2 - 0
src/components/sidebar/index.tsx

@@ -0,0 +1,2 @@
+export { SidebarProvider } from './sidebar-provider';
+export { SidebarRenderer } from './sidebar-renderer';

+ 14 - 0
src/components/sidebar/sidebar-node-renderer.tsx

@@ -0,0 +1,14 @@
+import { useNodeRender, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
+
+import { NodeRenderContext } from '../../context';
+
+export function SidebarNodeRenderer(props: { node: FlowNodeEntity }) {
+  const { node } = props;
+  const nodeRender = useNodeRender(node);
+
+  return (
+    <NodeRenderContext.Provider value={nodeRender}>
+      {nodeRender.form?.render()}
+    </NodeRenderContext.Provider>
+  );
+}

+ 12 - 0
src/components/sidebar/sidebar-provider.tsx

@@ -0,0 +1,12 @@
+import { useState } from 'react';
+
+import { SidebarContext } from '../../context';
+
+export function SidebarProvider({ children }: { children: React.ReactNode }) {
+  const [nodeId, setNodeId] = useState<string | undefined>();
+  return (
+    <SidebarContext.Provider value={{ visible: !!nodeId, nodeId, setNodeId }}>
+      {children}
+    </SidebarContext.Provider>
+  );
+}

+ 88 - 0
src/components/sidebar/sidebar-renderer.tsx

@@ -0,0 +1,88 @@
+import { useCallback, useContext, useEffect, useMemo } from 'react';
+
+import {
+  PlaygroundEntityContext,
+  useRefresh,
+  useClientContext,
+} from '@flowgram.ai/free-layout-editor';
+import { SideSheet } from '@douyinfe/semi-ui';
+
+import { FlowNodeMeta } from '../../typings';
+import { SidebarContext, IsSidebarContext } from '../../context';
+import { SidebarNodeRenderer } from './sidebar-node-renderer';
+
+export const SidebarRenderer = () => {
+  const { nodeId, setNodeId } = useContext(SidebarContext);
+  const { selection, playground, document } = useClientContext();
+  const refresh = useRefresh();
+  const handleClose = useCallback(() => {
+    setNodeId(undefined);
+  }, []);
+  const node = nodeId ? document.getNode(nodeId) : undefined;
+  /**
+   * Listen readonly
+   */
+  useEffect(() => {
+    const disposable = playground.config.onReadonlyOrDisabledChange(() => {
+      handleClose();
+      refresh();
+    });
+    return () => disposable.dispose();
+  }, [playground]);
+  /**
+   * Listen selection
+   */
+  useEffect(() => {
+    const toDispose = selection.onSelectionChanged(() => {
+      /**
+       * 如果没有选中任何节点,则自动关闭侧边栏
+       * If no node is selected, the sidebar is automatically closed
+       */
+      if (selection.selection.length === 0) {
+        handleClose();
+      } else if (selection.selection.length === 1 && selection.selection[0] !== node) {
+        handleClose();
+      }
+    });
+    return () => toDispose.dispose();
+  }, [selection, handleClose, node]);
+  /**
+   * Close when node disposed
+   */
+  useEffect(() => {
+    if (node) {
+      const toDispose = node.onDispose(() => {
+        setNodeId(undefined);
+      });
+      return () => toDispose.dispose();
+    }
+    return () => {};
+  }, [node]);
+
+  const visible = useMemo(() => {
+    if (!node) {
+      return false;
+    }
+    const { sidebarDisable = false } = node.getNodeMeta<FlowNodeMeta>();
+    return !sidebarDisable;
+  }, [node]);
+
+  if (playground.config.readonly) {
+    return null;
+  }
+  /**
+   * Add "key" to rerender the sidebar when the node changes
+   */
+  const content =
+    node && visible ? (
+      <PlaygroundEntityContext.Provider key={node.id} value={node}>
+        <SidebarNodeRenderer node={node} />
+      </PlaygroundEntityContext.Provider>
+    ) : null;
+
+  return (
+    <SideSheet mask={false} visible={visible} onCancel={handleClose}>
+      <IsSidebarContext.Provider value={true}>{content}</IsSidebarContext.Provider>
+    </SideSheet>
+  );
+};

+ 8 - 0
src/components/testrun/node-status-bar/group/index.css

@@ -0,0 +1,8 @@
+.node-status-group {
+    padding: 6px;
+    font-weight: 500;
+    color: #333;
+    font-size: 15px;
+    display: flex;
+    align-items: center;
+}

+ 61 - 0
src/components/testrun/node-status-bar/group/index.tsx

@@ -0,0 +1,61 @@
+import { FC, useState } from 'react';
+
+import { IconSmallTriangleDown } from '@douyinfe/semi-icons';
+
+import { DataStructureViewer } from '../viewer';
+
+import './index.css';
+import { Tag } from '@douyinfe/semi-ui';
+
+interface NodeStatusGroupProps {
+  title: string;
+  data: unknown;
+  optional?: boolean;
+  disableCollapse?: boolean;
+}
+
+const isObjectHasContent = (obj: any = {}): boolean => Object.keys(obj).length > 0;
+
+export const NodeStatusGroup: FC<NodeStatusGroupProps> = ({
+  title,
+  data,
+  optional = false,
+  disableCollapse = false,
+}) => {
+  const hasContent = isObjectHasContent(data);
+  const [isExpanded, setIsExpanded] = useState(true);
+
+  if (optional && !hasContent) {
+    return null;
+  }
+
+  return (
+    <>
+      <div className="node-status-group" onClick={() => hasContent && setIsExpanded(!isExpanded)}>
+        {!disableCollapse && (
+          <IconSmallTriangleDown
+            style={{
+              transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
+              transition: 'transform 0.2s',
+              cursor: 'pointer',
+              marginRight: '4px',
+              opacity: hasContent ? 1 : 0,
+            }}
+          />
+        )}
+        <span>{title}:</span>
+        {!hasContent && (
+          <Tag
+            size="small"
+            style={{
+              marginLeft: 4,
+            }}
+          >
+            null
+          </Tag>
+        )}
+      </div>
+      {hasContent && isExpanded ? <DataStructureViewer data={data} /> : null}
+    </>
+  );
+};

+ 52 - 0
src/components/testrun/node-status-bar/header/index.tsx

@@ -0,0 +1,52 @@
+import React, { useState } from 'react';
+
+import { IconChevronDown } from '@douyinfe/semi-icons';
+
+import { useNodeRenderContext } from '../../../../hooks';
+import { NodeStatusHeaderContentStyle, NodeStatusHeaderStyle } from './style';
+
+interface NodeStatusBarProps {
+  header?: React.ReactNode;
+  defaultShowDetail?: boolean;
+  extraBtns?: React.ReactNode[];
+}
+
+export const NodeStatusHeader: React.FC<React.PropsWithChildren<NodeStatusBarProps>> = ({
+  header,
+  defaultShowDetail,
+  children,
+  extraBtns = [],
+}) => {
+  const [showDetail, setShowDetail] = useState(defaultShowDetail);
+  const { selectNode } = useNodeRenderContext();
+
+  const handleToggleShowDetail = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    selectNode(e);
+    setShowDetail(!showDetail);
+  };
+
+  return (
+    <NodeStatusHeaderStyle
+      // 必须要禁止 down 冒泡,防止判定圈选和 node hover(不支持多边形)
+      onMouseDown={(e) => e.stopPropagation()}
+    >
+      <NodeStatusHeaderContentStyle
+        className={showDetail ? 'status-header-opened' : ''}
+        // 必须要禁止 down 冒泡,防止判定圈选和 node hover(不支持多边形)
+        onMouseDown={(e) => e.stopPropagation()}
+        // 其他事件统一走点击事件,且也需要阻止冒泡
+        onClick={handleToggleShowDetail}
+      >
+        <div className="status-title">
+          {header}
+          {extraBtns.length > 0 ? extraBtns : null}
+        </div>
+        <div className="status-btns">
+          <IconChevronDown className={showDetail ? 'is-show-detail' : ''} />
+        </div>
+      </NodeStatusHeaderContentStyle>
+      {showDetail ? children : null}
+    </NodeStatusHeaderStyle>
+  );
+};

+ 56 - 0
src/components/testrun/node-status-bar/header/style.ts

@@ -0,0 +1,56 @@
+import styled from 'styled-components';
+
+export const NodeStatusHeaderStyle = styled.div`
+  border: 1px solid rgba(68, 83, 130, 0.25);
+  border-radius: 8px;
+  background-color: #fff;
+
+  position: absolute;
+  top: calc(100% + 8px);
+  left: 0;
+
+  width: 100%;
+`;
+
+export const NodeStatusHeaderContentStyle = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px;
+
+  &-opened {
+    padding-bottom: 0;
+  }
+
+  .status-title {
+    height: 24px;
+    display: flex;
+    align-items: center;
+    column-gap: 8px;
+    min-width: 0;
+
+    :global {
+      .coz-tag {
+        height: 20px;
+      }
+      .semi-tag-content {
+        font-weight: 500;
+        line-height: 16px;
+        font-size: 12px;
+      }
+      .semi-tag-suffix-icon > div {
+        font-size: 14px;
+      }
+    }
+  }
+  .status-btns {
+    height: 24px;
+    display: flex;
+    align-items: center;
+    column-gap: 4px;
+  }
+
+  .is-show-detail {
+    transform: rotate(180deg);
+  }
+`;

+ 32 - 0
src/components/testrun/node-status-bar/icon/success.tsx

@@ -0,0 +1,32 @@
+interface Props {
+  className?: string;
+  style?: React.CSSProperties;
+}
+
+export const IconSuccessFill = ({ className, style }: Props) => (
+  <svg
+    className={className}
+    style={style}
+    xmlns="http://www.w3.org/2000/svg"
+    width="20"
+    height="20"
+    fill="none"
+    viewBox="0 0 20 20"
+  >
+    <g clipPath="url(#icon-workflow-run-success_svg__a)">
+      <path
+        fill="#3EC254"
+        d="M.833 10A9.166 9.166 0 0 0 10 19.168a9.166 9.166 0 0 0 9.167-9.166A9.166 9.166 0 0 0 10 .834a9.166 9.166 0 0 0-9.167 9.167"
+      ></path>
+      <path
+        fill="#fff"
+        d="M6.077 9.755a.833.833 0 0 0 0 1.179l2.357 2.357a.833.833 0 0 0 1.179 0l4.714-4.714a.833.833 0 1 0-1.178-1.179l-4.125 4.125-1.768-1.768a.833.833 0 0 0-1.179 0"
+      ></path>
+    </g>
+    <defs>
+      <clipPath id="icon-workflow-run-success_svg__a">
+        <path fill="#fff" d="M0 0h20v20H0z"></path>
+      </clipPath>
+    </defs>
+  </svg>
+);

+ 22 - 0
src/components/testrun/node-status-bar/icon/warning.tsx

@@ -0,0 +1,22 @@
+interface Props {
+  className?: string;
+  style?: React.CSSProperties;
+}
+
+export const IconWarningFill = ({ className, style }: Props) => (
+  <svg
+    className={className}
+    style={style}
+    width="1em"
+    height="1em"
+    viewBox="0 0 24 24"
+    fill="currentColor"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z"
+    ></path>
+  </svg>
+);

+ 42 - 0
src/components/testrun/node-status-bar/index.tsx

@@ -0,0 +1,42 @@
+import { useEffect, useState } from 'react';
+
+import { NodeReport } from '@flowgram.ai/runtime-interface';
+import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
+
+import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
+import { NodeStatusRender } from './render';
+
+const useNodeReport = () => {
+  const node = useCurrentEntity();
+  const [report, setReport] = useState<NodeReport>();
+
+  const runtimeService = useService(WorkflowRuntimeService);
+
+  useEffect(() => {
+    const reportDisposer = runtimeService.onNodeReportChange((nodeReport) => {
+      if (nodeReport.id !== node.id) {
+        return;
+      }
+      setReport(nodeReport);
+    });
+    const resetDisposer = runtimeService.onReset(() => {
+      setReport(undefined);
+    });
+    return () => {
+      reportDisposer.dispose();
+      resetDisposer.dispose();
+    };
+  }, []);
+
+  return report;
+};
+
+export const NodeStatusBar = () => {
+  const report = useNodeReport();
+
+  if (!report) {
+    return null;
+  }
+
+  return <NodeStatusRender report={report} />;
+};

+ 14 - 0
src/components/testrun/node-status-bar/render/index.css

@@ -0,0 +1,14 @@
+.node-status-succeed {
+    background-color: rgba(105, 209, 140, 0.3);
+    color: rgba(0, 178, 60, 1);
+}
+
+.node-status-processing {
+    background-color: rgba(153, 187, 255, 0.3);
+    color: rgba(61, 121, 242, 1);
+}
+
+.node-status-failed {
+    background-color: rgba(255, 163, 171, 0.3);
+    color: rgba(229, 50, 65, 1);
+}

+ 233 - 0
src/components/testrun/node-status-bar/render/index.tsx

@@ -0,0 +1,233 @@
+import { FC, useMemo, useState } from 'react';
+
+import { NodeReport, WorkflowStatus } from '@flowgram.ai/runtime-interface';
+import { Tag, Button, Select } from '@douyinfe/semi-ui';
+import { IconSpin } from '@douyinfe/semi-icons';
+
+import { IconWarningFill } from '../icon/warning';
+import { IconSuccessFill } from '../icon/success';
+import { NodeStatusHeader } from '../header';
+import './index.css';
+import { NodeStatusGroup } from '../group';
+
+interface NodeStatusRenderProps {
+  report: NodeReport;
+}
+
+const msToSeconds = (ms: number): string => (ms / 1000).toFixed(2) + 's';
+const displayCount = 6;
+
+export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
+  const { status: nodeStatus } = report;
+  const [currentSnapshotIndex, setCurrentSnapshotIndex] = useState(0);
+
+  const snapshots = report.snapshots || [];
+  const currentSnapshot = snapshots[currentSnapshotIndex] || snapshots[0];
+
+  // 节点 5 个状态
+  const isNodePending = nodeStatus === WorkflowStatus.Pending;
+  const isNodeProcessing = nodeStatus === WorkflowStatus.Processing;
+  const isNodeFailed = nodeStatus === WorkflowStatus.Failed;
+  const isNodeSucceed = nodeStatus === WorkflowStatus.Succeeded;
+  const isNodeCanceled = nodeStatus === WorkflowStatus.Canceled;
+
+  const tagColor = useMemo(() => {
+    if (isNodeSucceed) {
+      return 'node-status-succeed';
+    }
+    if (isNodeFailed) {
+      return 'node-status-failed';
+    }
+    if (isNodeProcessing) {
+      return 'node-status-processing';
+    }
+  }, [isNodeSucceed, isNodeFailed, isNodeProcessing]);
+
+  const renderIcon = () => {
+    if (isNodeProcessing) {
+      return (
+        <IconSpin
+          spin
+          style={{
+            color: 'rgba(77,83,232,1',
+          }}
+        />
+      );
+    }
+    if (isNodeSucceed) {
+      return <IconSuccessFill />;
+    }
+    return <IconWarningFill className={tagColor} />;
+  };
+  const renderDesc = () => {
+    const getDesc = () => {
+      if (isNodeProcessing) {
+        return 'Running';
+      } else if (isNodePending) {
+        return 'Run terminated';
+      } else if (isNodeSucceed) {
+        return 'Succeed';
+      } else if (isNodeFailed) {
+        return 'Failed';
+      } else if (isNodeCanceled) {
+        return 'Canceled';
+      }
+    };
+
+    const desc = getDesc();
+
+    return desc ? <p style={{ margin: 0 }}>{desc}</p> : null;
+  };
+  const renderCost = () => (
+    <Tag size="small" className={tagColor}>
+      {msToSeconds(report.timeCost)}
+    </Tag>
+  );
+
+  const renderSnapshotNavigation = () => {
+    if (snapshots.length <= 1) {
+      return null;
+    }
+
+    const count = (
+      <p
+        style={{
+          fontWeight: 500,
+          color: '#333',
+          fontSize: '15px',
+          marginLeft: 12,
+        }}
+      >
+        Total: {snapshots.length}
+      </p>
+    );
+
+    if (snapshots.length <= displayCount) {
+      return (
+        <>
+          {count}
+          <div
+            style={{
+              margin: '12px',
+              display: 'flex',
+              gap: '8px',
+              alignItems: 'center',
+              flexWrap: 'wrap',
+            }}
+          >
+            {snapshots.map((_, index) => (
+              <Button
+                key={index}
+                size="small"
+                type={currentSnapshotIndex === index ? 'primary' : 'tertiary'}
+                onClick={() => setCurrentSnapshotIndex(index)}
+                style={{
+                  minWidth: '32px',
+                  height: '32px',
+                  padding: '0',
+                  borderRadius: '4px',
+                  fontSize: '12px',
+                  border: '1px solid',
+                  borderColor:
+                    currentSnapshotIndex === index ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
+                  fontWeight: currentSnapshotIndex === index ? '800' : '500',
+                }}
+              >
+                {index + 1}
+              </Button>
+            ))}
+          </div>
+        </>
+      );
+    }
+
+    // 超过5个时,前5个显示为按钮,剩余的放在下拉选择中
+    return (
+      <>
+        {count}
+        <div
+          style={{
+            margin: '12px',
+            display: 'flex',
+            gap: '8px',
+            alignItems: 'center',
+            flexWrap: 'wrap',
+          }}
+        >
+          {snapshots.slice(0, displayCount).map((_, index) => (
+            <Button
+              key={index}
+              size="small"
+              type="tertiary"
+              onClick={() => setCurrentSnapshotIndex(index)}
+              style={{
+                minWidth: '32px',
+                height: '32px',
+                padding: '0',
+                borderRadius: '4px',
+                fontSize: '12px',
+                border: '1px solid',
+                borderColor: currentSnapshotIndex === index ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
+                fontWeight: currentSnapshotIndex === index ? '800' : '500',
+              }}
+            >
+              {index + 1}
+            </Button>
+          ))}
+          <Select
+            value={currentSnapshotIndex >= displayCount ? currentSnapshotIndex : undefined}
+            onChange={(value) => setCurrentSnapshotIndex(value as number)}
+            style={{
+              width: '100px',
+              height: '32px',
+              border: '1px solid',
+              borderColor:
+                currentSnapshotIndex >= displayCount ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
+            }}
+            size="small"
+            placeholder="Select"
+          >
+            {snapshots.slice(displayCount).map((_, index) => {
+              const actualIndex = index + displayCount;
+              return (
+                <Select.Option key={actualIndex} value={actualIndex}>
+                  {actualIndex + 1}
+                </Select.Option>
+              );
+            })}
+          </Select>
+        </div>
+      </>
+    );
+  };
+
+  if (!report) {
+    return null;
+  }
+
+  return (
+    <NodeStatusHeader
+      header={
+        <>
+          {renderIcon()}
+          {renderDesc()}
+          {renderCost()}
+        </>
+      }
+    >
+      <div
+        style={{
+          width: '100%',
+          height: '100%',
+          padding: '0px 2px 10px 2px',
+        }}
+      >
+        {renderSnapshotNavigation()}
+        <NodeStatusGroup title="Inputs" data={currentSnapshot?.inputs} />
+        <NodeStatusGroup title="Outputs" data={currentSnapshot?.outputs} />
+        <NodeStatusGroup title="Branch" data={currentSnapshot?.branch} optional />
+        <NodeStatusGroup title="Data" data={currentSnapshot?.data} optional />
+      </div>
+    </NodeStatusHeader>
+  );
+};

+ 137 - 0
src/components/testrun/node-status-bar/viewer/index.css

@@ -0,0 +1,137 @@
+.node-status-data-structure-viewer {
+    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+    font-size: 14px;
+    line-height: 1.5;
+    color: #333;
+    background: #fafafa;
+    border-radius: 6px;
+    padding: 12px 12px 12px 0;
+    margin: 12px;
+    border: 1px solid #e1e4e8;
+    overflow: hidden;
+}
+
+.tree-node {
+    margin: 2px 0;
+}
+
+.tree-node-header {
+    display: flex;
+    align-items: flex-start;
+    gap: 4px;
+    min-height: 20px;
+    padding: 2px 0;
+    border-radius: 3px;
+    transition: background-color 0.15s ease;
+}
+
+.tree-node-header:hover {
+    background-color: rgba(0, 0, 0, 0.04);
+}
+
+.expand-button {
+    background: none;
+    border: none;
+    cursor: pointer;
+    font-size: 10px;
+    color: #666;
+    width: 16px;
+    height: 16px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 2px;
+    transition: all 0.15s ease;
+    padding: 0;
+    margin: 0;
+}
+
+.expand-button:hover {
+    background-color: rgba(0, 0, 0, 0.1);
+    color: #333;
+}
+
+.expand-button.expanded {
+    transform: rotate(90deg);
+}
+
+.expand-button.collapsed {
+    transform: rotate(0deg);
+}
+
+.expand-placeholder {
+    width: 16px;
+    height: 16px;
+    display: inline-block;
+    flex-shrink: 0;
+}
+
+.node-label {
+    color: #0969da;
+    font-weight: 500;
+    cursor: pointer;
+    user-select: auto;
+    margin-right: 4px;
+}
+
+.node-label:hover {
+    text-decoration: underline;
+}
+
+.node-value {
+    margin-left: 4px;
+}
+
+.primitive-value-quote {
+    color: #8f8f8f;
+}
+
+.primitive-value {
+    cursor: pointer;
+    user-select: all;
+    padding: 1px 3px;
+    border-radius: 3px;
+    transition: background-color 0.15s ease;
+}
+
+.primitive-value:hover {
+    background-color: rgba(0, 0, 0, 0.05);
+}
+
+.primitive-value.string {
+    color: #032f62;
+    background-color: rgba(3, 47, 98, 0.05);
+}
+
+.primitive-value.number {
+    color: #005cc5;
+    background-color: rgba(0, 92, 197, 0.05);
+}
+
+.primitive-value.boolean {
+    color: #e36209;
+    background-color: rgba(227, 98, 9, 0.05);
+}
+
+.primitive-value.null,
+.primitive-value.undefined {
+    color: #6a737d;
+    font-style: italic;
+    background-color: rgba(106, 115, 125, 0.05);
+}
+
+.tree-node-children {
+    margin-left: 8px;
+    padding-left: 8px;
+    position: relative;
+}
+
+.tree-node-children::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    width: 1px;
+    background: #e1e4e8;
+}

+ 154 - 0
src/components/testrun/node-status-bar/viewer/index.tsx

@@ -0,0 +1,154 @@
+import React, { useState } from 'react';
+
+import './index.css';
+import { Toast } from '@douyinfe/semi-ui';
+
+interface DataStructureViewerProps {
+  data: any;
+  level?: number;
+}
+
+interface TreeNodeProps {
+  label: string;
+  value: any;
+  level: number;
+  isLast?: boolean;
+}
+
+const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false }) => {
+  const [isExpanded, setIsExpanded] = useState(true);
+
+  const handleCopy = (text: string) => {
+    navigator.clipboard.writeText(text);
+    Toast.success('Copied');
+  };
+
+  const isExpandable = (val: any) =>
+    val !== null &&
+    typeof val === 'object' &&
+    ((Array.isArray(val) && val.length > 0) ||
+      (!Array.isArray(val) && Object.keys(val).length > 0));
+
+  const renderPrimitiveValue = (val: any) => {
+    if (val === null) return <span className="primitive-value null">null</span>;
+    if (val === undefined) return <span className="primitive-value undefined">undefined</span>;
+
+    switch (typeof val) {
+      case 'string':
+        return (
+          <span className="string">
+            <span className="primitive-value-quote">{'"'}</span>
+            <span className="primitive-value" onDoubleClick={() => handleCopy(val)}>
+              {val}
+            </span>
+            <span className="primitive-value-quote">{'"'}</span>
+          </span>
+        );
+      case 'number':
+        return (
+          <span className="primitive-value number" onDoubleClick={() => handleCopy(String(val))}>
+            {val}
+          </span>
+        );
+      case 'boolean':
+        return (
+          <span
+            className="primitive-value boolean"
+            onDoubleClick={() => handleCopy(val.toString())}
+          >
+            {val.toString()}
+          </span>
+        );
+      default:
+        return (
+          <span className="primitive-value" onDoubleClick={() => handleCopy(String(val))}>
+            {String(val)}
+          </span>
+        );
+    }
+  };
+
+  const renderChildren = () => {
+    if (Array.isArray(value)) {
+      return value.map((item, index) => (
+        <TreeNode
+          key={index}
+          label={`${index + 1}.`}
+          value={item}
+          level={level + 1}
+          isLast={index === value.length - 1}
+        />
+      ));
+    } else {
+      const entries = Object.entries(value);
+      return entries.map(([key, val], index) => (
+        <TreeNode
+          key={key}
+          label={`${key}:`}
+          value={val}
+          level={level + 1}
+          isLast={index === entries.length - 1}
+        />
+      ));
+    }
+  };
+
+  return (
+    <div className="tree-node">
+      <div className="tree-node-header">
+        {isExpandable(value) ? (
+          <button
+            className={`expand-button ${isExpanded ? 'expanded' : 'collapsed'}`}
+            onClick={() => setIsExpanded(!isExpanded)}
+          >
+            ▶
+          </button>
+        ) : (
+          <span className="expand-placeholder"></span>
+        )}
+        <span
+          className="node-label"
+          onClick={() =>
+            handleCopy(
+              JSON.stringify({
+                [label]: value,
+              })
+            )
+          }
+        >
+          {label}
+        </span>
+        {!isExpandable(value) && <span className="node-value">{renderPrimitiveValue(value)}</span>}
+      </div>
+      {isExpandable(value) && isExpanded && (
+        <div className="tree-node-children">{renderChildren()}</div>
+      )}
+    </div>
+  );
+};
+
+export const DataStructureViewer: React.FC<DataStructureViewerProps> = ({ data, level = 0 }) => {
+  if (data === null || data === undefined || typeof data !== 'object') {
+    return (
+      <div className="node-status-data-structure-viewer">
+        <TreeNode label="value" value={data} level={0} />
+      </div>
+    );
+  }
+
+  const entries = Object.entries(data);
+
+  return (
+    <div className="node-status-data-structure-viewer">
+      {entries.map(([key, value], index) => (
+        <TreeNode
+          key={key}
+          label={key}
+          value={value}
+          level={0}
+          isLast={index === entries.length - 1}
+        />
+      ))}
+    </div>
+  );
+};

+ 78 - 0
src/components/testrun/testrun-button/index.tsx

@@ -0,0 +1,78 @@
+import { useState, useEffect, useCallback } from 'react';
+
+import { useClientContext, getNodeForm, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
+import { Button, Badge, SideSheet } from '@douyinfe/semi-ui';
+import { IconPlay } from '@douyinfe/semi-icons';
+
+import { TestRunSideSheet } from '../testrun-sidesheet';
+
+export function TestRunButton(props: { disabled: boolean }) {
+  const [errorCount, setErrorCount] = useState(0);
+  const clientContext = useClientContext();
+  const [visible, setVisible] = useState(false);
+
+  const updateValidateData = useCallback(() => {
+    const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
+    const count = allForms.filter((form) => form?.state.invalid).length;
+    setErrorCount(count);
+  }, [clientContext]);
+
+  /**
+   * Validate all node and Save
+   */
+  const onTestRun = useCallback(async () => {
+    const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
+    await Promise.all(allForms.map(async (form) => form?.validate()));
+    console.log('>>>>> save data: ', clientContext.document.toJSON());
+    setVisible(true);
+  }, [clientContext]);
+
+  /**
+   * Listen single node validate
+   */
+  useEffect(() => {
+    const listenSingleNodeValidate = (node: FlowNodeEntity) => {
+      const form = getNodeForm(node);
+      if (form) {
+        const formValidateDispose = form.onValidate(() => updateValidateData());
+        node.onDispose(() => formValidateDispose.dispose());
+      }
+    };
+    clientContext.document.getAllNodes().map((node) => listenSingleNodeValidate(node));
+    const dispose = clientContext.document.onNodeCreate(({ node }) =>
+      listenSingleNodeValidate(node)
+    );
+    return () => dispose.dispose();
+  }, [clientContext]);
+
+  const button =
+    errorCount === 0 ? (
+      <Button
+        disabled={props.disabled}
+        onClick={onTestRun}
+        icon={<IconPlay size="small" />}
+        style={{ backgroundColor: 'rgba(0,178,60,1)', borderRadius: '8px', color: '#fff' }}
+      >
+        Test Run
+      </Button>
+    ) : (
+      <Badge count={errorCount} position="rightTop" type="danger">
+        <Button
+          type="danger"
+          disabled={props.disabled}
+          onClick={onTestRun}
+          icon={<IconPlay size="small" />}
+          style={{ backgroundColor: 'rgba(255,115,0, 1)', borderRadius: '8px', color: '#fff' }}
+        >
+            Test Run
+        </Button>
+      </Badge>
+    );
+
+  return (
+    <>
+      {button}
+      <TestRunSideSheet visible={visible} onCancel={() => setVisible((v) => !v)} />
+    </>
+  );
+}

+ 138 - 0
src/components/testrun/testrun-sidesheet/index.tsx

@@ -0,0 +1,138 @@
+import { FC, useEffect, useState } from 'react';
+
+import { WorkflowInputs, WorkflowOutputs } from '@flowgram.ai/runtime-interface';
+import { useService } from '@flowgram.ai/free-layout-editor';
+import { Button, JsonViewer, SideSheet } from '@douyinfe/semi-ui';
+import { IconPlay, IconSpin, IconStop } from '@douyinfe/semi-icons';
+
+import { NodeStatusGroup } from '../node-status-bar/group';
+import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
+
+interface TestRunSideSheetProps {
+  visible: boolean;
+  onCancel: () => void;
+}
+
+export const TestRunSideSheet: FC<TestRunSideSheetProps> = ({ visible, onCancel }) => {
+  const runtimeService = useService(WorkflowRuntimeService);
+  const [isRunning, setRunning] = useState(false);
+  const [value, setValue] = useState<string>(`{}`);
+  const [error, setError] = useState<string | undefined>();
+  const [result, setResult] = useState<
+    | {
+        inputs: WorkflowInputs;
+        outputs: WorkflowOutputs;
+      }
+    | undefined
+  >();
+
+  const onTestRun = async () => {
+    if (isRunning) {
+      await runtimeService.taskCancel();
+      return;
+    }
+    setResult(undefined);
+    setError(undefined);
+    setRunning(true);
+    try {
+      await runtimeService.taskRun(value);
+    } catch (e: any) {
+      setError(e.message);
+    }
+  };
+
+  const onClose = async () => {
+    await runtimeService.taskCancel();
+    setValue(`{}`);
+    setRunning(false);
+    onCancel();
+  };
+
+  useEffect(() => {
+    const disposer = runtimeService.onTerminated(({ result }) => {
+      setRunning(false);
+      setResult(result);
+    });
+    return () => disposer.dispose();
+  }, []);
+
+  const renderRunning = (
+    <div
+      style={{
+        width: '100%',
+        height: '80%',
+        display: 'flex',
+        flexDirection: 'column',
+        justifyContent: 'center',
+        alignItems: 'center',
+        gap: 16,
+      }}
+    >
+      <IconSpin spin size="large" />
+      <div
+        style={{
+          fontSize: '18px',
+        }}
+      >
+        Running...
+      </div>
+    </div>
+  );
+
+  const renderForm = (
+    <div>
+      <div
+        style={{
+          fontSize: '15px',
+          fontWeight: '500',
+          marginBottom: '10px',
+          color: '#333',
+        }}
+      >
+        Input
+      </div>
+      <JsonViewer showSearch={false} height={300} value={value} onChange={setValue} />
+      <div
+        style={{
+          color: 'red',
+          fontSize: '14px',
+          marginTop: '30px',
+        }}
+      >
+        {error}
+      </div>
+
+      <NodeStatusGroup title="Inputs" data={result?.inputs} optional disableCollapse />
+      <NodeStatusGroup title="Outputs" data={result?.outputs} optional disableCollapse />
+    </div>
+  );
+
+  const renderButton = (
+    <Button
+      onClick={onTestRun}
+      icon={isRunning ? <IconStop size="small" /> : <IconPlay size="small" />}
+      style={{
+        backgroundColor: isRunning ? 'rgba(87,104,161,0.08)' : 'rgba(0,178,60,1)',
+        borderRadius: '8px',
+        color: isRunning ? 'rgba(15,21,40,0.82)' : '#fff',
+        marginBottom: '16px',
+        width: '100%',
+        height: '40px',
+      }}
+    >
+      {isRunning ? 'Cancel' : 'Test Run'}
+    </Button>
+  );
+
+  return (
+    <SideSheet
+      title="Test Run"
+      visible={visible}
+      mask={false}
+      onCancel={onClose}
+      footer={renderButton}
+    >
+      {isRunning ? renderRunning : renderForm}
+    </SideSheet>
+  );
+};

+ 26 - 0
src/components/tools/auto-layout.tsx

@@ -0,0 +1,26 @@
+import { useCallback } from 'react';
+
+import { usePlayground, usePlaygroundTools } from '@flowgram.ai/free-layout-editor';
+import { IconButton, Tooltip } from '@douyinfe/semi-ui';
+
+import { IconAutoLayout } from '../../assets/icon-auto-layout';
+
+export const AutoLayout = () => {
+  const tools = usePlaygroundTools();
+  const playground = usePlayground();
+  const autoLayout = useCallback(async () => {
+    await tools.autoLayout();
+  }, [tools]);
+
+  return (
+    <Tooltip content={'Auto Layout'}>
+      <IconButton
+        disabled={playground.config.readonly}
+        type="tertiary"
+        theme="borderless"
+        onClick={autoLayout}
+        icon={IconAutoLayout}
+      />
+    </Tooltip>
+  );
+};

+ 76 - 0
src/components/tools/comment.tsx

@@ -0,0 +1,76 @@
+import { useState, useCallback } from 'react';
+
+import {
+  delay,
+  usePlayground,
+  useService,
+  WorkflowDocument,
+  WorkflowDragService,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+import { IconButton, Tooltip } from '@douyinfe/semi-ui';
+
+import { WorkflowNodeType } from '../../nodes';
+import { IconComment } from '../../assets/icon-comment';
+
+export const Comment = () => {
+  const playground = usePlayground();
+  const document = useService(WorkflowDocument);
+  const selectService = useService(WorkflowSelectService);
+  const dragService = useService(WorkflowDragService);
+
+  const [tooltipVisible, setTooltipVisible] = useState(false);
+
+  const calcNodePosition = useCallback(
+    (mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
+      const mousePosition = playground.config.getPosFromMouseEvent(mouseEvent);
+      return {
+        x: mousePosition.x,
+        y: mousePosition.y - 75,
+      };
+    },
+    [playground]
+  );
+
+  const createComment = useCallback(
+    async (mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
+      setTooltipVisible(false);
+      const canvasPosition = calcNodePosition(mouseEvent);
+      // 创建节点
+      const node = document.createWorkflowNodeByType(WorkflowNodeType.Comment, canvasPosition);
+      // 等待节点渲染
+      await delay(16);
+      // 选中节点
+      selectService.selectNode(node);
+      // 开始拖拽
+      dragService.startDragSelectedNodes(mouseEvent);
+    },
+    [selectService, calcNodePosition, document, dragService]
+  );
+
+  return (
+    <Tooltip
+      trigger="custom"
+      visible={tooltipVisible}
+      onVisibleChange={setTooltipVisible}
+      content="Comment"
+    >
+      <IconButton
+        disabled={playground.config.readonly}
+        icon={
+          <IconComment
+            style={{
+              width: 16,
+              height: 16,
+            }}
+          />
+        }
+        type="tertiary"
+        theme="borderless"
+        onClick={createComment}
+        onMouseEnter={() => setTooltipVisible(true)}
+        onMouseLeave={() => setTooltipVisible(false)}
+      />
+    </Tooltip>
+  );
+};

+ 17 - 0
src/components/tools/fit-view.tsx

@@ -0,0 +1,17 @@
+import { usePlaygroundTools } from '@flowgram.ai/free-layout-editor';
+import { IconButton, Tooltip } from '@douyinfe/semi-ui';
+import { IconExpand } from '@douyinfe/semi-icons';
+
+export const FitView = () => {
+  const tools = usePlaygroundTools();
+  return (
+    <Tooltip content="FitView">
+      <IconButton
+        icon={<IconExpand />}
+        type="tertiary"
+        theme="borderless"
+        onClick={() => tools.fitView()}
+      />
+    </Tooltip>
+  );
+};

+ 77 - 0
src/components/tools/index.tsx

@@ -0,0 +1,77 @@
+import { useState, useEffect } from 'react';
+
+import { useRefresh } from '@flowgram.ai/free-layout-editor';
+import { useClientContext } from '@flowgram.ai/free-layout-editor';
+import { Tooltip, IconButton, Divider } from '@douyinfe/semi-ui';
+import { IconUndo, IconRedo } from '@douyinfe/semi-icons';
+
+import { TestRunButton } from '../testrun/testrun-button';
+import { AddNode } from '../add-node';
+import { ZoomSelect } from './zoom-select';
+import { SwitchLine } from './switch-line';
+import { ToolContainer, ToolSection } from './styles';
+import { Readonly } from './readonly';
+import { MinimapSwitch } from './minimap-switch';
+import { Minimap } from './minimap';
+import { Interactive } from './interactive';
+import { FitView } from './fit-view';
+import { Comment } from './comment';
+import { AutoLayout } from './auto-layout';
+
+export const DemoTools = () => {
+  const { history, playground } = useClientContext();
+  const [canUndo, setCanUndo] = useState(false);
+  const [canRedo, setCanRedo] = useState(false);
+  const [minimapVisible, setMinimapVisible] = useState(true);
+  useEffect(() => {
+    const disposable = history.undoRedoService.onChange(() => {
+      setCanUndo(history.canUndo());
+      setCanRedo(history.canRedo());
+    });
+    return () => disposable.dispose();
+  }, [history]);
+  const refresh = useRefresh();
+
+  useEffect(() => {
+    const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());
+    return () => disposable.dispose();
+  }, [playground]);
+
+  return (
+    <ToolContainer className="demo-free-layout-tools">
+      <ToolSection>
+        <Interactive />
+        <AutoLayout />
+        <SwitchLine />
+        <ZoomSelect />
+        <FitView />
+        <MinimapSwitch minimapVisible={minimapVisible} setMinimapVisible={setMinimapVisible} />
+        <Minimap visible={minimapVisible} />
+        <Readonly />
+        <Comment />
+        <Tooltip content="Undo">
+          <IconButton
+            type="tertiary"
+            theme="borderless"
+            icon={<IconUndo />}
+            disabled={!canUndo || playground.config.readonly}
+            onClick={() => history.undo()}
+          />
+        </Tooltip>
+        <Tooltip content="Redo">
+          <IconButton
+            type="tertiary"
+            theme="borderless"
+            icon={<IconRedo />}
+            disabled={!canRedo || playground.config.readonly}
+            onClick={() => history.redo()}
+          />
+        </Tooltip>
+        <Divider layout="vertical" style={{ height: '16px' }} margin={3} />
+        <AddNode disabled={playground.config.readonly} />
+        <Divider layout="vertical" style={{ height: '16px' }} margin={3} />
+        <TestRunButton disabled={playground.config.readonly} />
+      </ToolSection>
+    </ToolContainer>
+  );
+};

+ 96 - 0
src/components/tools/interactive.tsx

@@ -0,0 +1,96 @@
+import { useEffect, useState } from 'react';
+
+import {
+  usePlaygroundTools,
+  type InteractiveType as IdeInteractiveType,
+} from '@flowgram.ai/free-layout-editor';
+import { Tooltip, Popover } from '@douyinfe/semi-ui';
+
+import { MousePadSelector } from './mouse-pad-selector';
+
+export const CACHE_KEY = 'workflow_prefer_interactive_type';
+export const SHOW_KEY = 'show_workflow_interactive_type_guide';
+export const IS_MAC_OS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);
+
+export const getPreferInteractiveType = () => {
+  const data = localStorage.getItem(CACHE_KEY) as string;
+  if (data && [InteractiveType.Mouse, InteractiveType.Pad].includes(data as InteractiveType)) {
+    return data;
+  }
+  return IS_MAC_OS ? InteractiveType.Pad : InteractiveType.Mouse;
+};
+
+export const setPreferInteractiveType = (type: InteractiveType) => {
+  localStorage.setItem(CACHE_KEY, type);
+};
+
+export enum InteractiveType {
+  Mouse = 'MOUSE',
+  Pad = 'PAD',
+}
+
+export const Interactive = () => {
+  const tools = usePlaygroundTools();
+  const [visible, setVisible] = useState(false);
+
+  const [interactiveType, setInteractiveType] = useState<InteractiveType>(
+    () => getPreferInteractiveType() as InteractiveType
+  );
+
+  const [showInteractivePanel, setShowInteractivePanel] = useState(false);
+
+  const mousePadTooltip =
+    interactiveType === InteractiveType.Mouse ? 'Mouse-Friendly' : 'Touchpad-Friendly';
+
+  useEffect(() => {
+    tools.setMouseScrollDelta((zoom) => zoom / 20);
+
+    // read from localStorage
+    const preferInteractiveType = getPreferInteractiveType();
+    tools.setInteractiveType(preferInteractiveType as IdeInteractiveType);
+  }, []);
+
+  const handleClose = () => {
+    setVisible(false);
+  };
+
+  return (
+    <Popover trigger="custom" position="top" visible={visible} onClickOutSide={handleClose}>
+      <Tooltip
+        content={mousePadTooltip}
+        style={{ display: showInteractivePanel ? 'none' : 'block' }}
+      >
+        <div className="workflow-toolbar-interactive">
+          <MousePadSelector
+            value={interactiveType}
+            onChange={(value) => {
+              setInteractiveType(value);
+              setPreferInteractiveType(value);
+              tools.setInteractiveType(value as unknown as IdeInteractiveType);
+            }}
+            onPopupVisibleChange={setShowInteractivePanel}
+            containerStyle={{
+              border: 'none',
+              height: '32px',
+              width: '32px',
+              justifyContent: 'center',
+              alignItems: 'center',
+              gap: '2px',
+              padding: '4px',
+              borderRadius: 'var(--small, 6px)',
+            }}
+            iconStyle={{
+              margin: '0',
+              width: '16px',
+              height: '16px',
+            }}
+            arrowStyle={{
+              width: '12px',
+              height: '12px',
+            }}
+          />
+        </div>
+      </Tooltip>
+    </Popover>
+  );
+};

+ 21 - 0
src/components/tools/minimap-switch.tsx

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

+ 33 - 0
src/components/tools/minimap.tsx

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

+ 112 - 0
src/components/tools/mouse-pad-selector.less

@@ -0,0 +1,112 @@
+/* stylelint-disable no-descending-specificity */
+/* stylelint-disable selector-class-pattern */
+.ui-mouse-pad-selector {
+  position: relative;
+
+  display: flex;
+  align-items: center;
+
+  box-sizing: border-box;
+  width: 68px;
+  height: 32px;
+  padding: 8px 12px;
+
+  border: 1px solid rgba(29, 28, 35, 8%);
+  border-radius: 8px;
+
+  &-icon {
+    height: 20px;
+    margin-right: 12px;
+  }
+
+  &-arrow {
+    height: 16px;
+    font-size: 12px;
+  }
+
+  &-popover {
+    padding: 16px;
+
+    &-options {
+      display: flex;
+      gap: 12px;
+      margin-top: 12px;
+    }
+
+    .mouse-pad-option {
+      box-sizing: border-box;
+      width: 220px;
+      padding-bottom: 20px;
+
+      text-align: center;
+
+      background: var(--coz-mg-card, #FFF);
+      border: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));
+      border-radius: var(--default, 8px);
+
+      &-icon {
+        padding-top: 26px;
+      }
+
+      &-title {
+        padding-top: 8px;
+      }
+
+      &-subTitle {
+        padding: 4px 12px 0;
+      }
+
+      &-icon-selected {
+        color: rgb(19 0 221);
+      }
+
+      &-title-selected {
+        color: var(--coz-fg-hglt, #4E40E5);
+      }
+
+      &-subTitle-selected {
+        color: var(--coz-fg-hglt, #4E40E5);
+      }
+
+      &-selected {
+        cursor: pointer;
+        background-color: var(--coz-mg-hglt, rgba(186, 192, 255, 20%));
+        border: 1px solid var(--coz-stroke-hglt, #4E40E5);
+        border-radius: var(--default, 8px);
+      }
+
+      &:hover:not(&-selected) {
+        cursor: pointer;
+
+        background-color: var(--coz-mg-card-hovered, #FFF);
+        border: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));
+        border-radius: var(--default, 8px);
+        box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 16%), 0 16px 48px 0 rgba(0, 0, 0, 8%);
+      }
+
+      &:active:not(&-selected) {
+        background-color: rgba(46, 46, 56, 12%);
+      }
+
+      &:last-of-type {
+        padding-top: 13px;
+      }
+    }
+  }
+
+  &:hover {
+    cursor: pointer;
+    background-color: rgba(46, 46, 56, 8%);
+    border-color: rgba(77, 83, 232, 100%);
+  }
+
+  &:active,
+  &:focus {
+    background-color: rgba(46, 46, 56, 12%);
+    border-color: rgba(77, 83, 232, 100%);
+  }
+
+  &-active {
+    border-color: rgba(77, 83, 232, 100%);
+  }
+}

+ 117 - 0
src/components/tools/mouse-pad-selector.tsx

@@ -0,0 +1,117 @@
+import React, { type CSSProperties, useState } from 'react';
+
+import { Popover, Typography } from '@douyinfe/semi-ui';
+
+import { IconPad, IconPadTool } from '../../assets/icon-pad';
+import { IconMouse, IconMouseTool } from '../../assets/icon-mouse';
+
+import './mouse-pad-selector.less';
+
+const { Title, Paragraph } = Typography;
+
+export enum InteractiveType {
+  Mouse = 'MOUSE',
+  Pad = 'PAD',
+}
+
+export interface MousePadSelectorProps {
+  value: InteractiveType;
+  onChange: (value: InteractiveType) => void;
+  onPopupVisibleChange?: (visible: boolean) => void;
+  containerStyle?: CSSProperties;
+  iconStyle?: CSSProperties;
+  arrowStyle?: CSSProperties;
+}
+
+const InteractiveItem: React.FC<{
+  title: string;
+  subTitle: string;
+  icon: React.ReactNode;
+  value: InteractiveType;
+  selected: boolean;
+  onChange: (value: InteractiveType) => void;
+}> = ({ title, subTitle, icon, onChange, value, selected }) => (
+  <div
+    className={`mouse-pad-option ${selected ? 'mouse-pad-option-selected' : ''}`}
+    onClick={() => onChange(value)}
+  >
+    <div className={`mouse-pad-option-icon ${selected ? 'mouse-pad-option-icon-selected' : ''}`}>
+      {icon}
+    </div>
+    <Title
+      heading={6}
+      className={`mouse-pad-option-title ${selected ? 'mouse-pad-option-title-selected' : ''}`}
+    >
+      {title}
+    </Title>
+    <Paragraph
+      type="tertiary"
+      className={`mouse-pad-option-subTitle ${
+        selected ? 'mouse-pad-option-subTitle-selected' : ''
+      }`}
+    >
+      {subTitle}
+    </Paragraph>
+  </div>
+);
+
+export const MousePadSelector: React.FC<
+  MousePadSelectorProps & React.RefAttributes<HTMLDivElement>
+> = ({ value, onChange, onPopupVisibleChange, containerStyle, iconStyle, arrowStyle }) => {
+  const isMouse = value === InteractiveType.Mouse;
+  const [visible, setVisible] = useState(false);
+
+  return (
+    <Popover
+      trigger="custom"
+      position="topLeft"
+      closeOnEsc
+      visible={visible}
+      onVisibleChange={(v) => {
+        onPopupVisibleChange?.(v);
+      }}
+      onClickOutSide={() => {
+        setVisible(false);
+      }}
+      spacing={20}
+      content={
+        <div className={'ui-mouse-pad-selector-popover'}>
+          <Typography.Title heading={4}>{'Interaction mode'}</Typography.Title>
+          <div className={'ui-mouse-pad-selector-popover-options'}>
+            <InteractiveItem
+              title={'Mouse-Friendly'}
+              subTitle={'Drag the canvas with the left mouse button, zoom with the scroll wheel.'}
+              value={InteractiveType.Mouse}
+              selected={value === InteractiveType.Mouse}
+              icon={<IconMouse />}
+              onChange={onChange}
+            />
+
+            <InteractiveItem
+              title={'Touchpad-Friendly'}
+              subTitle={
+                'Drag with two fingers moving in the same direction, zoom by pinching or spreading two fingers.'
+              }
+              value={InteractiveType.Pad}
+              selected={value === InteractiveType.Pad}
+              icon={<IconPad />}
+              onChange={onChange}
+            />
+          </div>
+        </div>
+      }
+    >
+      <div
+        className={`ui-mouse-pad-selector ${visible ? 'ui-mouse-pad-selector-active' : ''}`}
+        onClick={() => {
+          setVisible(!visible);
+        }}
+        style={containerStyle}
+      >
+        <div className={'ui-mouse-pad-selector-icon'} style={iconStyle}>
+          {isMouse ? <IconMouseTool /> : <IconPadTool />}
+        </div>
+      </div>
+    </Popover>
+  );
+};

+ 0 - 0
src/components/tools/readonly.tsx


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