Ver código fonte

refactor: decoupling node panel and create node (#129)

* refactor(free-container-plugin): decoupling workflow node panel service private methods to utils

* fix(free-demo): multi-select style in sub-canvas

* fix(config): enable eslint formatter

* feat(free-container-plugin): call node panel on single select mode

* refactor(free-demo): make methods in onDragLineEnd more atomic and easier understand

* refactor(free-demo): make methods in useAddNode more atomic and easier understand

* refactor(free-demo): make methods in lineAddButton.onClick more atomic and easier understand

* chore(free-demo): default add node to canvas viewport center

* fix(node-panel): resolve promise after panel closed

* chore(free-demo): add comments to node panel related code

* chore(node-panel): clear useless code
Louis Young 9 meses atrás
pai
commit
288c48e56f
26 arquivos alterados com 816 adições e 451 exclusões
  1. 3 3
      .vscode/settings.json
  2. 33 20
      apps/demo-free-layout/src/components/add-node/use-add-node.ts
  3. 96 23
      apps/demo-free-layout/src/components/line-add-button/index.tsx
  4. 3 25
      apps/demo-free-layout/src/hooks/use-editor-props.tsx
  5. 39 22
      apps/demo-free-layout/src/styles/index.css
  6. 1 0
      apps/demo-free-layout/src/utils/index.ts
  7. 91 0
      apps/demo-free-layout/src/utils/on-drag-line-end.ts
  8. 1 1
      packages/plugins/free-node-panel-plugin/src/component.tsx
  9. 1 5
      packages/plugins/free-node-panel-plugin/src/create-plugin.ts
  10. 1 0
      packages/plugins/free-node-panel-plugin/src/index.ts
  11. 6 5
      packages/plugins/free-node-panel-plugin/src/layer.tsx
  12. 44 344
      packages/plugins/free-node-panel-plugin/src/service.ts
  13. 3 3
      packages/plugins/free-node-panel-plugin/src/type.ts
  14. 51 0
      packages/plugins/free-node-panel-plugin/src/utils/adjust-node-position.ts
  15. 44 0
      packages/plugins/free-node-panel-plugin/src/utils/build-line.ts
  16. 23 0
      packages/plugins/free-node-panel-plugin/src/utils/get-container-node.ts
  17. 25 0
      packages/plugins/free-node-panel-plugin/src/utils/get-port-box.ts
  18. 45 0
      packages/plugins/free-node-panel-plugin/src/utils/get-sub-nodes.ts
  19. 0 0
      packages/plugins/free-node-panel-plugin/src/utils/greater-or-less.ts
  20. 39 0
      packages/plugins/free-node-panel-plugin/src/utils/index.ts
  21. 5 0
      packages/plugins/free-node-panel-plugin/src/utils/is-container.ts
  22. 22 0
      packages/plugins/free-node-panel-plugin/src/utils/rect-distance.ts
  23. 58 0
      packages/plugins/free-node-panel-plugin/src/utils/sub-nodes-auto-offset.ts
  24. 90 0
      packages/plugins/free-node-panel-plugin/src/utils/sub-position-offset.ts
  25. 85 0
      packages/plugins/free-node-panel-plugin/src/utils/update-sub-nodes-position.ts
  26. 7 0
      packages/plugins/free-node-panel-plugin/src/utils/wait-node-render.ts

+ 3 - 3
.vscode/settings.json

@@ -91,7 +91,7 @@
     "typescriptreact"
   ],
   "editor.semanticHighlighting.enabled": false,
-  "eslint.format.enable": false,
+  "eslint.format.enable": true,
   "eslint.enable": true,
   "eslint.useFlatConfig": true,
   "eslint.codeActionsOnSave.mode": "problems",
@@ -130,7 +130,7 @@
   // "stylelint.stylelintPath": "config/stylelint-config/node_modules/stylelint",
   "emmet.triggerExpansionOnTab": true,
   "[typescript]": {
-    "editor.defaultFormatter": "esbenp.prettier-vscode"
+    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
   },
   "[yaml]": {
     "editor.defaultFormatter": "esbenp.prettier-vscode"
@@ -160,7 +160,7 @@
     "editor.defaultFormatter": "esbenp.prettier-vscode"
   },
   "[typescriptreact]": {
-    "editor.defaultFormatter": "esbenp.prettier-vscode"
+    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
   },
   "[ignore]": {
     "editor.defaultFormatter": "foxundermoon.shell-format"

+ 33 - 20
apps/demo-free-layout/src/components/add-node/use-add-node.ts

@@ -1,21 +1,23 @@
 import { useCallback } from 'react';
 
-import { WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
+import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
 import {
   useService,
   WorkflowDocument,
   usePlayground,
-  getAntiOverlapPosition,
   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,
@@ -24,6 +26,7 @@ const useGetPanelPosition = () => {
   );
 };
 
+// hook to handle node selection - 处理节点选择的 hook
 const useSelectNode = () => {
   const selectService = useService(WorkflowSelectService);
   return useCallback(
@@ -31,12 +34,14 @@ const useSelectNode = () => {
       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);
@@ -46,25 +51,33 @@ export const useAddNode = () => {
 
   return useCallback(
     async (targetBoundingRect: DOMRect): Promise<void> => {
+      // calculate panel position based on target element - 根据目标元素计算面板位置
       const panelPosition = getPanelPosition(targetBoundingRect);
-      await nodePanelService.call({
-        panelPosition,
-        customPosition: ({ selectPosition }) => {
-          const nodeWidth = 360;
-          const nodePanelOffset = 150 / playground.config.zoom;
-          const customPositionX = panelPosition.x + nodeWidth / 2 + nodePanelOffset;
-          const customNodePosition = getAntiOverlapPosition(workflowDocument, {
-            x: customPositionX,
-            y: selectPosition.y,
-          });
-          return {
-            x: customNodePosition.x,
-            y: customNodePosition.y,
-          };
-        },
-        enableSelectPosition: true,
-        enableMultiAdd: true,
-        afterAddNode: select,
+      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]

+ 96 - 23
apps/demo-free-layout/src/components/line-add-button/index.tsx

@@ -1,6 +1,20 @@
-import { WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
+import { useCallback } from 'react';
+
+import {
+  WorkflowNodePanelService,
+  WorkflowNodePanelUtils,
+} from '@flowgram.ai/free-node-panel-plugin';
 import { LineRenderProps } from '@flowgram.ai/free-lines-plugin';
-import { useService } from '@flowgram.ai/free-layout-editor';
+import {
+  delay,
+  HistoryService,
+  useService,
+  WorkflowDocument,
+  WorkflowDragService,
+  WorkflowLinesManager,
+  WorkflowNodeEntity,
+  WorkflowNodeJSON,
+} from '@flowgram.ai/free-layout-editor';
 
 import './index.less';
 import { useVisible } from './use-visible';
@@ -10,13 +24,90 @@ export const LineAddButton = (props: LineRenderProps) => {
   const { line, selected, color } = props;
   const visible = useVisible({ line, selected, color });
   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 <></>;
   }
 
-  const { fromPort, toPort } = line;
-
   return (
     <div
       className="line-add-button"
@@ -27,25 +118,7 @@ export const LineAddButton = (props: LineRenderProps) => {
       }}
       data-testid="sdk.workflow.canvas.line.add"
       data-line-id={line.id}
-      onClick={async () => {
-        const node = await nodePanelService.call({
-          panelPosition: {
-            x: (line.position.from.x + line.position.to.x) / 2,
-            y: (line.position.from.y + line.position.to.y) / 2,
-          },
-          fromPort,
-          toPort,
-          enableBuildLine: true,
-          enableAutoOffset: true,
-          panelProps: {
-            enableScrollClose: true,
-          },
-        });
-        if (!node) {
-          return;
-        }
-        line.dispose();
-      }}
+      onClick={onClick}
     >
       <IconPlusCircle />
     </div>

+ 3 - 25
apps/demo-free-layout/src/hooks/use-editor-props.tsx

@@ -4,14 +4,12 @@ import { useMemo } from 'react';
 import { debounce } from 'lodash-es';
 import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
 import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
-import {
-  createFreeNodePanelPlugin,
-  WorkflowNodePanelService,
-} from '@flowgram.ai/free-node-panel-plugin';
+import { createFreeNodePanelPlugin } from '@flowgram.ai/free-node-panel-plugin';
 import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';
 import { FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
 import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
 
+import { onDragLineEnd } from '../utils';
 import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
 import { shortcuts } from '../shortcuts';
 import { CustomService } from '../services';
@@ -94,27 +92,7 @@ export function useEditorProps(
        * Drag the end of the line to create an add panel (feature optional)
        * 拖拽线条结束需要创建一个添加面板 (功能可选)
        */
-      async onDragLineEnd(ctx, params) {
-        const nodePanelService = ctx.get(WorkflowNodePanelService);
-        const { fromPort, toPort, mousePos, line, originLine } = params;
-        if (originLine || !line) {
-          return;
-        }
-        if (toPort) {
-          return;
-        }
-        // Open add panel
-        await nodePanelService.call({
-          fromPort,
-          toPort: undefined,
-          panelPosition: mousePos,
-          enableBuildLine: true,
-          panelProps: {
-            enableNodePlaceholder: true,
-            enableScrollClose: true,
-          },
-        });
-      },
+      onDragLineEnd,
       /**
        * SelectBox config
        */

+ 39 - 22
apps/demo-free-layout/src/styles/index.css

@@ -1,37 +1,54 @@
+.gedit-selector-bounds-background {
+    cursor: move;
+    display: none !important;
+}
+
+.gedit-selector-bounds-foreground {
+    cursor: move;
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 0;
+    height: 0;
+    outline: 1px solid var(--g-playground-selectBox-outline);
+    z-index: 33;
+    background-color: var(--g-playground-selectBox-background);
+}
+
 .demo-editor {
-  flex-grow: 1;
-  position: relative;
-  height: 100%;
+    flex-grow: 1;
+    position: relative;
+    height: 100%;
 }
 
 .demo-container {
-  position: absolute;
-  left: 0px;
-  top: 0px;
-  display: flex;
-  width: 100%;
-  height: 100%;
-  flex-direction: column;
+    position: absolute;
+    left: 0px;
+    top: 0px;
+    display: flex;
+    width: 100%;
+    height: 100%;
+    flex-direction: column;
 }
 
 .demo-tools {
-  padding: 10px;
-  display: flex;
-  justify-content: space-between;
+    padding: 10px;
+    display: flex;
+    justify-content: space-between;
 }
 
-.demo-tools-group>* {
-  margin-right: 8px;
+.demo-tools-group > * {
+    margin-right: 8px;
 }
 
 .port-if {
-  position: absolute;
-  right: 0px;
-  top: 36px;
+    position: absolute;
+    right: 0px;
+    top: 36px;
 }
 
-.mouse-pad-option-icon  {
-  display: flex;
-  justify-content: center;
-  align-items: center;
+.mouse-pad-option-icon {
+    display: flex;
+    justify-content: center;
+    align-items: center;
 }

+ 1 - 0
apps/demo-free-layout/src/utils/index.ts

@@ -0,0 +1 @@
+export { onDragLineEnd } from './on-drag-line-end';

+ 91 - 0
apps/demo-free-layout/src/utils/on-drag-line-end.ts

@@ -0,0 +1,91 @@
+import {
+  WorkflowNodePanelService,
+  WorkflowNodePanelUtils,
+} from '@flowgram.ai/free-node-panel-plugin';
+import {
+  delay,
+  FreeLayoutPluginContext,
+  onDragLineEndParams,
+  WorkflowDragService,
+  WorkflowLinesManager,
+  WorkflowNodeEntity,
+  WorkflowNodeJSON,
+} from '@flowgram.ai/free-layout-editor';
+
+/**
+ * Drag the end of the line to create an add panel (feature optional)
+ * 拖拽线条结束需要创建一个添加面板 (功能可选)
+ */
+export const onDragLineEnd = async (ctx: FreeLayoutPluginContext, params: onDragLineEndParams) => {
+  // get services from context - 从上下文获取服务
+  const nodePanelService = ctx.get(WorkflowNodePanelService);
+  const document = ctx.document;
+  const dragService = ctx.get(WorkflowDragService);
+  const linesManager = ctx.get(WorkflowLinesManager);
+
+  // get params from drag event - 从拖拽事件获取参数
+  const { fromPort, toPort, mousePos, line, originLine } = params;
+
+  // return if invalid line state - 如果线条状态无效则返回
+  if (originLine || !line) {
+    return;
+  }
+
+  // return if target port exists - 如果目标端口存在则返回
+  if (toPort) {
+    return;
+  }
+
+  // get container node for the new node - 获取新节点的容器节点
+  const containerNode = WorkflowNodePanelUtils.getContainerNode({
+    fromPort,
+  });
+
+  // open node selection panel - 打开节点选择面板
+  const result = await nodePanelService.singleSelectNodePanel({
+    position: mousePos,
+    containerNode,
+    panelProps: {
+      enableNodePlaceholder: true,
+      enableScrollClose: true,
+    },
+  });
+
+  // return if no node selected - 如果没有选择节点则返回
+  if (!result) {
+    return;
+  }
+
+  // get selected node type and data - 获取选择的节点类型和数据
+  const { nodeType, nodeJSON } = result;
+
+  // calculate position for the new node - 计算新节点的位置
+  const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
+    nodeType,
+    position: mousePos,
+    fromPort,
+    toPort,
+    containerNode,
+    document,
+    dragService,
+  });
+
+  // create new workflow node - 创建新的工作流节点
+  const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
+    nodeType,
+    nodePosition,
+    nodeJSON ?? ({} as WorkflowNodeJSON),
+    containerNode?.id
+  );
+
+  // wait for node render - 等待节点渲染
+  await delay(20);
+
+  // build connection line - 构建连接线
+  WorkflowNodePanelUtils.buildLine({
+    fromPort,
+    node,
+    toPort,
+    linesManager,
+  });
+};

+ 1 - 1
packages/plugins/free-node-panel-plugin/src/component.tsx

@@ -8,7 +8,7 @@ interface NodePanelContainerProps {
   children: ReactNode;
 }
 
-export const NodePanelContainer: FC<NodePanelContainerProps> = props => {
+export const NodePanelContainer: FC<NodePanelContainerProps> = (props) => {
   const { onSelect, position, children } = props;
   const panelRef = useRef<HTMLDivElement>(null);
 

+ 1 - 5
packages/plugins/free-node-panel-plugin/src/create-plugin.ts

@@ -1,8 +1,4 @@
-import {
-  definePluginCreator,
-  type PluginBindConfig,
-  type PluginContext,
-} from '@flowgram.ai/core';
+import { definePluginCreator, type PluginBindConfig, type PluginContext } from '@flowgram.ai/core';
 
 import { NodePanelPluginOptions } from './type';
 import { WorkflowNodePanelService } from './service';

+ 1 - 0
packages/plugins/free-node-panel-plugin/src/index.ts

@@ -7,3 +7,4 @@ export type {
   NodePanelLayerOptions as NodePanelServiceOptions,
   NodePanelPluginOptions,
 } from './type';
+export { type IWorkflowNodePanelUtils, WorkflowNodePanelUtils } from './utils';

+ 6 - 5
packages/plugins/free-node-panel-plugin/src/layer.tsx

@@ -2,9 +2,9 @@
 import React from 'react';
 
 import { inject } from 'inversify';
-import { Layer } from '@flowgram.ai/core';
-import { nanoid } from '@flowgram.ai/free-layout-core';
 import { domUtils } from '@flowgram.ai/utils';
+import { nanoid } from '@flowgram.ai/free-layout-core';
+import { Layer } from '@flowgram.ai/core';
 
 import type {
   CallNodePanelParams,
@@ -42,7 +42,7 @@ export class WorkflowNodePanelLayer extends Layer<NodePanelLayerOptions> {
     const NodePanelRender = this.options.renderer;
     return (
       <>
-        {Array.from(this.renderList.keys()).map(taskId => {
+        {Array.from(this.renderList.keys()).map((taskId) => {
           const renderProps = this.renderList.get(taskId)!;
           return <NodePanelRender key={taskId} {...renderProps} />;
         })}
@@ -52,8 +52,8 @@ export class WorkflowNodePanelLayer extends Layer<NodePanelLayerOptions> {
 
   private async call(params: CallNodePanelParams): Promise<void> {
     const taskId = nanoid();
-    const { enableMultiAdd, onSelect, onClose } = params;
-    return new Promise(resolve => {
+    const { onSelect, onClose, enableMultiAdd = false, panelProps = {} } = params;
+    return new Promise((resolve) => {
       const unmount = () => {
         // 清理挂载的组件
         this.renderList.delete(taskId);
@@ -72,6 +72,7 @@ export class WorkflowNodePanelLayer extends Layer<NodePanelLayerOptions> {
       };
       const renderProps: NodePanelRenderProps = {
         ...params,
+        panelProps,
         onSelect: handleSelect,
         onClose: handleClose,
       };

+ 44 - 344
packages/plugins/free-node-panel-plugin/src/service.ts

@@ -1,24 +1,24 @@
 import { inject, injectable } from 'inversify';
-import { delay, DisposableCollection, Rectangle } from '@flowgram.ai/utils';
-import type { IPoint, PositionSchema } from '@flowgram.ai/utils';
+import { DisposableCollection } from '@flowgram.ai/utils';
+import type { PositionSchema } from '@flowgram.ai/utils';
 import {
   WorkflowDocument,
   WorkflowDragService,
   WorkflowLinesManager,
-  WorkflowPortEntity,
-  WorkflowNodePortsData,
   WorkflowNodeEntity,
-  WorkflowNodeMeta,
 } from '@flowgram.ai/free-layout-core';
 import { WorkflowSelectService } from '@flowgram.ai/free-layout-core';
 import { WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
-import { FreeOperationType, HistoryService } from '@flowgram.ai/free-history-plugin';
-import { FlowNodeTransformData } from '@flowgram.ai/document';
+import { HistoryService } from '@flowgram.ai/free-history-plugin';
 import { PlaygroundConfigEntity } from '@flowgram.ai/core';
-import { TransformData } from '@flowgram.ai/core';
 
-import { isGreaterThan, isLessThan } from './utils';
-import type { CallNodePanel, NodePanelCallParams, NodePanelResult } from './type';
+import { WorkflowNodePanelUtils } from './utils';
+import type {
+  CallNodePanel,
+  CallNodePanelParams,
+  NodePanelCallParams,
+  NodePanelResult,
+} from './type';
 
 /**
  * 添加节点面板服务
@@ -43,7 +43,7 @@ export class WorkflowNodePanelService {
 
   private readonly toDispose = new DisposableCollection();
 
-  private callNodePanel: CallNodePanel = async () => undefined;
+  public callNodePanel: CallNodePanel = async () => undefined;
 
   /** 销毁 */
   public dispose(): void {
@@ -78,7 +78,7 @@ export class WorkflowNodePanelService {
         position: panelPosition,
         enableMultiAdd,
         panelProps,
-        containerNode: this.getContainerNode({
+        containerNode: WorkflowNodePanelUtils.getContainerNode({
           fromPort,
           containerNode,
         }),
@@ -98,6 +98,26 @@ export class WorkflowNodePanelService {
     });
   }
 
+  /**
+   * 唤起单选面板
+   */
+  public async singleSelectNodePanel(
+    params: Omit<CallNodePanelParams, 'onSelect' | 'onClose' | 'enableMultiAdd'>
+  ): Promise<NodePanelResult | undefined> {
+    return new Promise((resolve) => {
+      this.callNodePanel({
+        ...params,
+        enableMultiAdd: false,
+        onSelect: async (panelParams?: NodePanelResult) => {
+          resolve(panelParams);
+        },
+        onClose: () => {
+          resolve(undefined);
+        },
+      });
+    });
+  }
+
   /** 添加节点 */
   private async addNode(
     callParams: NodePanelCallParams,
@@ -124,7 +144,7 @@ export class WorkflowNodePanelService {
 
     const { nodeType, selectEvent, nodeJSON } = panelParams;
 
-    const containerNode = this.getContainerNode({
+    const containerNode = WorkflowNodePanelUtils.getContainerNode({
       fromPort,
       containerNode: callParams.containerNode,
     });
@@ -143,16 +163,18 @@ export class WorkflowNodePanelService {
     // 自定义坐标
     const nodePosition: PositionSchema = callParams.customPosition
       ? callParams.customPosition({ nodeType, selectPosition })
-      : this.adjustNodePosition({
+      : WorkflowNodePanelUtils.adjustNodePosition({
           nodeType,
           position: enableSelectPosition ? selectPosition : panelPosition,
           fromPort,
           toPort,
           containerNode,
+          document: this.document,
+          dragService: this.dragService,
         });
 
     // 创建节点
-    const node: WorkflowNodeEntity = await this.document.createWorkflowNodeByType(
+    const node: WorkflowNodeEntity = this.document.createWorkflowNodeByType(
       nodeType,
       nodePosition,
       nodeJSON ?? ({} as WorkflowNodeJSON),
@@ -165,20 +187,15 @@ export class WorkflowNodePanelService {
 
     // 后续节点偏移
     if (enableAutoOffset && fromPort && toPort) {
-      const subOffset = this.subPositionOffset({
+      WorkflowNodePanelUtils.subNodesAutoOffset({
         node,
         fromPort,
         toPort,
         padding: autoOffsetPadding,
-      });
-      const subsequentNodes = this.getSubsequentNodes(toPort.node);
-      this.updateSubSequentNodesPosition({
-        node,
-        subsequentNodes,
-        fromPort,
-        toPort,
         containerNode,
-        offset: subOffset,
+        historyService: this.historyService,
+        dragService: this.dragService,
+        linesManager: this.linesManager,
       });
     }
 
@@ -187,14 +204,15 @@ export class WorkflowNodePanelService {
     }
 
     // 等待节点渲染
-    await delay(20);
+    await WorkflowNodePanelUtils.waitNodeRender();
 
     // 重建连线(需先让端口完成渲染)
     if (enableBuildLine) {
-      this.buildLine({
+      WorkflowNodePanelUtils.buildLine({
         fromPort,
         node,
         toPort,
+        linesManager: this.linesManager,
       });
     }
 
@@ -206,322 +224,4 @@ export class WorkflowNodePanelService {
 
     return node;
   }
-
-  /** 建立连线 */
-  private buildLine(params: {
-    node: WorkflowNodeEntity;
-    fromPort?: WorkflowPortEntity;
-    toPort?: WorkflowPortEntity;
-  }): void {
-    const { fromPort, node, toPort } = params;
-    const portsData = node.getData(WorkflowNodePortsData);
-    if (!portsData) {
-      return;
-    }
-
-    const shouldBuildFromLine = portsData.inputPorts?.length > 0;
-    if (fromPort && shouldBuildFromLine) {
-      const toTargetPort = portsData.inputPorts[0];
-      const isSingleInput = portsData.inputPorts.length === 1;
-      this.linesManager.createLine({
-        from: fromPort.node.id,
-        fromPort: fromPort.portID,
-        to: node.id,
-        toPort: isSingleInput ? undefined : toTargetPort.id,
-      });
-    }
-    const shouldBuildToLine = portsData.outputPorts?.length > 0;
-    if (toPort && shouldBuildToLine) {
-      const fromTargetPort = portsData.outputPorts[0];
-      this.linesManager.createLine({
-        from: node.id,
-        fromPort: fromTargetPort.portID,
-        to: toPort.node.id,
-        toPort: toPort.portID,
-      });
-    }
-  }
-
-  /** 调整节点坐标 */
-  private adjustNodePosition(params: {
-    nodeType: string;
-    position: PositionSchema;
-    fromPort?: WorkflowPortEntity;
-    toPort?: WorkflowPortEntity;
-    containerNode?: WorkflowNodeEntity;
-  }): PositionSchema {
-    const { nodeType, position, fromPort, toPort, containerNode } = params;
-    const register = this.document.getNodeRegistry(nodeType);
-    const size = register?.meta?.size;
-    let adjustedPosition = position;
-    if (!size) {
-      adjustedPosition = position;
-    }
-    // 计算坐标偏移
-    else if (fromPort && toPort) {
-      // 输入输出
-      adjustedPosition = {
-        x: position.x,
-        y: position.y - size.height / 2,
-      };
-    } else if (fromPort && !toPort) {
-      // 仅输入
-      adjustedPosition = {
-        x: position.x + size.width / 2,
-        y: position.y - size.height / 2,
-      };
-    } else if (!fromPort && toPort) {
-      // 仅输出
-      adjustedPosition = {
-        x: position.x - size.width / 2,
-        y: position.y - size.height / 2,
-      };
-    } else {
-      adjustedPosition = position;
-    }
-    return this.dragService.adjustSubNodePosition(nodeType, containerNode, adjustedPosition);
-  }
-
-  private getContainerNode(params: {
-    containerNode?: WorkflowNodeEntity;
-    fromPort?: WorkflowPortEntity;
-    toPort?: WorkflowPortEntity;
-  }): WorkflowNodeEntity | undefined {
-    const { fromPort, containerNode } = params;
-    if (containerNode) {
-      return containerNode;
-    }
-    const fromNode = fromPort?.node;
-    const fromContainer = fromNode?.parent;
-    if (this.isContainer(fromNode)) {
-      // 子画布内部输入连线
-      return fromNode;
-    }
-    return fromContainer;
-  }
-
-  /** 获取端口矩形 */
-  private getPortBox(port: WorkflowPortEntity, offset: IPoint = { x: 0, y: 0 }): Rectangle {
-    const node = port.node;
-    if (this.isContainer(node)) {
-      // 子画布内部端口需要虚拟节点
-      const { point } = port;
-      if (port.portType === 'input') {
-        return new Rectangle(point.x + offset.x, point.y - 50 + offset.y, 300, 100);
-      }
-      return new Rectangle(point.x - 300, point.y - 50, 300, 100);
-    }
-    const box = node.getData(FlowNodeTransformData).bounds;
-    return box;
-  }
-
-  /** 后续节点位置偏移 */
-  private subPositionOffset(params: {
-    node: WorkflowNodeEntity;
-    fromPort: WorkflowPortEntity;
-    toPort: WorkflowPortEntity;
-    padding: {
-      x: number;
-      y: number;
-    };
-  }):
-    | {
-        x: number;
-        y: number;
-      }
-    | undefined {
-    const { node, fromPort, toPort, padding } = params;
-
-    const fromBox = this.getPortBox(fromPort);
-    const toBox = this.getPortBox(toPort);
-
-    const nodeTrans = node.getData(FlowNodeTransformData);
-    const nodeSize = node.getNodeMeta()?.size ?? {
-      width: nodeTrans.bounds.width,
-      height: nodeTrans.bounds.height,
-    };
-
-    // 最小距离
-    const minDistance: IPoint = {
-      x: nodeSize.width + padding.x,
-      y: nodeSize.height + padding.y,
-    };
-    // from 与 to 的距离
-    const boxDistance = this.rectDistance(fromBox, toBox);
-
-    // 需要的偏移量
-    const neededOffset: IPoint = {
-      x: isGreaterThan(boxDistance.x, minDistance.x) ? 0 : minDistance.x - boxDistance.x,
-      y: isGreaterThan(boxDistance.y, minDistance.y) ? 0 : minDistance.y - boxDistance.y,
-    };
-
-    // 至少有一个方向满足要求,无需偏移
-    if (neededOffset.x === 0 || neededOffset.y === 0) {
-      return;
-    }
-
-    // 是否存在相交
-    const intersection = {
-      // 这里没有写反,Rectangle内置的算法是反的
-      vertical: Rectangle.intersects(fromBox, toBox, 'horizontal'),
-      horizontal: Rectangle.intersects(fromBox, toBox, 'vertical'),
-    };
-
-    // 初始化偏移量
-    let offsetX: number = 0;
-    let offsetY: number = 0;
-
-    if (!intersection.horizontal) {
-      // 水平不相交,需要加垂直方向的偏移
-      if (isGreaterThan(toBox.center.y, fromBox.center.y)) {
-        // B在A下方
-        offsetY = neededOffset.y;
-      } else if (isLessThan(toBox.center.y, fromBox.center.y)) {
-        // B在A上方
-        offsetY = -neededOffset.y;
-      }
-    }
-
-    if (!intersection.vertical) {
-      // 垂直不相交,需要加水平方向的偏移
-      if (isGreaterThan(toBox.center.x, fromBox.center.x)) {
-        // B在A右侧
-        offsetX = neededOffset.x;
-      } else if (isLessThan(toBox.center.x, fromBox.center.x)) {
-        // B在A左侧
-        offsetX = -neededOffset.x;
-      }
-    }
-
-    return {
-      x: offsetX,
-      y: offsetY,
-    };
-  }
-
-  /** 矩形间距 */
-  private rectDistance(rectA: Rectangle, rectB: Rectangle): IPoint {
-    // 计算 x 轴距离
-    const distanceX = Math.abs(
-      Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left)
-    );
-    // 计算 y 轴距离
-    const distanceY = Math.abs(
-      Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top)
-    );
-    if (Rectangle.intersects(rectA, rectB)) {
-      // 相交距离为负
-      return {
-        x: -distanceX,
-        y: -distanceY,
-      };
-    }
-    return {
-      x: distanceX,
-      y: distanceY,
-    };
-  }
-
-  /** 获取后续节点 */
-  private getSubsequentNodes(node: WorkflowNodeEntity): WorkflowNodeEntity[] {
-    if (this.isContainer(node)) {
-      return [];
-    }
-    const brothers = node.parent?.collapsedChildren ?? [];
-    const linkedBrothers = new Set();
-    const linesMap = new Map<string, string[]>();
-    this.linesManager.getAllLines().forEach((line) => {
-      if (!linesMap.has(line.from.id)) {
-        linesMap.set(line.from.id, []);
-      }
-      if (
-        !line.to?.id ||
-        this.isContainer(line.to) // 子画布内部成环
-      ) {
-        return;
-      }
-      linesMap.get(line.from.id)?.push(line.to.id);
-    });
-
-    const bfs = (nodeId: string) => {
-      if (linkedBrothers.has(nodeId)) {
-        return;
-      }
-      linkedBrothers.add(nodeId);
-      const nextNodes = linesMap.get(nodeId) ?? [];
-      nextNodes.forEach(bfs);
-    };
-
-    bfs(node.id);
-
-    const subsequentNodes = brothers.filter((node) => linkedBrothers.has(node.id));
-    return subsequentNodes;
-  }
-
-  /** 更新后续节点位置 */
-  private updateSubSequentNodesPosition(params: {
-    node: WorkflowNodeEntity;
-    subsequentNodes: WorkflowNodeEntity[];
-    fromPort: WorkflowPortEntity;
-    toPort: WorkflowPortEntity;
-    containerNode?: WorkflowNodeEntity;
-    offset?: IPoint;
-  }): void {
-    const { node, subsequentNodes, fromPort, toPort, containerNode, offset } = params;
-    if (!offset || !toPort) {
-      return;
-    }
-    // 更新后续节点位置
-    const subsequentNodesPositions = subsequentNodes.map((node) => {
-      const nodeTrans = node.getData(TransformData);
-      return {
-        x: nodeTrans.position.x,
-        y: nodeTrans.position.y,
-      };
-    });
-    this.historyService.pushOperation({
-      type: FreeOperationType.dragNodes,
-      value: {
-        ids: subsequentNodes.map((node) => node.id),
-        value: subsequentNodesPositions.map((position) => ({
-          x: position.x + offset.x,
-          y: position.y + offset.y,
-        })),
-        oldValue: subsequentNodesPositions,
-      },
-    });
-    // 新增节点坐标需重新计算
-    const fromBox = this.getPortBox(fromPort);
-    const toBox = this.getPortBox(toPort, offset);
-    const nodeTrans = node.getData(TransformData);
-    let nodePos: PositionSchema = {
-      x: (fromBox.center.x + toBox.center.x) / 2,
-      y: (fromBox.y + toBox.y) / 2,
-    };
-    if (containerNode) {
-      nodePos = this.dragService.adjustSubNodePosition(
-        node.flowNodeType as string,
-        containerNode,
-        nodePos
-      );
-    }
-    this.historyService.pushOperation({
-      type: FreeOperationType.dragNodes,
-      value: {
-        ids: [node.id],
-        value: [nodePos],
-        oldValue: [
-          {
-            x: nodeTrans.position.x,
-            y: nodeTrans.position.y,
-          },
-        ],
-      },
-    });
-  }
-
-  /** 是否容器节点 */
-  private isContainer(node?: WorkflowNodeEntity): boolean {
-    return node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
-  }
 }

+ 3 - 3
packages/plugins/free-node-panel-plugin/src/type.ts

@@ -1,8 +1,8 @@
 import type React from 'react';
 
+import type { PositionSchema } from '@flowgram.ai/utils';
 import type { WorkflowNodeEntity, WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
 import type { WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
-import type { PositionSchema } from '@flowgram.ai/utils';
 
 export interface NodePanelCallParams {
   /** 唤起节点面板的位置 */
@@ -47,8 +47,8 @@ export interface CallNodePanelParams {
   onSelect: (params?: NodePanelResult) => void;
   position: PositionSchema;
   onClose: () => void;
-  enableMultiAdd: boolean;
-  panelProps: Record<string, any>;
+  panelProps?: Record<string, any>;
+  enableMultiAdd?: boolean;
   containerNode?: WorkflowNodeEntity;
 }
 

+ 51 - 0
packages/plugins/free-node-panel-plugin/src/utils/adjust-node-position.ts

@@ -0,0 +1,51 @@
+import { PositionSchema } from '@flowgram.ai/utils';
+import {
+  WorkflowDocument,
+  WorkflowDragService,
+  WorkflowNodeEntity,
+  WorkflowPortEntity,
+} from '@flowgram.ai/free-layout-core';
+
+export type IAdjustNodePosition = (params: {
+  nodeType: string;
+  position: PositionSchema;
+  document: WorkflowDocument;
+  dragService: WorkflowDragService;
+  fromPort?: WorkflowPortEntity;
+  toPort?: WorkflowPortEntity;
+  containerNode?: WorkflowNodeEntity;
+}) => PositionSchema;
+
+/** 调整节点坐标 */
+export const adjustNodePosition: IAdjustNodePosition = (params) => {
+  const { nodeType, position, fromPort, toPort, containerNode, document, dragService } = params;
+  const register = document.getNodeRegistry(nodeType);
+  const size = register?.meta?.size;
+  let adjustedPosition = position;
+  if (!size) {
+    adjustedPosition = position;
+  }
+  // 计算坐标偏移
+  else if (fromPort && toPort) {
+    // 输入输出
+    adjustedPosition = {
+      x: position.x,
+      y: position.y - size.height / 2,
+    };
+  } else if (fromPort && !toPort) {
+    // 仅输入
+    adjustedPosition = {
+      x: position.x + size.width / 2,
+      y: position.y - size.height / 2,
+    };
+  } else if (!fromPort && toPort) {
+    // 仅输出
+    adjustedPosition = {
+      x: position.x - size.width / 2,
+      y: position.y - size.height / 2,
+    };
+  } else {
+    adjustedPosition = position;
+  }
+  return dragService.adjustSubNodePosition(nodeType, containerNode, adjustedPosition);
+};

+ 44 - 0
packages/plugins/free-node-panel-plugin/src/utils/build-line.ts

@@ -0,0 +1,44 @@
+import {
+  WorkflowLinesManager,
+  WorkflowNodeEntity,
+  WorkflowNodePortsData,
+  WorkflowPortEntity,
+} from '@flowgram.ai/free-layout-core';
+
+export type IBuildLine = (params: {
+  node: WorkflowNodeEntity;
+  linesManager: WorkflowLinesManager;
+  fromPort?: WorkflowPortEntity;
+  toPort?: WorkflowPortEntity;
+}) => void;
+
+/** 建立连线 */
+export const buildLine: IBuildLine = (params) => {
+  const { fromPort, node, toPort, linesManager } = params;
+  const portsData = node.getData(WorkflowNodePortsData);
+  if (!portsData) {
+    return;
+  }
+
+  const shouldBuildFromLine = portsData.inputPorts?.length > 0;
+  if (fromPort && shouldBuildFromLine) {
+    const toTargetPort = portsData.inputPorts[0];
+    const isSingleInput = portsData.inputPorts.length === 1;
+    linesManager.createLine({
+      from: fromPort.node.id,
+      fromPort: fromPort.portID,
+      to: node.id,
+      toPort: isSingleInput ? undefined : toTargetPort.id,
+    });
+  }
+  const shouldBuildToLine = portsData.outputPorts?.length > 0;
+  if (toPort && shouldBuildToLine) {
+    const fromTargetPort = portsData.outputPorts[0];
+    linesManager.createLine({
+      from: node.id,
+      fromPort: fromTargetPort.portID,
+      to: toPort.node.id,
+      toPort: toPort.portID,
+    });
+  }
+};

+ 23 - 0
packages/plugins/free-node-panel-plugin/src/utils/get-container-node.ts

@@ -0,0 +1,23 @@
+import { WorkflowNodeEntity, WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
+
+import { isContainer } from './is-container';
+
+export type IGetContainerNode = (params: {
+  containerNode?: WorkflowNodeEntity;
+  fromPort?: WorkflowPortEntity;
+  toPort?: WorkflowPortEntity;
+}) => WorkflowNodeEntity | undefined;
+
+export const getContainerNode: IGetContainerNode = (params) => {
+  const { fromPort, containerNode } = params;
+  if (containerNode) {
+    return containerNode;
+  }
+  const fromNode = fromPort?.node;
+  const fromContainer = fromNode?.parent;
+  if (isContainer(fromNode)) {
+    // 子画布内部输入连线
+    return fromNode;
+  }
+  return fromContainer;
+};

+ 25 - 0
packages/plugins/free-node-panel-plugin/src/utils/get-port-box.ts

@@ -0,0 +1,25 @@
+import { IPoint, Rectangle } from '@flowgram.ai/utils';
+import { WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
+import { FlowNodeTransformData } from '@flowgram.ai/document';
+
+import { isContainer } from './is-container';
+
+export type IGetPortBox = (port: WorkflowPortEntity, offset?: IPoint) => Rectangle;
+
+/** 获取端口矩形 */
+export const getPortBox: IGetPortBox = (
+  port: WorkflowPortEntity,
+  offset: IPoint = { x: 0, y: 0 }
+): Rectangle => {
+  const node = port.node;
+  if (isContainer(node)) {
+    // 子画布内部端口需要虚拟节点
+    const { point } = port;
+    if (port.portType === 'input') {
+      return new Rectangle(point.x + offset.x, point.y - 50 + offset.y, 300, 100);
+    }
+    return new Rectangle(point.x - 300, point.y - 50, 300, 100);
+  }
+  const box = node.getData(FlowNodeTransformData).bounds;
+  return box;
+};

+ 45 - 0
packages/plugins/free-node-panel-plugin/src/utils/get-sub-nodes.ts

@@ -0,0 +1,45 @@
+import { WorkflowNodeEntity, WorkflowLinesManager } from '@flowgram.ai/free-layout-core';
+
+import { isContainer } from './is-container';
+
+export type IGetSubsequentNodes = (params: {
+  node: WorkflowNodeEntity;
+  linesManager: WorkflowLinesManager;
+}) => WorkflowNodeEntity[];
+
+/** 获取后续节点 */
+export const getSubsequentNodes: IGetSubsequentNodes = (params) => {
+  const { node, linesManager } = params;
+  if (isContainer(node)) {
+    return [];
+  }
+  const brothers = node.parent?.blocks ?? [];
+  const linkedBrothers = new Set();
+  const linesMap = new Map<string, string[]>();
+  linesManager.getAllLines().forEach((line) => {
+    if (!linesMap.has(line.from.id)) {
+      linesMap.set(line.from.id, []);
+    }
+    if (
+      !line.to?.id ||
+      isContainer(line.to) // 子画布内部成环
+    ) {
+      return;
+    }
+    linesMap.get(line.from.id)?.push(line.to.id);
+  });
+
+  const bfs = (nodeId: string) => {
+    if (linkedBrothers.has(nodeId)) {
+      return;
+    }
+    linkedBrothers.add(nodeId);
+    const nextNodes = linesMap.get(nodeId) ?? [];
+    nextNodes.forEach(bfs);
+  };
+
+  bfs(node.id);
+
+  const subsequentNodes = brothers.filter((node) => linkedBrothers.has(node.id));
+  return subsequentNodes;
+};

+ 0 - 0
packages/plugins/free-node-panel-plugin/src/utils.ts → packages/plugins/free-node-panel-plugin/src/utils/greater-or-less.ts


+ 39 - 0
packages/plugins/free-node-panel-plugin/src/utils/index.ts

@@ -0,0 +1,39 @@
+import { IWaitNodeRender, waitNodeRender } from './wait-node-render';
+import {
+  updateSubSequentNodesPosition,
+  IUpdateSubSequentNodesPosition,
+} from './update-sub-nodes-position';
+import { subPositionOffset, ISubPositionOffset } from './sub-position-offset';
+import { subNodesAutoOffset, ISubNodesAutoOffset } from './sub-nodes-auto-offset';
+import { rectDistance, IRectDistance } from './rect-distance';
+import { getSubsequentNodes, IGetSubsequentNodes } from './get-sub-nodes';
+import { getPortBox, IGetPortBox } from './get-port-box';
+import { getContainerNode, IGetContainerNode } from './get-container-node';
+import { buildLine, IBuildLine } from './build-line';
+import { adjustNodePosition, IAdjustNodePosition } from './adjust-node-position';
+
+export interface IWorkflowNodePanelUtils {
+  adjustNodePosition: IAdjustNodePosition;
+  buildLine: IBuildLine;
+  getPortBox: IGetPortBox;
+  getSubsequentNodes: IGetSubsequentNodes;
+  getContainerNode: IGetContainerNode;
+  rectDistance: IRectDistance;
+  subNodesAutoOffset: ISubNodesAutoOffset;
+  subPositionOffset: ISubPositionOffset;
+  updateSubSequentNodesPosition: IUpdateSubSequentNodesPosition;
+  waitNodeRender: IWaitNodeRender;
+}
+
+export const WorkflowNodePanelUtils: IWorkflowNodePanelUtils = {
+  adjustNodePosition,
+  buildLine,
+  getPortBox,
+  getSubsequentNodes,
+  getContainerNode,
+  rectDistance,
+  subNodesAutoOffset,
+  subPositionOffset,
+  updateSubSequentNodesPosition,
+  waitNodeRender,
+};

+ 5 - 0
packages/plugins/free-node-panel-plugin/src/utils/is-container.ts

@@ -0,0 +1,5 @@
+import { WorkflowNodeEntity, WorkflowNodeMeta } from '@flowgram.ai/free-layout-core';
+
+/** 是否容器节点 */
+export const isContainer = (node?: WorkflowNodeEntity): boolean =>
+  node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;

+ 22 - 0
packages/plugins/free-node-panel-plugin/src/utils/rect-distance.ts

@@ -0,0 +1,22 @@
+import { Rectangle, IPoint } from '@flowgram.ai/utils';
+
+export type IRectDistance = (rectA: Rectangle, rectB: Rectangle) => IPoint;
+
+/** 矩形间距 */
+export const rectDistance: IRectDistance = (rectA, rectB) => {
+  // 计算 x 轴距离
+  const distanceX = Math.abs(Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left));
+  // 计算 y 轴距离
+  const distanceY = Math.abs(Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top));
+  if (Rectangle.intersects(rectA, rectB)) {
+    // 相交距离为负
+    return {
+      x: -distanceX,
+      y: -distanceY,
+    };
+  }
+  return {
+    x: distanceX,
+    y: distanceY,
+  };
+};

+ 58 - 0
packages/plugins/free-node-panel-plugin/src/utils/sub-nodes-auto-offset.ts

@@ -0,0 +1,58 @@
+import {
+  WorkflowDragService,
+  WorkflowLinesManager,
+  WorkflowNodeEntity,
+  WorkflowPortEntity,
+} from '@flowgram.ai/free-layout-core';
+import { HistoryService } from '@flowgram.ai/free-history-plugin';
+
+import { updateSubSequentNodesPosition } from './update-sub-nodes-position';
+import { subPositionOffset, XYSchema } from './sub-position-offset';
+import { getSubsequentNodes } from './get-sub-nodes';
+
+export type ISubNodesAutoOffset = (params: {
+  node: WorkflowNodeEntity;
+  fromPort: WorkflowPortEntity;
+  toPort: WorkflowPortEntity;
+  padding?: XYSchema;
+  linesManager: WorkflowLinesManager;
+  historyService: HistoryService;
+  dragService: WorkflowDragService;
+  containerNode?: WorkflowNodeEntity;
+}) => void;
+
+export const subNodesAutoOffset: ISubNodesAutoOffset = (params) => {
+  const {
+    node,
+    fromPort,
+    toPort,
+    linesManager,
+    historyService,
+    dragService,
+    containerNode,
+    padding = {
+      x: 100,
+      y: 100,
+    },
+  } = params;
+  const subOffset = subPositionOffset({
+    node,
+    fromPort,
+    toPort,
+    padding,
+  });
+  const subsequentNodes = getSubsequentNodes({
+    node: toPort.node,
+    linesManager,
+  });
+  updateSubSequentNodesPosition({
+    node,
+    subsequentNodes,
+    fromPort,
+    toPort,
+    containerNode,
+    offset: subOffset,
+    historyService,
+    dragService,
+  });
+};

+ 90 - 0
packages/plugins/free-node-panel-plugin/src/utils/sub-position-offset.ts

@@ -0,0 +1,90 @@
+import { IPoint, Rectangle } from '@flowgram.ai/utils';
+import { WorkflowNodeEntity, WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
+import { FlowNodeTransformData } from '@flowgram.ai/document';
+
+import { rectDistance } from './rect-distance';
+import { isGreaterThan, isLessThan } from './greater-or-less';
+import { getPortBox } from './get-port-box';
+
+export interface XYSchema {
+  x: number;
+  y: number;
+}
+
+export type ISubPositionOffset = (params: {
+  node: WorkflowNodeEntity;
+  fromPort: WorkflowPortEntity;
+  toPort: WorkflowPortEntity;
+  padding: XYSchema;
+}) => XYSchema | undefined;
+
+/** 后续节点位置偏移 */
+export const subPositionOffset: ISubPositionOffset = (params) => {
+  const { node, fromPort, toPort, padding } = params;
+
+  const fromBox = getPortBox(fromPort);
+  const toBox = getPortBox(toPort);
+
+  const nodeTrans = node.getData(FlowNodeTransformData);
+  const nodeSize = node.getNodeMeta()?.size ?? {
+    width: nodeTrans.bounds.width,
+    height: nodeTrans.bounds.height,
+  };
+
+  // 最小距离
+  const minDistance: IPoint = {
+    x: nodeSize.width + padding.x,
+    y: nodeSize.height + padding.y,
+  };
+  // from 与 to 的距离
+  const boxDistance = rectDistance(fromBox, toBox);
+
+  // 需要的偏移量
+  const neededOffset: IPoint = {
+    x: isGreaterThan(boxDistance.x, minDistance.x) ? 0 : minDistance.x - boxDistance.x,
+    y: isGreaterThan(boxDistance.y, minDistance.y) ? 0 : minDistance.y - boxDistance.y,
+  };
+
+  // 至少有一个方向满足要求,无需偏移
+  if (neededOffset.x === 0 || neededOffset.y === 0) {
+    return;
+  }
+
+  // 是否存在相交
+  const intersection = {
+    // 这里没有写反,Rectangle内置的算法是反的
+    vertical: Rectangle.intersects(fromBox, toBox, 'horizontal'),
+    horizontal: Rectangle.intersects(fromBox, toBox, 'vertical'),
+  };
+
+  // 初始化偏移量
+  let offsetX: number = 0;
+  let offsetY: number = 0;
+
+  if (!intersection.horizontal) {
+    // 水平不相交,需要加垂直方向的偏移
+    if (isGreaterThan(toBox.center.y, fromBox.center.y)) {
+      // B在A下方
+      offsetY = neededOffset.y;
+    } else if (isLessThan(toBox.center.y, fromBox.center.y)) {
+      // B在A上方
+      offsetY = -neededOffset.y;
+    }
+  }
+
+  if (!intersection.vertical) {
+    // 垂直不相交,需要加水平方向的偏移
+    if (isGreaterThan(toBox.center.x, fromBox.center.x)) {
+      // B在A右侧
+      offsetX = neededOffset.x;
+    } else if (isLessThan(toBox.center.x, fromBox.center.x)) {
+      // B在A左侧
+      offsetX = -neededOffset.x;
+    }
+  }
+
+  return {
+    x: offsetX,
+    y: offsetY,
+  };
+};

+ 85 - 0
packages/plugins/free-node-panel-plugin/src/utils/update-sub-nodes-position.ts

@@ -0,0 +1,85 @@
+import { IPoint, PositionSchema } from '@flowgram.ai/utils';
+import {
+  WorkflowNodeEntity,
+  WorkflowPortEntity,
+  WorkflowDragService,
+} from '@flowgram.ai/free-layout-core';
+import { FreeOperationType, HistoryService } from '@flowgram.ai/free-history-plugin';
+import { TransformData } from '@flowgram.ai/core';
+
+import { getPortBox } from './get-port-box';
+
+export type IUpdateSubSequentNodesPosition = (params: {
+  node: WorkflowNodeEntity;
+  subsequentNodes: WorkflowNodeEntity[];
+  fromPort: WorkflowPortEntity;
+  toPort: WorkflowPortEntity;
+  historyService: HistoryService;
+  dragService: WorkflowDragService;
+  containerNode?: WorkflowNodeEntity;
+  offset?: IPoint;
+}) => void;
+
+/** 更新后续节点位置 */
+export const updateSubSequentNodesPosition: IUpdateSubSequentNodesPosition = (params) => {
+  const {
+    node,
+    subsequentNodes,
+    fromPort,
+    toPort,
+    containerNode,
+    offset,
+    historyService,
+    dragService,
+  } = params;
+  if (!offset || !toPort) {
+    return;
+  }
+  // 更新后续节点位置
+  const subsequentNodesPositions = subsequentNodes.map((node) => {
+    const nodeTrans = node.getData(TransformData);
+    return {
+      x: nodeTrans.position.x,
+      y: nodeTrans.position.y,
+    };
+  });
+  historyService.pushOperation({
+    type: FreeOperationType.dragNodes,
+    value: {
+      ids: subsequentNodes.map((node) => node.id),
+      value: subsequentNodesPositions.map((position) => ({
+        x: position.x + offset.x,
+        y: position.y + offset.y,
+      })),
+      oldValue: subsequentNodesPositions,
+    },
+  });
+  // 新增节点坐标需重新计算
+  const fromBox = getPortBox(fromPort);
+  const toBox = getPortBox(toPort, offset);
+  const nodeTrans = node.getData(TransformData);
+  let nodePos: PositionSchema = {
+    x: (fromBox.center.x + toBox.center.x) / 2,
+    y: (fromBox.y + toBox.y) / 2,
+  };
+  if (containerNode) {
+    nodePos = dragService.adjustSubNodePosition(
+      node.flowNodeType as string,
+      containerNode,
+      nodePos
+    );
+  }
+  historyService.pushOperation({
+    type: FreeOperationType.dragNodes,
+    value: {
+      ids: [node.id],
+      value: [nodePos],
+      oldValue: [
+        {
+          x: nodeTrans.position.x,
+          y: nodeTrans.position.y,
+        },
+      ],
+    },
+  });
+};

+ 7 - 0
packages/plugins/free-node-panel-plugin/src/utils/wait-node-render.ts

@@ -0,0 +1,7 @@
+import { delay } from '@flowgram.ai/free-layout-core';
+
+export type IWaitNodeRender = () => Promise<void>;
+
+export const waitNodeRender: IWaitNodeRender = async () => {
+  await delay(20);
+};