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

feat(demo): break & continue for loop container (#559)

* feat(demo): break & continue node

* feat(demo): add node into container

* feat(demo): limit container of break & continue
Louis Young 5 месяцев назад
Родитель
Сommit
38ec5e8e4d
27 измененных файлов с 467 добавлено и 146 удалено
  1. BIN
      apps/demo-free-layout/src/assets/icon-break.jpg
  2. BIN
      apps/demo-free-layout/src/assets/icon-continue.jpg
  3. 32 7
      apps/demo-free-layout/src/components/add-node/use-add-node.ts
  4. 2 2
      apps/demo-free-layout/src/components/node-panel/index.tsx
  5. 9 2
      apps/demo-free-layout/src/components/node-panel/node-list.tsx
  6. 13 1
      apps/demo-free-layout/src/hooks/use-editor-props.tsx
  7. 33 0
      apps/demo-free-layout/src/nodes/break/form-meta.tsx
  8. 44 0
      apps/demo-free-layout/src/nodes/break/index.ts
  9. 2 0
      apps/demo-free-layout/src/nodes/constants.ts
  10. 33 0
      apps/demo-free-layout/src/nodes/continue/form-meta.tsx
  11. 44 0
      apps/demo-free-layout/src/nodes/continue/index.ts
  12. 4 0
      apps/demo-free-layout/src/nodes/index.ts
  13. 2 2
      apps/demo-free-layout/src/nodes/loop/index.ts
  14. 31 5
      apps/demo-free-layout/src/plugins/context-menu-plugin/context-menu-layer.tsx
  15. 11 2
      apps/demo-free-layout/src/shortcuts/copy/index.ts
  16. 16 1
      apps/demo-free-layout/src/shortcuts/paste/index.ts
  17. 2 0
      apps/demo-free-layout/src/typings/node.ts
  18. 1 0
      packages/plugins/free-container-plugin/src/index.ts
  19. 20 124
      packages/plugins/free-container-plugin/src/node-into-container/service.ts
  20. 41 0
      packages/plugins/free-container-plugin/src/utils/adjust-sub-node-position.ts
  21. 41 0
      packages/plugins/free-container-plugin/src/utils/get-collision-transform.ts
  22. 27 0
      packages/plugins/free-container-plugin/src/utils/get-container-transforms.ts
  23. 18 0
      packages/plugins/free-container-plugin/src/utils/index.ts
  24. 9 0
      packages/plugins/free-container-plugin/src/utils/is-container.ts
  25. 9 0
      packages/plugins/free-container-plugin/src/utils/is-point-in-rect.ts
  26. 15 0
      packages/plugins/free-container-plugin/src/utils/is-rect-intersects.ts
  27. 8 0
      packages/plugins/free-container-plugin/src/utils/next-frame.ts

BIN
apps/demo-free-layout/src/assets/icon-break.jpg


BIN
apps/demo-free-layout/src/assets/icon-continue.jpg


+ 32 - 7
apps/demo-free-layout/src/components/add-node/use-add-node.ts

@@ -2,7 +2,6 @@
  * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
  * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
  * SPDX-License-Identifier: MIT
  * SPDX-License-Identifier: MIT
  */
  */
-
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
 import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
 import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
@@ -14,12 +13,13 @@ import {
   WorkflowNodeEntity,
   WorkflowNodeEntity,
   WorkflowSelectService,
   WorkflowSelectService,
   WorkflowNodeJSON,
   WorkflowNodeJSON,
+  getAntiOverlapPosition,
+  WorkflowNodeMeta,
+  FlowNodeBaseType,
 } from '@flowgram.ai/free-layout-editor';
 } from '@flowgram.ai/free-layout-editor';
-
 // hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook
 // hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook
 const useGetPanelPosition = () => {
 const useGetPanelPosition = () => {
   const playground = usePlayground();
   const playground = usePlayground();
-
   return useCallback(
   return useCallback(
     (targetBoundingRect: DOMRect): PositionSchema =>
     (targetBoundingRect: DOMRect): PositionSchema =>
       // convert mouse position to canvas position - 将鼠标位置转换为画布位置
       // convert mouse position to canvas position - 将鼠标位置转换为画布位置
@@ -30,7 +30,6 @@ const useGetPanelPosition = () => {
     [playground]
     [playground]
   );
   );
 };
 };
-
 // hook to handle node selection - 处理节点选择的 hook
 // hook to handle node selection - 处理节点选择的 hook
 const useSelectNode = () => {
 const useSelectNode = () => {
   const selectService = useService(WorkflowSelectService);
   const selectService = useService(WorkflowSelectService);
@@ -46,10 +45,27 @@ const useSelectNode = () => {
   );
   );
 };
 };
 
 
+const getContainerNode = (selectService: WorkflowSelectService) => {
+  const { activatedNode } = selectService;
+  if (!activatedNode) {
+    return;
+  }
+  const { isContainer } = activatedNode.getNodeMeta<WorkflowNodeMeta>();
+  if (isContainer) {
+    return activatedNode;
+  }
+  const parentNode = activatedNode.parent;
+  if (!parentNode || parentNode.flowNodeType === FlowNodeBaseType.ROOT) {
+    return;
+  }
+  return parentNode;
+};
+
 // main hook for adding new nodes - 添加新节点的主 hook
 // main hook for adding new nodes - 添加新节点的主 hook
 export const useAddNode = () => {
 export const useAddNode = () => {
   const workflowDocument = useService(WorkflowDocument);
   const workflowDocument = useService(WorkflowDocument);
   const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
   const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
+  const selectService = useService(WorkflowSelectService);
   const playground = usePlayground();
   const playground = usePlayground();
   const getPanelPosition = useGetPanelPosition();
   const getPanelPosition = useGetPanelPosition();
   const select = useSelectNode();
   const select = useSelectNode();
@@ -58,11 +74,13 @@ export const useAddNode = () => {
     async (targetBoundingRect: DOMRect): Promise<void> => {
     async (targetBoundingRect: DOMRect): Promise<void> => {
       // calculate panel position based on target element - 根据目标元素计算面板位置
       // calculate panel position based on target element - 根据目标元素计算面板位置
       const panelPosition = getPanelPosition(targetBoundingRect);
       const panelPosition = getPanelPosition(targetBoundingRect);
+      const containerNode = getContainerNode(selectService);
       await new Promise<void>((resolve) => {
       await new Promise<void>((resolve) => {
         // call the node panel service to show the panel - 调用节点面板服务来显示面板
         // call the node panel service to show the panel - 调用节点面板服务来显示面板
         nodePanelService.callNodePanel({
         nodePanelService.callNodePanel({
           position: panelPosition,
           position: panelPosition,
           enableMultiAdd: true,
           enableMultiAdd: true,
+          containerNode,
           panelProps: {},
           panelProps: {},
           // handle node selection from panel - 处理从面板中选择节点
           // handle node selection from panel - 处理从面板中选择节点
           onSelect: async (panelParams?: NodePanelResult) => {
           onSelect: async (panelParams?: NodePanelResult) => {
@@ -70,13 +88,20 @@ export const useAddNode = () => {
               return;
               return;
             }
             }
             const { nodeType, nodeJSON } = panelParams;
             const { nodeType, nodeJSON } = panelParams;
+            const position = Boolean(containerNode)
+              ? getAntiOverlapPosition(workflowDocument, {
+                  x: 0,
+                  y: 200,
+                })
+              : undefined;
             // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
             // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
             const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
             const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
               nodeType,
               nodeType,
-              undefined, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
-              nodeJSON ?? ({} as WorkflowNodeJSON)
+              position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
+              nodeJSON ?? ({} as WorkflowNodeJSON),
+              containerNode?.id
             );
             );
-            select(node); // select the newly created node - 选择新创建的节点
+            select(node);
           },
           },
           // handle panel close - 处理面板关闭
           // handle panel close - 处理面板关闭
           onClose: () => {
           onClose: () => {

+ 2 - 2
apps/demo-free-layout/src/components/node-panel/index.tsx

@@ -13,7 +13,7 @@ import { NodeList } from './node-list';
 import './index.less';
 import './index.less';
 
 
 export const NodePanel: FC<NodePanelRenderProps> = (props) => {
 export const NodePanel: FC<NodePanelRenderProps> = (props) => {
-  const { onSelect, position, onClose, panelProps } = props;
+  const { onSelect, position, onClose, containerNode, panelProps = {} } = props;
   const { enableNodePlaceholder } = panelProps;
   const { enableNodePlaceholder } = panelProps;
 
 
   return (
   return (
@@ -21,7 +21,7 @@ export const NodePanel: FC<NodePanelRenderProps> = (props) => {
       trigger="click"
       trigger="click"
       visible={true}
       visible={true}
       onVisibleChange={(v) => (v ? null : onClose())}
       onVisibleChange={(v) => (v ? null : onClose())}
-      content={<NodeList onSelect={onSelect} />}
+      content={<NodeList onSelect={onSelect} containerNode={containerNode} />}
       placement="right"
       placement="right"
       popupAlign={{ offset: [30, 0] }}
       popupAlign={{ offset: [30, 0] }}
       overlayStyle={{
       overlayStyle={{

+ 9 - 2
apps/demo-free-layout/src/components/node-panel/node-list.tsx

@@ -7,7 +7,7 @@ import React, { FC } from 'react';
 
 
 import styled from 'styled-components';
 import styled from 'styled-components';
 import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
 import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
-import { useClientContext } from '@flowgram.ai/free-layout-editor';
+import { useClientContext, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
 
 
 import { FlowNodeRegistry } from '../../typings';
 import { FlowNodeRegistry } from '../../typings';
 import { nodeRegistries } from '../../nodes';
 import { nodeRegistries } from '../../nodes';
@@ -62,10 +62,11 @@ const NodesWrap = styled.div`
 
 
 interface NodeListProps {
 interface NodeListProps {
   onSelect: NodePanelRenderProps['onSelect'];
   onSelect: NodePanelRenderProps['onSelect'];
+  containerNode?: WorkflowNodeEntity;
 }
 }
 
 
 export const NodeList: FC<NodeListProps> = (props) => {
 export const NodeList: FC<NodeListProps> = (props) => {
-  const { onSelect } = props;
+  const { onSelect, containerNode } = props;
   const context = useClientContext();
   const context = useClientContext();
   const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
   const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
     const json = registry.onAdd?.(context);
     const json = registry.onAdd?.(context);
@@ -79,6 +80,12 @@ export const NodeList: FC<NodeListProps> = (props) => {
     <NodesWrap style={{ width: 80 * 2 + 20 }}>
     <NodesWrap style={{ width: 80 * 2 + 20 }}>
       {nodeRegistries
       {nodeRegistries
         .filter((register) => register.meta.nodePanelVisible !== false)
         .filter((register) => register.meta.nodePanelVisible !== false)
+        .filter((register) => {
+          if (register.meta.onlyInContainer) {
+            return register.meta.onlyInContainer === containerNode?.flowNodeType;
+          }
+          return true;
+        })
         .map((registry) => (
         .map((registry) => (
           <Node
           <Node
             key={registry.type}
             key={registry.type}

+ 13 - 1
apps/demo-free-layout/src/hooks/use-editor-props.tsx

@@ -131,7 +131,7 @@ export function useEditorProps(
         return true;
         return true;
       },
       },
       canDropToNode: (ctx, params) => {
       canDropToNode: (ctx, params) => {
-        const { dragNodeType } = params;
+        const { dragNodeType, dropNodeType } = params;
         /**
         /**
          * 开始/结束节点无法更改容器
          * 开始/结束节点无法更改容器
          * The start and end nodes cannot change container
          * The start and end nodes cannot change container
@@ -146,6 +146,18 @@ export function useEditorProps(
         ) {
         ) {
           return false;
           return false;
         }
         }
+        /**
+         * 继续循环与终止循环只能在循环节点中
+         * Continue loop and break loop can only be in loop nodes
+         */
+        if (
+          [WorkflowNodeType.Continue, WorkflowNodeType.Break].includes(
+            dragNodeType as WorkflowNodeType
+          ) &&
+          dropNodeType !== WorkflowNodeType.Loop
+        ) {
+          return false;
+        }
         return true;
         return true;
       },
       },
       /**
       /**

+ 33 - 0
apps/demo-free-layout/src/nodes/break/form-meta.tsx

@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormMeta } from '@flowgram.ai/free-layout-editor';
+
+import { defaultFormMeta } from '../default-form-meta';
+import { useIsSidebar } from '../../hooks';
+import { FormHeader, FormContent } from '../../form-components';
+
+export const renderForm = () => {
+  const isSidebar = useIsSidebar();
+  if (isSidebar) {
+    return (
+      <>
+        <FormHeader />
+        <FormContent />
+      </>
+    );
+  }
+  return (
+    <>
+      <FormHeader />
+      <FormContent />
+    </>
+  );
+};
+
+export const formMeta: FormMeta = {
+  ...defaultFormMeta,
+  render: renderForm,
+};

+ 44 - 0
apps/demo-free-layout/src/nodes/break/index.ts

@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { nanoid } from 'nanoid';
+
+import { FlowNodeRegistry } from '../../typings';
+import iconBreak from '../../assets/icon-break.jpg';
+import { formMeta } from './form-meta';
+import { WorkflowNodeType } from '../constants';
+
+let index = 0;
+export const BreakNodeRegistry: FlowNodeRegistry = {
+  type: WorkflowNodeType.Break,
+  meta: {
+    defaultPorts: [{ type: 'input' }],
+    sidebarDisabled: true,
+    size: {
+      width: 360,
+      height: 54,
+    },
+    expandable: false,
+    onlyInContainer: WorkflowNodeType.Loop,
+  },
+  info: {
+    icon: iconBreak,
+    description:
+      'The final node of the workflow, used to return the result information after the workflow is run.',
+  },
+  /**
+   * Render node via formMeta
+   */
+  formMeta,
+  onAdd() {
+    return {
+      id: `break_${nanoid(5)}`,
+      type: 'break',
+      data: {
+        title: `Break_${++index}`,
+      },
+    };
+  },
+};

+ 2 - 0
apps/demo-free-layout/src/nodes/constants.ts

@@ -13,4 +13,6 @@ export enum WorkflowNodeType {
   BlockStart = 'block-start',
   BlockStart = 'block-start',
   BlockEnd = 'block-end',
   BlockEnd = 'block-end',
   Comment = 'comment',
   Comment = 'comment',
+  Continue = 'continue',
+  Break = 'break',
 }
 }

+ 33 - 0
apps/demo-free-layout/src/nodes/continue/form-meta.tsx

@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormMeta } from '@flowgram.ai/free-layout-editor';
+
+import { defaultFormMeta } from '../default-form-meta';
+import { useIsSidebar } from '../../hooks';
+import { FormHeader, FormContent } from '../../form-components';
+
+export const renderForm = () => {
+  const isSidebar = useIsSidebar();
+  if (isSidebar) {
+    return (
+      <>
+        <FormHeader />
+        <FormContent />
+      </>
+    );
+  }
+  return (
+    <>
+      <FormHeader />
+      <FormContent />
+    </>
+  );
+};
+
+export const formMeta: FormMeta = {
+  ...defaultFormMeta,
+  render: renderForm,
+};

+ 44 - 0
apps/demo-free-layout/src/nodes/continue/index.ts

@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { nanoid } from 'nanoid';
+
+import { FlowNodeRegistry } from '../../typings';
+import iconContinue from '../../assets/icon-continue.jpg';
+import { formMeta } from './form-meta';
+import { WorkflowNodeType } from '../constants';
+
+let index = 0;
+export const ContinueNodeRegistry: FlowNodeRegistry = {
+  type: WorkflowNodeType.Continue,
+  meta: {
+    defaultPorts: [{ type: 'input' }],
+    sidebarDisabled: true,
+    size: {
+      width: 360,
+      height: 54,
+    },
+    expandable: false,
+    onlyInContainer: WorkflowNodeType.Loop,
+  },
+  info: {
+    icon: iconContinue,
+    description:
+      'The final node of the workflow, used to return the result information after the workflow is run.',
+  },
+  /**
+   * Render node via formMeta
+   */
+  formMeta,
+  onAdd() {
+    return {
+      id: `continue_${nanoid(5)}`,
+      type: 'continue',
+      data: {
+        title: `Continue_${++index}`,
+      },
+    };
+  },
+};

+ 4 - 0
apps/demo-free-layout/src/nodes/index.ts

@@ -9,8 +9,10 @@ import { LoopNodeRegistry } from './loop';
 import { LLMNodeRegistry } from './llm';
 import { LLMNodeRegistry } from './llm';
 import { HTTPNodeRegistry } from './http';
 import { HTTPNodeRegistry } from './http';
 import { EndNodeRegistry } from './end';
 import { EndNodeRegistry } from './end';
+import { ContinueNodeRegistry } from './continue';
 import { ConditionNodeRegistry } from './condition';
 import { ConditionNodeRegistry } from './condition';
 import { CommentNodeRegistry } from './comment';
 import { CommentNodeRegistry } from './comment';
+import { BreakNodeRegistry } from './break';
 import { BlockStartNodeRegistry } from './block-start';
 import { BlockStartNodeRegistry } from './block-start';
 import { BlockEndNodeRegistry } from './block-end';
 import { BlockEndNodeRegistry } from './block-end';
 export { WorkflowNodeType } from './constants';
 export { WorkflowNodeType } from './constants';
@@ -25,4 +27,6 @@ export const nodeRegistries: FlowNodeRegistry[] = [
   BlockStartNodeRegistry,
   BlockStartNodeRegistry,
   BlockEndNodeRegistry,
   BlockEndNodeRegistry,
   HTTPNodeRegistry,
   HTTPNodeRegistry,
+  ContinueNodeRegistry,
+  BreakNodeRegistry,
 ];
 ];

+ 2 - 2
apps/demo-free-layout/src/nodes/loop/index.ts

@@ -46,8 +46,8 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
     padding: () => ({
     padding: () => ({
       top: 120,
       top: 120,
       bottom: 60,
       bottom: 60,
-      left: 100,
-      right: 100,
+      left: 60,
+      right: 60,
     }),
     }),
     /**
     /**
      * Controls the node selection status within the subcanvas
      * Controls the node selection status within the subcanvas

+ 31 - 5
apps/demo-free-layout/src/plugins/context-menu-plugin/context-menu-layer.tsx

@@ -12,7 +12,12 @@ import {
   WorkflowHoverService,
   WorkflowHoverService,
   WorkflowNodeEntity,
   WorkflowNodeEntity,
   WorkflowNodeJSON,
   WorkflowNodeJSON,
+  WorkflowSelectService,
+  WorkflowDocument,
+  PositionSchema,
+  WorkflowDragService,
 } from '@flowgram.ai/free-layout-editor';
 } from '@flowgram.ai/free-layout-editor';
+import { ContainerUtils } from '@flowgram.ai/free-container-plugin';
 
 
 @injectable()
 @injectable()
 export class ContextMenuLayer extends Layer {
 export class ContextMenuLayer extends Layer {
@@ -22,6 +27,12 @@ export class ContextMenuLayer extends Layer {
 
 
   @inject(WorkflowHoverService) hoverService: WorkflowHoverService;
   @inject(WorkflowHoverService) hoverService: WorkflowHoverService;
 
 
+  @inject(WorkflowSelectService) selectService: WorkflowSelectService;
+
+  @inject(WorkflowDocument) document: WorkflowDocument;
+
+  @inject(WorkflowDragService) dragService: WorkflowDragService;
+
   onReady() {
   onReady() {
     this.listenPlaygroundEvent('contextmenu', (e) => {
     this.listenPlaygroundEvent('contextmenu', (e) => {
       this.openNodePanel(e);
       this.openNodePanel(e);
@@ -31,9 +42,11 @@ export class ContextMenuLayer extends Layer {
   }
   }
 
 
   openNodePanel(e: MouseEvent) {
   openNodePanel(e: MouseEvent) {
-    const pos = this.getPosFromMouseEvent(e);
+    const mousePos = this.getPosFromMouseEvent(e);
+    const containerNode = this.getContainerNode(mousePos);
     this.nodePanelService.callNodePanel({
     this.nodePanelService.callNodePanel({
-      position: pos,
+      position: mousePos,
+      containerNode,
       panelProps: {},
       panelProps: {},
       // handle node selection from panel - 处理从面板中选择节点
       // handle node selection from panel - 处理从面板中选择节点
       onSelect: async (panelParams?: NodePanelResult) => {
       onSelect: async (panelParams?: NodePanelResult) => {
@@ -41,17 +54,30 @@ export class ContextMenuLayer extends Layer {
           return;
           return;
         }
         }
         const { nodeType, nodeJSON } = panelParams;
         const { nodeType, nodeJSON } = panelParams;
+        const position = this.dragService.adjustSubNodePosition(nodeType, containerNode, mousePos);
         // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
         // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
         const node: WorkflowNodeEntity = this.ctx.document.createWorkflowNodeByType(
         const node: WorkflowNodeEntity = this.ctx.document.createWorkflowNodeByType(
           nodeType,
           nodeType,
-          pos,
-          nodeJSON ?? ({} as WorkflowNodeJSON)
+          position,
+          nodeJSON ?? ({} as WorkflowNodeJSON),
+          containerNode?.id
         );
         );
         // select the newly created node - 选择新创建的节点
         // select the newly created node - 选择新创建的节点
-        this.ctx.selection.selection = [node];
+        this.selectService.select(node);
       },
       },
       // handle panel close - 处理面板关闭
       // handle panel close - 处理面板关闭
       onClose: () => {},
       onClose: () => {},
     });
     });
   }
   }
+
+  private getContainerNode(mousePos: PositionSchema): WorkflowNodeEntity | undefined {
+    const allNodes = this.document.getAllNodes();
+    const containerTransforms = ContainerUtils.getContainerTransforms(allNodes);
+    const collisionTransform = ContainerUtils.getCollisionTransform({
+      targetPoint: mousePos,
+      transforms: containerTransforms,
+      document: this.document,
+    });
+    return collisionTransform?.entity;
+  }
 }
 }

+ 11 - 2
apps/demo-free-layout/src/shortcuts/copy/index.ts

@@ -226,8 +226,17 @@ export class CopyShortcut implements ShortcutsHandler {
    * show success notification - 显示成功通知
    * show success notification - 显示成功通知
    */
    */
   private notifySuccess(): void {
   private notifySuccess(): void {
-    const nodeTypes = this.selectedNodes.map((node) => node.flowNodeType);
-    if (nodeTypes.includes('start') || nodeTypes.includes('end')) {
+    const startEndNodeTypes: WorkflowNodeType[] = [
+      WorkflowNodeType.Start,
+      WorkflowNodeType.End,
+      WorkflowNodeType.BlockStart,
+      WorkflowNodeType.BlockEnd,
+    ];
+    if (
+      this.selectedNodes.some((node) =>
+        startEndNodeTypes.includes(node.flowNodeType as WorkflowNodeType)
+      )
+    ) {
       Toast.warning({
       Toast.warning({
         content:
         content:
           'The Start/End node cannot be duplicated, other nodes have been copied to the clipboard',
           'The Start/End node cannot be duplicated, other nodes have been copied to the clipboard',

+ 16 - 1
apps/demo-free-layout/src/shortcuts/paste/index.ts

@@ -124,13 +124,28 @@ export class PasteShortcut implements ShortcutsHandler {
       });
       });
       return false;
       return false;
     }
     }
-    // 跨域名表示不同环境,上架插件不同,不能复制
+    // Cross-domain means different environments, different plugins, cannot be copied - 跨域名表示不同环境,上架插件不同,不能复制
     if (data.source.host !== window.location.host) {
     if (data.source.host !== window.location.host) {
       Toast.error({
       Toast.error({
         content: 'Cannot paste nodes from different host',
         content: 'Cannot paste nodes from different host',
       });
       });
       return false;
       return false;
     }
     }
+    // Check container - 检查容器
+    const parent = this.getSelectedContainer();
+    for (const nodeJSON of data.json.nodes) {
+      const res = this.dragService.canDropToNode({
+        dragNodeType: nodeJSON.type,
+        dropNodeType: parent?.flowNodeType,
+        dropNode: parent,
+      });
+      if (!res.allowDrop) {
+        Toast.error({
+          content: res.message ?? 'Cannot paste nodes to invalid container',
+        });
+        return false;
+      }
+    }
     return true;
     return true;
   }
   }
 
 

+ 2 - 0
apps/demo-free-layout/src/typings/node.ts

@@ -14,6 +14,7 @@ import {
 import { IFlowValue } from '@flowgram.ai/form-materials';
 import { IFlowValue } from '@flowgram.ai/form-materials';
 
 
 import { type JsonSchema } from './json-schema';
 import { type JsonSchema } from './json-schema';
+import { WorkflowNodeType } from '../nodes';
 
 
 /**
 /**
  * You can customize the data of the node, and here you can use JsonSchema to define the input and output of the node
  * You can customize the data of the node, and here you can use JsonSchema to define the input and output of the node
@@ -52,6 +53,7 @@ export interface FlowNodeMeta extends WorkflowNodeMeta {
   sidebarDisabled?: boolean;
   sidebarDisabled?: boolean;
   nodePanelHidden?: boolean;
   nodePanelHidden?: boolean;
   wrapperStyle?: React.CSSProperties;
   wrapperStyle?: React.CSSProperties;
+  onlyInContainer?: WorkflowNodeType;
 }
 }
 
 
 /**
 /**

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

@@ -5,3 +5,4 @@
 
 
 export * from './node-into-container';
 export * from './node-into-container';
 export * from './sub-canvas';
 export * from './sub-canvas';
+export { ContainerUtils } from './utils';

+ 20 - 124
packages/plugins/free-container-plugin/src/node-into-container/service.ts

@@ -6,14 +6,7 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion -- no need */
 /* eslint-disable @typescript-eslint/no-non-null-assertion -- no need */
 import { throttle } from 'lodash';
 import { throttle } from 'lodash';
 import { inject, injectable } from 'inversify';
 import { inject, injectable } from 'inversify';
-import {
-  type PositionSchema,
-  Rectangle,
-  type Disposable,
-  DisposableCollection,
-  Emitter,
-  type IPoint,
-} from '@flowgram.ai/utils';
+import { type Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
 import {
 import {
   type NodesDragEvent,
   type NodesDragEvent,
   WorkflowDocument,
   WorkflowDocument,
@@ -25,11 +18,12 @@ import {
   WorkflowSelectService,
   WorkflowSelectService,
 } from '@flowgram.ai/free-layout-core';
 } from '@flowgram.ai/free-layout-core';
 import { HistoryService } from '@flowgram.ai/free-history-plugin';
 import { HistoryService } from '@flowgram.ai/free-history-plugin';
-import { FlowNodeTransformData, FlowNodeRenderData, FlowNodeBaseType } from '@flowgram.ai/document';
+import { FlowNodeRenderData, FlowNodeBaseType } from '@flowgram.ai/document';
 import { PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
 import { PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
 
 
 import type { NodeIntoContainerEvent, NodeIntoContainerState } from './type';
 import type { NodeIntoContainerEvent, NodeIntoContainerState } from './type';
 import { NodeIntoContainerType } from './constant';
 import { NodeIntoContainerType } from './constant';
+import { ContainerUtils } from '../utils';
 
 
 @injectable()
 @injectable()
 export class NodeIntoContainerService {
 export class NodeIntoContainerService {
@@ -83,7 +77,7 @@ export class NodeIntoContainerService {
     if (
     if (
       !parentNode ||
       !parentNode ||
       !containerNode ||
       !containerNode ||
-      !this.isContainer(parentNode) ||
+      !ContainerUtils.isContainer(parentNode) ||
       !nodeJSON.meta?.position
       !nodeJSON.meta?.position
     ) {
     ) {
       return;
       return;
@@ -92,7 +86,7 @@ export class NodeIntoContainerService {
     this.operationService.moveNode(node, {
     this.operationService.moveNode(node, {
       parent: containerNode,
       parent: containerNode,
     });
     });
-    await this.nextFrame();
+    await ContainerUtils.nextFrame();
     parentTransform.fireChange();
     parentTransform.fireChange();
     this.operationService.updateNodePosition(node, {
     this.operationService.updateNodePosition(node, {
       x: parentTransform.position.x + nodeJSON.meta!.position!.x,
       x: parentTransform.position.x + nodeJSON.meta!.position!.x,
@@ -110,7 +104,7 @@ export class NodeIntoContainerService {
   public canMoveOutContainer(node: WorkflowNodeEntity): boolean {
   public canMoveOutContainer(node: WorkflowNodeEntity): boolean {
     const parentNode = node.parent;
     const parentNode = node.parent;
     const containerNode = parentNode?.parent;
     const containerNode = parentNode?.parent;
-    if (!parentNode || !containerNode || !this.isContainer(parentNode)) {
+    if (!parentNode || !containerNode || !ContainerUtils.isContainer(parentNode)) {
       return false;
       return false;
     }
     }
     const canDrop = this.dragService.canDropToNode({
     const canDrop = this.dragService.canDropToNode({
@@ -157,7 +151,7 @@ export class NodeIntoContainerService {
       }
       }
       line.dispose();
       line.dispose();
     });
     });
-    await this.nextFrame();
+    await ContainerUtils.nextFrame();
   }
   }
 
 
   /** 初始化状态 */
   /** 初始化状态 */
@@ -188,7 +182,7 @@ export class NodeIntoContainerService {
         }
         }
         this.historyService.startTransaction(); // 开始合并历史记录
         this.historyService.startTransaction(); // 开始合并历史记录
         this.state.isDraggingNode = true;
         this.state.isDraggingNode = true;
-        this.state.transforms = this.getContainerTransforms();
+        this.state.transforms = ContainerUtils.getContainerTransforms(this.document.getAllNodes());
         this.state.dragNode = this.selectService.selectedNodes[0];
         this.state.dragNode = this.selectService.selectedNodes[0];
         this.state.dropNode = undefined;
         this.state.dropNode = undefined;
         this.state.sourceParent = this.state.dragNode?.parent;
         this.state.sourceParent = this.state.dragNode?.parent;
@@ -230,39 +224,10 @@ export class NodeIntoContainerService {
     this.moveOutContainer({ node: dragNode });
     this.moveOutContainer({ node: dragNode });
     this.state.isSkipEvent = true;
     this.state.isSkipEvent = true;
     event.dragger.stop(event.dragEvent.clientX, event.dragEvent.clientY);
     event.dragger.stop(event.dragEvent.clientX, event.dragEvent.clientY);
-    await this.nextFrame();
+    await ContainerUtils.nextFrame();
     this.dragService.startDragSelectedNodes(event.triggerEvent);
     this.dragService.startDragSelectedNodes(event.triggerEvent);
   }
   }
 
 
-  /** 获取重叠位置 */
-  private getCollisionTransform(params: {
-    transforms: FlowNodeTransformData[];
-    targetRect?: Rectangle;
-    targetPoint?: PositionSchema;
-    withPadding?: boolean;
-  }): FlowNodeTransformData | undefined {
-    const { targetRect, targetPoint, transforms, withPadding = false } = params;
-    const collisionTransform = transforms.find((transform) => {
-      const { bounds, entity } = transform;
-      const padding = withPadding ? this.document.layout.getPadding(entity) : { left: 0, right: 0 };
-      const transformRect = new Rectangle(
-        bounds.x + padding.left + padding.right,
-        bounds.y,
-        bounds.width,
-        bounds.height
-      );
-      // 检测两个正方形是否相互碰撞
-      if (targetRect) {
-        return this.isRectIntersects(targetRect, transformRect);
-      }
-      if (targetPoint) {
-        return this.isPointInRect(targetPoint, transformRect);
-      }
-      return false;
-    });
-    return collisionTransform;
-  }
-
   /** 设置放置节点高亮 */
   /** 设置放置节点高亮 */
   private setDropNode(dropNode?: WorkflowNodeEntity) {
   private setDropNode(dropNode?: WorkflowNodeEntity) {
     if (this.state.dropNode === dropNode) {
     if (this.state.dropNode === dropNode) {
@@ -288,26 +253,6 @@ export class NodeIntoContainerService {
     }
     }
   }
   }
 
 
-  /** 获取容器节点transforms */
-  private getContainerTransforms(): FlowNodeTransformData[] {
-    return this.document
-      .getAllNodes()
-      .filter((node) => {
-        if (node.originParent) {
-          return node.getNodeMeta().selectable && node.originParent.getNodeMeta().selectable;
-        }
-        return node.getNodeMeta().selectable;
-      })
-      .filter((node) => this.isContainer(node))
-      .sort((a, b) => {
-        const aIndex = a.renderData.stackIndex;
-        const bIndex = b.renderData.stackIndex;
-        //  确保层级高的节点在前面
-        return bIndex - aIndex;
-      })
-      .map((node) => node.transform);
-  }
-
   /** 放置节点到容器 */
   /** 放置节点到容器 */
   private async dropNodeToContainer(): Promise<void> {
   private async dropNodeToContainer(): Promise<void> {
     const { dropNode, dragNode, isDraggingNode } = this.state;
     const { dropNode, dragNode, isDraggingNode } = this.state;
@@ -330,9 +275,10 @@ export class NodeIntoContainerService {
     const availableTransforms = transforms.filter(
     const availableTransforms = transforms.filter(
       (transform) => transform.entity.id !== dragNode.id
       (transform) => transform.entity.id !== dragNode.id
     );
     );
-    const collisionTransform = this.getCollisionTransform({
+    const collisionTransform = ContainerUtils.getCollisionTransform({
       targetPoint: mousePos,
       targetPoint: mousePos,
       transforms: availableTransforms,
       transforms: availableTransforms,
+      document: this.document,
     });
     });
     const dropNode = collisionTransform?.entity;
     const dropNode = collisionTransform?.entity;
     const canDrop = this.canDropToContainer({
     const canDrop = this.canDropToContainer({
@@ -398,9 +344,16 @@ export class NodeIntoContainerService {
       parent: containerNode,
       parent: containerNode,
     });
     });
 
 
-    this.operationService.updateNodePosition(node, this.adjustSubNodePosition(node, containerNode));
+    const containerPadding = this.document.layout.getPadding(containerNode);
+    const position = ContainerUtils.adjustSubNodePosition({
+      targetNode: node,
+      containerNode,
+      containerPadding,
+    });
+
+    this.operationService.updateNodePosition(node, position);
 
 
-    await this.nextFrame();
+    await ContainerUtils.nextFrame();
 
 
     this.emitter.fire({
     this.emitter.fire({
       type: NodeIntoContainerType.In,
       type: NodeIntoContainerType.In,
@@ -409,61 +362,4 @@ export class NodeIntoContainerService {
       targetContainer: containerNode,
       targetContainer: containerNode,
     });
     });
   }
   }
-
-  /**
-   * 如果存在容器节点,且传入鼠标坐标,需要用容器的坐标减去传入的鼠标坐标
-   */
-  private adjustSubNodePosition(
-    targetNode: WorkflowNodeEntity,
-    containerNode: WorkflowNodeEntity
-  ): IPoint {
-    if (containerNode.flowNodeType === FlowNodeBaseType.ROOT) {
-      return targetNode.transform.position;
-    }
-    const nodeWorldTransform = targetNode.transform.transform.worldTransform;
-    const containerWorldTransform = containerNode.transform.transform.worldTransform;
-    const nodePosition = {
-      x: nodeWorldTransform.tx,
-      y: nodeWorldTransform.ty,
-    };
-    const isParentEmpty = !containerNode.children || containerNode.children.length === 0;
-    const containerPadding = this.document.layout.getPadding(containerNode);
-    if (isParentEmpty) {
-      // 确保空容器节点不偏移
-      return {
-        x: 0,
-        y: containerPadding.top,
-      };
-    } else {
-      return {
-        x: nodePosition.x - containerWorldTransform.tx,
-        y: nodePosition.y - containerWorldTransform.ty,
-      };
-    }
-  }
-
-  private isContainer(node?: WorkflowNodeEntity): boolean {
-    return node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
-  }
-
-  /** 判断点是否在矩形内 */
-  private isPointInRect(point: PositionSchema, rect: Rectangle): boolean {
-    return (
-      point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
-    );
-  }
-
-  /** 判断两个矩形是否相交 */
-  private isRectIntersects(rectA: Rectangle, rectB: Rectangle): boolean {
-    // 检查水平方向是否有重叠
-    const hasHorizontalOverlap = rectA.right > rectB.left && rectA.left < rectB.right;
-    // 检查垂直方向是否有重叠
-    const hasVerticalOverlap = rectA.bottom > rectB.top && rectA.top < rectB.bottom;
-    // 只有当水平和垂直方向都有重叠时,两个矩形才相交
-    return hasHorizontalOverlap && hasVerticalOverlap;
-  }
-
-  private async nextFrame(): Promise<void> {
-    await new Promise((resolve) => requestAnimationFrame(resolve));
-  }
 }
 }

+ 41 - 0
packages/plugins/free-container-plugin/src/utils/adjust-sub-node-position.ts

@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { IPoint, PaddingSchema } from '@flowgram.ai/utils';
+import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
+import { FlowNodeBaseType } from '@flowgram.ai/document';
+
+/**
+ * 如果存在容器节点,且传入鼠标坐标,需要用容器的坐标减去传入的鼠标坐标
+ */
+export const adjustSubNodePosition = (params: {
+  targetNode: WorkflowNodeEntity;
+  containerNode: WorkflowNodeEntity;
+  containerPadding: PaddingSchema;
+}): IPoint => {
+  const { targetNode, containerNode, containerPadding } = params;
+  if (containerNode.flowNodeType === FlowNodeBaseType.ROOT) {
+    return targetNode.transform.position;
+  }
+  const nodeWorldTransform = targetNode.transform.transform.worldTransform;
+  const containerWorldTransform = containerNode.transform.transform.worldTransform;
+  const nodePosition = {
+    x: nodeWorldTransform.tx,
+    y: nodeWorldTransform.ty,
+  };
+  const isParentEmpty = !containerNode.children || containerNode.children.length === 0;
+  if (isParentEmpty) {
+    // 确保空容器节点不偏移
+    return {
+      x: 0,
+      y: containerPadding.top,
+    };
+  } else {
+    return {
+      x: nodePosition.x - containerWorldTransform.tx,
+      y: nodePosition.y - containerWorldTransform.ty,
+    };
+  }
+};

+ 41 - 0
packages/plugins/free-container-plugin/src/utils/get-collision-transform.ts

@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { PositionSchema, Rectangle } from '@flowgram.ai/utils';
+import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
+import { FlowNodeTransformData } from '@flowgram.ai/document';
+
+import { isRectIntersects } from './is-rect-intersects';
+import { isPointInRect } from './is-point-in-rect';
+
+/** 获取重叠位置 */
+export const getCollisionTransform = (params: {
+  transforms: FlowNodeTransformData[];
+  targetRect?: Rectangle;
+  targetPoint?: PositionSchema;
+  withPadding?: boolean;
+  document: WorkflowDocument;
+}): FlowNodeTransformData | undefined => {
+  const { targetRect, targetPoint, transforms, withPadding = false, document } = params;
+  const collisionTransform = transforms.find((transform) => {
+    const { bounds, entity } = transform;
+    const padding = withPadding ? document.layout.getPadding(entity) : { left: 0, right: 0 };
+    const transformRect = new Rectangle(
+      bounds.x + padding.left + padding.right,
+      bounds.y,
+      bounds.width,
+      bounds.height
+    );
+    // 检测两个正方形是否相互碰撞
+    if (targetRect) {
+      return isRectIntersects(targetRect, transformRect);
+    }
+    if (targetPoint) {
+      return isPointInRect(targetPoint, transformRect);
+    }
+    return false;
+  });
+  return collisionTransform;
+};

+ 27 - 0
packages/plugins/free-container-plugin/src/utils/get-container-transforms.ts

@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
+import { FlowNodeTransformData } from '@flowgram.ai/document';
+
+import { isContainer } from './is-container';
+
+/** 获取容器节点transforms */
+export const getContainerTransforms = (allNodes: WorkflowNodeEntity[]): FlowNodeTransformData[] =>
+  allNodes
+    .filter((node) => {
+      if (node.originParent) {
+        return node.getNodeMeta().selectable && node.originParent.getNodeMeta().selectable;
+      }
+      return node.getNodeMeta().selectable;
+    })
+    .filter((node) => isContainer(node))
+    .sort((a, b) => {
+      const aIndex = a.renderData.stackIndex;
+      const bIndex = b.renderData.stackIndex;
+      //  确保层级高的节点在前面
+      return bIndex - aIndex;
+    })
+    .map((node) => node.transform);

+ 18 - 0
packages/plugins/free-container-plugin/src/utils/index.ts

@@ -0,0 +1,18 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { nextFrame } from './next-frame';
+import { isContainer } from './is-container';
+import { getContainerTransforms } from './get-container-transforms';
+import { getCollisionTransform } from './get-collision-transform';
+import { adjustSubNodePosition } from './adjust-sub-node-position';
+
+export const ContainerUtils = {
+  nextFrame,
+  isContainer,
+  adjustSubNodePosition,
+  getContainerTransforms,
+  getCollisionTransform,
+};

+ 9 - 0
packages/plugins/free-container-plugin/src/utils/is-container.ts

@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowNodeEntity, WorkflowNodeMeta } from '@flowgram.ai/free-layout-core';
+
+export const isContainer = (node?: WorkflowNodeEntity): boolean =>
+  node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;

+ 9 - 0
packages/plugins/free-container-plugin/src/utils/is-point-in-rect.ts

@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { PositionSchema, Rectangle } from '@flowgram.ai/utils';
+
+export const isPointInRect = (point: PositionSchema, rect: Rectangle): boolean =>
+  point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom;

+ 15 - 0
packages/plugins/free-container-plugin/src/utils/is-rect-intersects.ts

@@ -0,0 +1,15 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { Rectangle } from '@flowgram.ai/utils';
+
+export const isRectIntersects = (rectA: Rectangle, rectB: Rectangle): boolean => {
+  // 检查水平方向是否有重叠
+  const hasHorizontalOverlap = rectA.right > rectB.left && rectA.left < rectB.right;
+  // 检查垂直方向是否有重叠
+  const hasVerticalOverlap = rectA.bottom > rectB.top && rectA.top < rectB.bottom;
+  // 只有当水平和垂直方向都有重叠时,两个矩形才相交
+  return hasHorizontalOverlap && hasVerticalOverlap;
+};

+ 8 - 0
packages/plugins/free-container-plugin/src/utils/next-frame.ts

@@ -0,0 +1,8 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export const nextFrame = async (): Promise<void> => {
+  await new Promise((resolve) => requestAnimationFrame(resolve));
+};