Quellcode durchsuchen

feat(free-demo): enhance copy and paste shortcut capabilities (#161)

* chore(demo): define enum workflow node type

* feat(demo): workflow copy shortcut

* feat(demo): workflow paste shortcut

* feat(demo): workflow rest built-in shortcuts

* fix(history): remove delay after node delete

* fix(demo): paste to container need adjust node position

* fix(core): reset selection after node deleted

* feat(demo): add en comments to shortcut code

* fix(ci): tsc error
Louis Young vor 8 Monaten
Ursprung
Commit
e9c654935f
29 geänderte Dateien mit 1060 neuen und 221 gelöschten Zeilen
  1. 2 1
      apps/demo-free-layout/src/nodes/condition/index.ts
  2. 7 0
      apps/demo-free-layout/src/nodes/constants.ts
  3. 2 1
      apps/demo-free-layout/src/nodes/end/index.ts
  4. 1 0
      apps/demo-free-layout/src/nodes/index.ts
  5. 2 1
      apps/demo-free-layout/src/nodes/llm/index.ts
  6. 2 1
      apps/demo-free-layout/src/nodes/loop/index.ts
  7. 2 1
      apps/demo-free-layout/src/nodes/start/index.ts
  8. 29 0
      apps/demo-free-layout/src/shortcuts/collapse/index.ts
  9. 3 1
      apps/demo-free-layout/src/shortcuts/constants.ts
  10. 228 0
      apps/demo-free-layout/src/shortcuts/copy/index.ts
  11. 95 0
      apps/demo-free-layout/src/shortcuts/delete/index.ts
  12. 29 0
      apps/demo-free-layout/src/shortcuts/expand/index.ts
  13. 191 0
      apps/demo-free-layout/src/shortcuts/paste/index.ts
  14. 184 0
      apps/demo-free-layout/src/shortcuts/paste/traverse.ts
  15. 108 0
      apps/demo-free-layout/src/shortcuts/paste/unique-workflow.ts
  16. 28 0
      apps/demo-free-layout/src/shortcuts/select-all/index.ts
  17. 19 183
      apps/demo-free-layout/src/shortcuts/shortcuts.ts
  18. 22 0
      apps/demo-free-layout/src/shortcuts/type.ts
  19. 26 0
      apps/demo-free-layout/src/shortcuts/zoom-in/index.ts
  20. 26 0
      apps/demo-free-layout/src/shortcuts/zoom-out/index.ts
  21. 9 4
      cspell.json
  22. 6 0
      packages/canvas-engine/core/src/common/entity.ts
  23. 17 3
      packages/canvas-engine/core/src/services/selection-service.ts
  24. 1 1
      packages/canvas-engine/free-layout-core/src/entity-datas/workflow-node-lines-data.ts
  25. 1 1
      packages/canvas-engine/free-layout-core/src/service/workflow-drag-service.ts
  26. 13 8
      packages/canvas-engine/free-layout-core/src/workflow-document.ts
  27. 3 8
      packages/plugins/free-history-plugin/src/changes/add-node-change.ts
  28. 3 6
      packages/plugins/free-history-plugin/src/changes/delete-node-change.ts
  29. 1 1
      packages/plugins/shortcuts-plugin/src/shortcuts-contribution.ts

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

@@ -3,9 +3,10 @@ import { nanoid } from 'nanoid';
 import { FlowNodeRegistry } from '../../typings';
 import iconCondition from '../../assets/icon-condition.svg';
 import { formMeta } from './form-meta';
+import { WorkflowNodeType } from '../constants';
 
 export const ConditionNodeRegistry: FlowNodeRegistry = {
-  type: 'condition',
+  type: WorkflowNodeType.Condition,
   info: {
     icon: iconCondition,
     description:

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

@@ -0,0 +1,7 @@
+export enum WorkflowNodeType {
+  Start = 'start',
+  End = 'end',
+  LLM = 'llm',
+  Condition = 'condition',
+  Loop = 'loop',
+}

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

@@ -1,9 +1,10 @@
 import { FlowNodeRegistry } from '../../typings';
 import iconEnd from '../../assets/icon-end.jpg';
 import { formMeta } from './form-meta';
+import { WorkflowNodeType } from '../constants';
 
 export const EndNodeRegistry: FlowNodeRegistry = {
-  type: 'end',
+  type: WorkflowNodeType.End,
   meta: {
     deleteDisable: true,
     copyDisable: true,

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

@@ -4,6 +4,7 @@ import { LoopNodeRegistry } from './loop';
 import { LLMNodeRegistry } from './llm';
 import { EndNodeRegistry } from './end';
 import { ConditionNodeRegistry } from './condition';
+export { WorkflowNodeType } from './constants';
 
 export const nodeRegistries: FlowNodeRegistry[] = [
   ConditionNodeRegistry,

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

@@ -1,11 +1,12 @@
 import { nanoid } from 'nanoid';
 
+import { WorkflowNodeType } from '../constants';
 import { FlowNodeRegistry } from '../../typings';
 import iconLLM from '../../assets/icon-llm.jpg';
 
 let index = 0;
 export const LLMNodeRegistry: FlowNodeRegistry = {
-  type: 'llm',
+  type: WorkflowNodeType.LLM,
   info: {
     icon: iconLLM,
     description:

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

@@ -9,10 +9,11 @@ import { defaultFormMeta } from '../default-form-meta';
 import { FlowNodeRegistry } from '../../typings';
 import iconLoop from '../../assets/icon-loop.jpg';
 import { LoopFormRender } from './loop-form-render';
+import { WorkflowNodeType } from '../constants';
 
 let index = 0;
 export const LoopNodeRegistry: FlowNodeRegistry = {
-  type: 'loop',
+  type: WorkflowNodeType.Loop,
   info: {
     icon: iconLoop,
     description:

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

@@ -1,9 +1,10 @@
 import { FlowNodeRegistry } from '../../typings';
 import iconStart from '../../assets/icon-start.jpg';
 import { formMeta } from './form-meta';
+import { WorkflowNodeType } from '../constants';
 
 export const StartNodeRegistry: FlowNodeRegistry = {
-  type: 'start',
+  type: WorkflowNodeType.Start,
   meta: {
     isStart: true,
     deleteDisable: true,

+ 29 - 0
apps/demo-free-layout/src/shortcuts/collapse/index.ts

@@ -0,0 +1,29 @@
+import {
+  FreeLayoutPluginContext,
+  ShortcutsHandler,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+
+import { FlowCommandId } from '../constants';
+
+export class CollapseShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.COLLAPSE;
+
+  public commandDetail: ShortcutsHandler['commandDetail'] = {
+    label: 'Collapse',
+  };
+
+  public shortcuts = ['meta alt openbracket', 'ctrl alt openbracket'];
+
+  private selectService: WorkflowSelectService;
+
+  constructor(context: FreeLayoutPluginContext) {
+    this.selectService = context.get(WorkflowSelectService);
+  }
+
+  public async execute(): Promise<void> {
+    this.selectService.selectedNodes.forEach((node) => {
+      node.renderData.expanded = false;
+    });
+  }
+}

+ 3 - 1
apps/demo-free-layout/src/shortcuts/constants.ts

@@ -1,10 +1,12 @@
+export const WorkflowClipboardDataID = 'flowgram-workflow-clipboard-data';
+
 export enum FlowCommandId {
   COPY = 'COPY',
   PASTE = 'PASTE',
   CUT = 'CUT',
   GROUP = 'GROUP',
   UNGROUP = 'UNGROUP',
-  COLLAPSE = 'COLLPASE',
+  COLLAPSE = 'COLLAPSE',
   EXPAND = 'EXPAND',
   DELETE = 'DELETE',
   ZOOM_IN = 'ZOOM_IN',

+ 228 - 0
apps/demo-free-layout/src/shortcuts/copy/index.ts

@@ -0,0 +1,228 @@
+import {
+  FlowNodeTransformData,
+  FreeLayoutPluginContext,
+  Rectangle,
+  ShortcutsHandler,
+  TransformData,
+  WorkflowDocument,
+  WorkflowEdgeJSON,
+  WorkflowJSON,
+  WorkflowLineEntity,
+  WorkflowNodeEntity,
+  WorkflowNodeJSON,
+  WorkflowNodeLinesData,
+  WorkflowNodeMeta,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+import { Toast } from '@douyinfe/semi-ui';
+
+import type {
+  WorkflowClipboardRect,
+  WorkflowClipboardSource,
+  WorkflowClipboardData,
+} from '../type';
+import { FlowCommandId, WorkflowClipboardDataID } from '../constants';
+import { WorkflowNodeType } from '../../nodes';
+
+export class CopyShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.COPY;
+
+  public shortcuts = ['meta c', 'ctrl c'];
+
+  private document: WorkflowDocument;
+
+  private selectService: WorkflowSelectService;
+
+  constructor(context: FreeLayoutPluginContext) {
+    this.document = context.get(WorkflowDocument);
+    this.selectService = context.get(WorkflowSelectService);
+  }
+
+  /**
+   * execute copy operation - 执行复制操作
+   */
+  public async execute(): Promise<void> {
+    if (await this.hasSelectedText()) {
+      return;
+    }
+    if (!this.isValid(this.selectedNodes)) {
+      return;
+    }
+    const data = this.toData();
+    await this.write(data);
+  }
+
+  /**
+   *  has selected text - 是否有文字被选中
+   */
+  private async hasSelectedText(): Promise<boolean> {
+    if (!window.getSelection()?.toString()) {
+      return false;
+    }
+    await navigator.clipboard.writeText(window.getSelection()?.toString() ?? '');
+    Toast.success({
+      content: 'Text copied',
+    });
+    return true;
+  }
+
+  /**
+   * get selected nodes - 获取选中的节点
+   */
+  private get selectedNodes(): WorkflowNodeEntity[] {
+    return this.selectService.selection.filter(
+      (n) => n instanceof WorkflowNodeEntity
+    ) as WorkflowNodeEntity[];
+  }
+
+  /**
+   * validate selected nodes - 验证选中的节点
+   */
+  private isValid(nodes: WorkflowNodeEntity[]): boolean {
+    if (nodes.length === 0) {
+      Toast.warning({
+        content: 'No nodes selected',
+      });
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * create clipboard data - 转换为剪贴板数据
+   */
+  private toData(): WorkflowClipboardData {
+    const validNodes = this.getValidNodes(this.selectedNodes);
+    const source = this.toSource();
+    const json = this.toJSON(validNodes);
+    const bounds = this.getEntireBounds(validNodes);
+    return {
+      type: WorkflowClipboardDataID,
+      source,
+      json,
+      bounds,
+    };
+  }
+
+  /**
+   * get valid nodes - 获取有效的节点
+   */
+  private getValidNodes(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity[] {
+    return nodes.filter((n) => {
+      if (
+        [WorkflowNodeType.Start, WorkflowNodeType.End].includes(n.flowNodeType as WorkflowNodeType)
+      ) {
+        return false;
+      }
+      if (n.getNodeMeta<WorkflowNodeMeta>().copyDisable) {
+        return false;
+      }
+      return true;
+    });
+  }
+
+  /**
+   * get source data - 获取来源数据
+   */
+  private toSource(): WorkflowClipboardSource {
+    return {
+      host: window.location.host,
+    };
+  }
+
+  /**
+   * convert nodes to JSON - 将节点转换为JSON
+   */
+  private toJSON(nodes: WorkflowNodeEntity[]): WorkflowJSON {
+    const nodeJSONs = this.getNodeJSONs(nodes);
+    const edgeJSONs = this.getEdgeJSONs(nodes);
+    return {
+      nodes: nodeJSONs,
+      edges: edgeJSONs,
+    };
+  }
+
+  /**
+   * get JSON representation of nodes - 获取节点的JSON表示
+   */
+  private getNodeJSONs(nodes: WorkflowNodeEntity[]): WorkflowNodeJSON[] {
+    const nodeJSONs = nodes.map((node) => {
+      const nodeJSON = this.document.toNodeJSON(node);
+      if (!nodeJSON.meta?.position) {
+        return nodeJSON;
+      }
+      const { bounds } = node.getData(TransformData);
+      // Use absolute positioning as coordinates - 使用绝对定位作为坐标
+      nodeJSON.meta.position = {
+        x: bounds.x,
+        y: bounds.y,
+      };
+      return nodeJSON;
+    });
+    return nodeJSONs.filter(Boolean);
+  }
+
+  /**
+   * get edges of all nodes - 获取所有节点的边
+   */
+  private getEdgeJSONs(nodes: WorkflowNodeEntity[]): WorkflowEdgeJSON[] {
+    const lineSet = new Set<WorkflowLineEntity>();
+    const nodeIdSet = new Set(nodes.map((n) => n.id));
+    nodes.forEach((node) => {
+      const linesData = node.getData(WorkflowNodeLinesData);
+      const lines = [...linesData.inputLines, ...linesData.outputLines];
+      lines.forEach((line) => {
+        if (nodeIdSet.has(line.from.id) && line.to?.id && nodeIdSet.has(line.to.id)) {
+          lineSet.add(line);
+        }
+      });
+    });
+    return Array.from(lineSet).map((line) => line.toJSON());
+  }
+
+  /**
+   * get bounding rectangle of all nodes - 获取所有节点的边界矩形
+   */
+  private getEntireBounds(nodes: WorkflowNodeEntity[]): WorkflowClipboardRect {
+    const bounds = nodes.map((node) => node.getData<TransformData>(TransformData).bounds);
+    const rect = Rectangle.enlarge(bounds);
+    return {
+      x: rect.x,
+      y: rect.y,
+      width: rect.width,
+      height: rect.height,
+    };
+  }
+
+  /**
+   * write data to clipboard - 将数据写入剪贴板
+   */
+  private async write(data: WorkflowClipboardData): Promise<void> {
+    try {
+      await navigator.clipboard.writeText(JSON.stringify(data));
+      this.notifySuccess();
+    } catch (err) {
+      console.error('Failed to write text: ', err);
+    }
+  }
+
+  /**
+   * show success notification - 显示成功通知
+   */
+  private notifySuccess(): void {
+    const nodeTypes = this.selectedNodes.map((node) => node.flowNodeType);
+    if (nodeTypes.includes('start') || nodeTypes.includes('end')) {
+      Toast.warning({
+        content:
+          'The Start/End node cannot be duplicated, other nodes have been copied to the clipboard',
+        showClose: false,
+      });
+      return;
+    }
+    Toast.success({
+      content: 'Nodes have been copied to the clipboard',
+      showClose: false,
+    });
+    return;
+  }
+}

+ 95 - 0
apps/demo-free-layout/src/shortcuts/delete/index.ts

@@ -0,0 +1,95 @@
+import {
+  FreeLayoutPluginContext,
+  ShortcutsHandler,
+  WorkflowDocument,
+  WorkflowLineEntity,
+  WorkflowNodeEntity,
+  WorkflowNodeMeta,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+import { Toast } from '@douyinfe/semi-ui';
+
+import { FlowCommandId } from '../constants';
+import { WorkflowNodeType } from '../../nodes';
+
+export class DeleteShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.DELETE;
+
+  public shortcuts = ['backspace', 'delete'];
+
+  private document: WorkflowDocument;
+
+  private selectService: WorkflowSelectService;
+
+  /**
+   * initialize delete shortcut - 初始化删除快捷键
+   */
+  constructor(context: FreeLayoutPluginContext) {
+    this.document = context.get(WorkflowDocument);
+    this.selectService = context.get(WorkflowSelectService);
+  }
+
+  /**
+   * execute delete operation - 执行删除操作
+   */
+  public async execute(): Promise<void> {
+    if (!this.isValid(this.selectService.selectedNodes)) {
+      return;
+    }
+    // delete selected entities - 删除选中实体
+    this.selectService.selection.forEach((entity) => {
+      if (entity instanceof WorkflowNodeEntity) {
+        this.removeNode(entity);
+      } else if (entity instanceof WorkflowLineEntity) {
+        this.removeLine(entity);
+      } else {
+        entity.dispose();
+      }
+    });
+    // filter out disposed entities - 过滤掉已删除的实体
+    this.selectService.selection = this.selectService.selection.filter((s) => !s.disposed);
+  }
+
+  /**
+   * validate if nodes can be deleted - 验证节点是否可以删除
+   */
+  private isValid(nodes: WorkflowNodeEntity[]): boolean {
+    const hasSystemNodes = nodes.some((n) =>
+      [WorkflowNodeType.Start, WorkflowNodeType.End].includes(n.flowNodeType as WorkflowNodeType)
+    );
+    if (hasSystemNodes) {
+      Toast.error({
+        content: 'Start or End node cannot be deleted',
+        showClose: false,
+      });
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * remove node from workflow - 从工作流中删除节点
+   */
+  private removeNode(node: WorkflowNodeEntity): void {
+    if (!this.document.canRemove(node)) {
+      return;
+    }
+    const nodeMeta = node.getNodeMeta<WorkflowNodeMeta>();
+    const subCanvas = nodeMeta.subCanvas?.(node);
+    if (subCanvas?.isCanvas) {
+      subCanvas.parentNode.dispose();
+      return;
+    }
+    node.dispose();
+  }
+
+  /**
+   * remove line from workflow - 从工作流中删除连线
+   */
+  private removeLine(line: WorkflowLineEntity): void {
+    if (!this.document.linesManager.canRemove(line)) {
+      return;
+    }
+    line.dispose();
+  }
+}

+ 29 - 0
apps/demo-free-layout/src/shortcuts/expand/index.ts

@@ -0,0 +1,29 @@
+import {
+  FreeLayoutPluginContext,
+  ShortcutsHandler,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+
+import { FlowCommandId } from '../constants';
+
+export class ExpandShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.EXPAND;
+
+  public commandDetail: ShortcutsHandler['commandDetail'] = {
+    label: 'Expand',
+  };
+
+  public shortcuts = ['meta alt closebracket', 'ctrl alt openbracket'];
+
+  private selectService: WorkflowSelectService;
+
+  constructor(context: FreeLayoutPluginContext) {
+    this.selectService = context.get(WorkflowSelectService);
+  }
+
+  public async execute(): Promise<void> {
+    this.selectService.selectedNodes.forEach((node) => {
+      node.renderData.expanded = true;
+    });
+  }
+}

+ 191 - 0
apps/demo-free-layout/src/shortcuts/paste/index.ts

@@ -0,0 +1,191 @@
+import {
+  delay,
+  EntityManager,
+  FlowNodeTransformData,
+  FreeLayoutPluginContext,
+  IPoint,
+  Rectangle,
+  ShortcutsHandler,
+  WorkflowDocument,
+  WorkflowDragService,
+  WorkflowHoverService,
+  WorkflowJSON,
+  WorkflowNodeEntity,
+  WorkflowNodeMeta,
+  WorkflowSelectService,
+} from '@flowgram.ai/free-layout-editor';
+import { Toast } from '@douyinfe/semi-ui';
+
+import { WorkflowClipboardData, WorkflowClipboardRect } from '../type';
+import { FlowCommandId, WorkflowClipboardDataID } from '../constants';
+import { generateUniqueWorkflow } from './unique-workflow';
+
+export class PasteShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.PASTE;
+
+  public shortcuts = ['meta v', 'ctrl v'];
+
+  private document: WorkflowDocument;
+
+  private selectService: WorkflowSelectService;
+
+  private entityManager: EntityManager;
+
+  private hoverService: WorkflowHoverService;
+
+  private dragService: WorkflowDragService;
+
+  /**
+   * initialize paste shortcut handler - 初始化粘贴快捷键处理器
+   */
+  constructor(context: FreeLayoutPluginContext) {
+    this.document = context.get(WorkflowDocument);
+    this.selectService = context.get(WorkflowSelectService);
+    this.entityManager = context.get(EntityManager);
+    this.hoverService = context.get(WorkflowHoverService);
+    this.dragService = context.get(WorkflowDragService);
+  }
+
+  /**
+   * execute paste action - 执行粘贴操作
+   */
+  public async execute(): Promise<WorkflowNodeEntity[] | undefined> {
+    const data = await this.tryReadClipboard();
+    if (!data) {
+      return;
+    }
+    if (!this.isValidData(data)) {
+      return;
+    }
+    const nodes = this.apply(data);
+    if (nodes.length > 0) {
+      Toast.success({
+        content: 'Copy successfully',
+        showClose: false,
+      });
+      // wait for nodes to render - 等待节点渲染
+      await this.nextTick();
+      // scroll to visible area - 滚动到可视区域
+      this.scrollNodesToView(nodes);
+    }
+    return nodes;
+  }
+
+  /** try to read clipboard - 尝试读取剪贴板 */
+  private async tryReadClipboard(): Promise<WorkflowClipboardData | undefined> {
+    try {
+      // need user permission to access clipboard, may throw NotAllowedError - 需要用户授予网页剪贴板读取权限, 如果用户没有授予权限, 代码可能会抛出异常 NotAllowedError
+      const text: string = (await navigator.clipboard.readText()) || '';
+      const clipboardData: WorkflowClipboardData = JSON.parse(text);
+      return clipboardData;
+    } catch (e) {
+      // clipboard data is not fixed, no need to show error - 这里本身剪贴板里的数据就不固定,所以没必要报错
+      return;
+    }
+  }
+
+  private isValidData(data?: WorkflowClipboardData): boolean {
+    if (data?.type !== WorkflowClipboardDataID) {
+      Toast.error({
+        content: 'Invalid clipboard data',
+      });
+      return false;
+    }
+    // 跨域名表示不同环境,上架插件不同,不能复制
+    if (data.source.host !== window.location.host) {
+      Toast.error({
+        content: 'Cannot paste nodes from different host',
+      });
+      return false;
+    }
+    return true;
+  }
+
+  /** apply clipboard data - 应用剪切板数据 */
+  private apply(data: WorkflowClipboardData): WorkflowNodeEntity[] {
+    // extract raw json from clipboard data - 从剪贴板数据中提取原始JSON
+    const { json: rawJSON } = data;
+    const json = generateUniqueWorkflow({
+      json: rawJSON,
+      isUniqueId: (id: string) => !this.entityManager.getEntityById(id),
+    });
+
+    const offset = this.calcPasteOffset(data.bounds);
+    const parent = this.getSelectedContainer();
+    this.applyOffset({ json, offset, parent });
+    const { nodes } = this.document.renderJSON(json, {
+      parent,
+    });
+    this.selectNodes(nodes);
+    return nodes;
+  }
+
+  /** calculate paste offset - 计算粘贴偏移 */
+  private calcPasteOffset(boundsData: WorkflowClipboardRect): IPoint {
+    // extract bounds data - 提取边界数据
+    const { x, y, width, height } = boundsData;
+    const rect = new Rectangle(x, y, width, height);
+    const { center } = rect;
+    const mousePos = this.hoverService.hoveredPos;
+    return {
+      x: mousePos.x - center.x,
+      y: mousePos.y - center.y,
+    };
+  }
+
+  /**
+   * apply offset to node positions - 应用偏移到节点位置
+   */
+  private applyOffset(params: {
+    json: WorkflowJSON;
+    offset: IPoint;
+    parent?: WorkflowNodeEntity;
+  }): void {
+    const { json, offset, parent } = params;
+    json.nodes.forEach((nodeJSON) => {
+      if (!nodeJSON.meta?.position) {
+        return;
+      }
+      // calculate new position - 计算新位置
+      let position = {
+        x: nodeJSON.meta.position.x + offset.x,
+        y: nodeJSON.meta.position.y + offset.y,
+      };
+      if (parent) {
+        position = this.dragService.adjustSubNodePosition(
+          nodeJSON.type as string,
+          parent,
+          position
+        );
+      }
+      nodeJSON.meta.position = position;
+    });
+  }
+
+  /** get selected container node - 获取鼠标选中的容器 */
+  private getSelectedContainer(): WorkflowNodeEntity | undefined {
+    const { activatedNode } = this.selectService;
+    return activatedNode?.getNodeMeta<WorkflowNodeMeta>().isContainer ? activatedNode : undefined;
+  }
+
+  /** select nodes - 选中节点 */
+  private selectNodes(nodes: WorkflowNodeEntity[]): void {
+    this.selectService.selection = nodes;
+  }
+
+  /** scroll to nodes - 滚动到节点 */
+  private async scrollNodesToView(nodes: WorkflowNodeEntity[]): Promise<void> {
+    const nodeBounds = nodes.map((node) => node.getData(FlowNodeTransformData).bounds);
+    await this.document.playgroundConfig.scrollToView({
+      bounds: Rectangle.enlarge(nodeBounds),
+    });
+  }
+
+  /** wait for next frame - 等待下一帧 */
+  private async nextTick(): Promise<void> {
+    // 16ms is one render frame - 16ms 为一个渲染帧
+    const frameTime = 16;
+    await delay(frameTime);
+    await new Promise((resolve) => requestAnimationFrame(resolve));
+  }
+}

+ 184 - 0
apps/demo-free-layout/src/shortcuts/paste/traverse.ts

@@ -0,0 +1,184 @@
+// traverse value type - 遍历值类型
+export type TraverseValue = any;
+
+// traverse node interface - 遍历节点接口
+export interface TraverseNode {
+  value: TraverseValue; // node value - 节点值
+  container?: TraverseValue; // parent container - 父容器
+  parent?: TraverseNode; // parent node - 父节点
+  key?: string; // object key - 对象键名
+  index?: number; // array index - 数组索引
+}
+
+// traverse context interface - 遍历上下文接口
+export interface TraverseContext {
+  node: TraverseNode; // current node - 当前节点
+  setValue: (value: TraverseValue) => void; // set value function - 设置值函数
+  getParents: () => TraverseNode[]; // get parents function - 获取父节点函数
+  getPath: () => Array<string | number>; // get path function - 获取路径函数
+  getStringifyPath: () => string; // get string path function - 获取字符串路径函数
+  deleteSelf: () => void; // delete self function - 删除自身函数
+}
+
+// traverse handler type - 遍历处理器类型
+export type TraverseHandler = (context: TraverseContext) => void;
+
+/**
+ * traverse object deeply and handle each value - 深度遍历对象并处理每个值
+ * @param value traverse target - 遍历目标
+ * @param handle handler function - 处理函数
+ */
+export const traverse = <T extends TraverseValue = TraverseValue>(
+  value: T,
+  handler: TraverseHandler | TraverseHandler[]
+): T => {
+  const traverseHandler: TraverseHandler = Array.isArray(handler)
+    ? (context: TraverseContext) => {
+        handler.forEach((handlerFn) => handlerFn(context));
+      }
+    : handler;
+  TraverseUtils.traverseNodes({ value }, traverseHandler);
+  return value;
+};
+
+namespace TraverseUtils {
+  /**
+   * traverse nodes deeply and handle each value - 深度遍历节点并处理每个值
+   * @param node traverse node - 遍历节点
+   * @param handle handler function - 处理函数
+   */
+  export const traverseNodes = (node: TraverseNode, handle: TraverseHandler): void => {
+    const { value } = node;
+    if (!value) {
+      // handle null value - 处理空值
+      return;
+    }
+    if (Object.prototype.toString.call(value) === '[object Object]') {
+      // traverse object properties - 遍历对象属性
+      Object.entries(value).forEach(([key, item]) =>
+        traverseNodes(
+          {
+            value: item,
+            container: value,
+            key,
+            parent: node,
+          },
+          handle
+        )
+      );
+    } else if (Array.isArray(value)) {
+      // traverse array elements from end to start - 从末尾开始遍历数组元素
+      for (let index = value.length - 1; index >= 0; index--) {
+        const item: string = value[index];
+        traverseNodes(
+          {
+            value: item,
+            container: value,
+            index,
+            parent: node,
+          },
+          handle
+        );
+      }
+    }
+    const context: TraverseContext = createContext({ node });
+    handle(context);
+  };
+
+  /**
+   * create traverse context - 创建遍历上下文
+   * @param node traverse node - 遍历节点
+   */
+  const createContext = ({ node }: { node: TraverseNode }): TraverseContext => ({
+    node,
+    setValue: (value: unknown) => setValue(node, value),
+    getParents: () => getParents(node),
+    getPath: () => getPath(node),
+    getStringifyPath: () => getStringifyPath(node),
+    deleteSelf: () => deleteSelf(node),
+  });
+
+  /**
+   * set node value - 设置节点值
+   * @param node traverse node - 遍历节点
+   * @param value new value - 新值
+   */
+  const setValue = (node: TraverseNode, value: unknown) => {
+    // handle empty value - 处理空值
+    if (!value || !node) {
+      return;
+    }
+    node.value = value;
+    // get container info from parent scope - 从父作用域获取容器信息
+    const { container, key, index } = node;
+    if (key && container) {
+      container[key] = value;
+    } else if (typeof index === 'number') {
+      container[index] = value;
+    }
+  };
+
+  /**
+   * get parent nodes - 获取父节点列表
+   * @param node traverse node - 遍历节点
+   */
+  const getParents = (node: TraverseNode): TraverseNode[] => {
+    const parents: TraverseNode[] = [];
+    let currentNode: TraverseNode | undefined = node;
+    while (currentNode) {
+      parents.unshift(currentNode);
+      currentNode = currentNode.parent;
+    }
+    return parents;
+  };
+
+  /**
+   * get node path - 获取节点路径
+   * @param node traverse node - 遍历节点
+   */
+  const getPath = (node: TraverseNode): Array<string | number> => {
+    const path: Array<string | number> = [];
+    const parents = getParents(node);
+    parents.forEach((parent) => {
+      if (parent.key) {
+        path.unshift(parent.key);
+      } else if (parent.index) {
+        path.unshift(parent.index);
+      }
+    });
+    return path;
+  };
+
+  /**
+   * get stringify path - 获取字符串路径
+   * @param node traverse node - 遍历节点
+   */
+  const getStringifyPath = (node: TraverseNode): string => {
+    const path = getPath(node);
+    return path.reduce((stringifyPath: string, pathItem: string | number) => {
+      if (typeof pathItem === 'string') {
+        const re = /\W/g;
+        if (re.test(pathItem)) {
+          // handle special characters - 处理特殊字符
+          return `${stringifyPath}["${pathItem}"]`;
+        }
+        return `${stringifyPath}.${pathItem}`;
+      } else {
+        return `${stringifyPath}[${pathItem}]`;
+      }
+    }, '');
+  };
+
+  /**
+   * delete current node - 删除当前节点
+   * @param node traverse node - 遍历节点
+   */
+  const deleteSelf = (node: TraverseNode): void => {
+    const { container, key, index } = node;
+    if (key && container) {
+      delete container[key];
+    } else if (typeof index === 'number') {
+      container.splice(index, 1);
+    }
+  };
+}

+ 108 - 0
apps/demo-free-layout/src/shortcuts/paste/unique-workflow.ts

@@ -0,0 +1,108 @@
+import { customAlphabet } from 'nanoid';
+import type { WorkflowJSON, WorkflowNodeJSON } from '@flowgram.ai/free-layout-editor';
+
+import { traverse, TraverseContext } from './traverse';
+
+namespace UniqueWorkflowUtils {
+  /** generate unique id - 生成唯一ID */
+  const generateUniqueId = customAlphabet('1234567890', 6); // create a function to generate 6-digit number - 创建一个生成6位数字的函数
+
+  /** get all node ids from workflow json - 从工作流JSON中获取所有节点ID */
+  export const getAllNodeIds = (json: WorkflowJSON): string[] => {
+    const nodeIds = new Set<string>(); // use set to store unique ids - 使用Set存储唯一ID
+    const addNodeId = (node: WorkflowNodeJSON) => {
+      nodeIds.add(node.id);
+      if (node.blocks?.length) {
+        node.blocks.forEach((child) => addNodeId(child)); // recursively add child node ids - 递归添加子节点ID
+      }
+    };
+    json.nodes.forEach((node) => addNodeId(node));
+    return Array.from(nodeIds);
+  };
+
+  /** generate node replacement mapping - 生成节点替换映射 */
+  export const generateNodeReplaceMap = (
+    nodeIds: string[],
+    isUniqueId: (id: string) => boolean
+  ): Map<string, string> => {
+    const nodeReplaceMap = new Map<string, string>(); // create map for id replacement - 创建ID替换映射
+    nodeIds.forEach((id) => {
+      if (isUniqueId(id)) {
+        nodeReplaceMap.set(id, id); // keep original id if unique - 如果ID唯一则保持不变
+      } else {
+        let newId: string;
+        do {
+          newId = generateUniqueId(); // generate new id until unique - 生成新ID直到唯一
+        } while (!isUniqueId(newId));
+        nodeReplaceMap.set(id, newId);
+      }
+    });
+    return nodeReplaceMap;
+  };
+
+  /** check if value exists - 检查值是否存在 */
+  const isExist = (value: unknown): boolean => value !== null && value !== undefined;
+
+  /** check if node should be handled - 检查节点是否需要处理 */
+  const shouldHandle = (context: TraverseContext): boolean => {
+    const { node } = context;
+    // check edge data - 检查边数据
+    if (
+      node?.key &&
+      ['sourceNodeID', 'targetNodeID'].includes(node.key) &&
+      node.parent?.parent?.key === 'edges'
+    ) {
+      return true;
+    }
+    // check node data - 检查节点数据
+    if (
+      node?.key === 'id' &&
+      isExist(node.container?.type) &&
+      isExist(node.container?.meta) &&
+      isExist(node.container?.data)
+    ) {
+      return true;
+    }
+    // check variable data - 检查变量数据
+    if (
+      node?.key === 'blockID' &&
+      isExist(node.container?.name) &&
+      node.container?.source === 'block-output'
+    ) {
+      return true;
+    }
+    return false;
+  };
+
+  /**
+   * replace node ids in workflow json - 替换工作流JSON中的节点ID
+   * notice: this method has side effects, it will modify the input json to avoid deep copy overhead
+   * - 注意:此方法有副作用,会修改输入的json以避免深拷贝开销
+   */
+  export const replaceNodeId = (
+    json: WorkflowJSON,
+    nodeReplaceMap: Map<string, string>
+  ): WorkflowJSON => {
+    traverse(json, (context) => {
+      if (!shouldHandle(context)) {
+        return;
+      }
+      const { node } = context;
+      if (nodeReplaceMap.has(node.value)) {
+        context.setValue(nodeReplaceMap.get(node.value)); // replace old id with new id - 用新ID替换旧ID
+      }
+    });
+    return json;
+  };
+}
+
+/** generate unique workflow json - 生成唯一工作流JSON */
+export const generateUniqueWorkflow = (params: {
+  json: WorkflowJSON;
+  isUniqueId: (id: string) => boolean;
+}): WorkflowJSON => {
+  const { json, isUniqueId } = params;
+  const nodeIds = UniqueWorkflowUtils.getAllNodeIds(json); // get all existing node ids - 获取所有现有节点ID
+  const nodeReplaceMap = UniqueWorkflowUtils.generateNodeReplaceMap(nodeIds, isUniqueId); // generate id replacement map - 生成ID替换映射
+  return UniqueWorkflowUtils.replaceNodeId(json, nodeReplaceMap); // replace all node ids - 替换所有节点ID
+};

+ 28 - 0
apps/demo-free-layout/src/shortcuts/select-all/index.ts

@@ -0,0 +1,28 @@
+import {
+  FreeLayoutPluginContext,
+  Playground,
+  ShortcutsHandler,
+  WorkflowDocument,
+} from '@flowgram.ai/free-layout-editor';
+
+import { FlowCommandId } from '../constants';
+
+export class SelectAllShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.SELECT_ALL;
+
+  public shortcuts = ['meta a', 'ctrl a'];
+
+  private document: WorkflowDocument;
+
+  private playground: Playground;
+
+  constructor(context: FreeLayoutPluginContext) {
+    this.document = context.get(WorkflowDocument);
+    this.playground = context.playground;
+  }
+
+  public async execute(): Promise<void> {
+    const allNodes = this.document.getAllNodes();
+    this.playground.selectionService.selection = allNodes;
+  }
+}

+ 19 - 183
apps/demo-free-layout/src/shortcuts/shortcuts.ts

@@ -1,187 +1,23 @@
-import {
-  FlowNodeBaseType,
-  FlowNodeEntity,
-  FreeLayoutPluginContext,
-  ShortcutsRegistry,
-  WorkflowDocument,
-  WorkflowDragService,
-  WorkflowNodeEntity,
-  WorkflowNodeJSON,
-  WorkflowSelectService,
-  getAntiOverlapPosition,
-} from '@flowgram.ai/free-layout-editor';
-import { Toast } from '@douyinfe/semi-ui';
+import { FreeLayoutPluginContext, ShortcutsRegistry } from '@flowgram.ai/free-layout-editor';
 
-import { FlowCommandId } from './constants';
+import { ZoomOutShortcut } from './zoom-out';
+import { ZoomInShortcut } from './zoom-in';
+import { SelectAllShortcut } from './select-all';
+import { PasteShortcut } from './paste';
+import { ExpandShortcut } from './expand';
+import { DeleteShortcut } from './delete';
+import { CopyShortcut } from './copy';
+import { CollapseShortcut } from './collapse';
 
 export function shortcuts(shortcutsRegistry: ShortcutsRegistry, ctx: FreeLayoutPluginContext) {
-  shortcutsRegistry.addHandlers({
-    commandId: FlowCommandId.SELECT_ALL,
-    shortcuts: ['meta a', 'ctrl a'],
-    execute() {
-      const allNodes = ctx.document.getAllNodes();
-      ctx.playground.selectionService.selection = allNodes;
-    },
-  });
-  shortcutsRegistry.addHandlers({
-    commandId: FlowCommandId.COPY,
-    shortcuts: ['meta c', 'ctrl c'],
-    execute: async (node) => {
-      const document = ctx.get<WorkflowDocument>(WorkflowDocument);
-      const selectService = ctx.get<WorkflowSelectService>(WorkflowSelectService);
-
-      if (window.getSelection()?.toString()) {
-        navigator.clipboard.writeText(window.getSelection()?.toString() ?? '').then(() => {
-          Toast.success({
-            content: 'Text copied',
-          });
-        });
-      }
-      let selectedNodes = node instanceof WorkflowNodeEntity ? [node] : [];
-      if (selectedNodes.length == 0) {
-        selectedNodes = selectService.selectedNodes;
-      }
-
-      if (selectedNodes.length === 0) {
-        return;
-      }
-      const nodeEntities = selectedNodes.filter(
-        (n) => n.flowNodeType !== 'start' && n.flowNodeType !== 'end'
-      );
-      const nodes = await Promise.all(
-        nodeEntities.map(async (nodeEntity) => {
-          const nodeJSON = await document.toNodeJSON(nodeEntity);
-          return {
-            nodeJSON,
-            nodeType: nodeEntity.flowNodeType,
-          };
-        })
-      );
-      navigator.clipboard
-        .writeText(
-          JSON.stringify({
-            nodes,
-            fromHost: window.location.host,
-          })
-        )
-        .then(() => {
-          Toast.success({
-            content: 'Nodes copied',
-          });
-        })
-        .catch((err) => {
-          Toast.error({
-            content: 'Failed to copy nodes',
-          });
-          console.error('Failed to write text: ', err);
-        });
-    },
-  });
-  shortcutsRegistry.addHandlers({
-    commandId: FlowCommandId.PASTE,
-    shortcuts: ['meta v', 'ctrl v'],
-    execute: async (e: KeyboardEvent) => {
-      const document = ctx.get<WorkflowDocument>(WorkflowDocument);
-      const selectService = ctx.get<WorkflowSelectService>(WorkflowSelectService);
-      const dragService = ctx.get<WorkflowDragService>(WorkflowDragService);
-
-      const text: string = (await navigator.clipboard.readText()) || '';
-      let clipboardData: {
-        nodes: {
-          nodeJSON: WorkflowNodeJSON;
-          nodeType: string;
-        }[];
-        fromHost: string;
-      };
-      try {
-        clipboardData = JSON.parse(text);
-      } catch (e) {
-        return;
-      }
-      if (!clipboardData.nodes || !clipboardData.fromHost) {
-        return null;
-      }
-
-      if (clipboardData.fromHost !== window.location.host) {
-        Toast.error({
-          content: 'Cannot paste nodes from different pages',
-        });
-        return null;
-      }
-
-      const { activatedNode } = selectService;
-      const containerNode =
-        activatedNode?.flowNodeType === FlowNodeBaseType.SUB_CANVAS ? activatedNode : undefined;
-
-      const nodes = await Promise.all(
-        clipboardData.nodes.map(({ nodeJSON }) => {
-          delete nodeJSON.blocks;
-          delete nodeJSON.edges;
-          delete nodeJSON.meta?.canvasPosition;
-          const position = containerNode
-            ? dragService.adjustSubNodePosition(
-                nodeJSON.type as string,
-                containerNode,
-                nodeJSON.meta?.position
-              )
-            : nodeJSON.meta?.position;
-          return document.copyNodeFromJSON(
-            nodeJSON.type as string,
-            nodeJSON,
-            '',
-            getAntiOverlapPosition(document, position!),
-            containerNode?.id
-          );
-        })
-      );
-
-      if (nodes.length > 0) {
-        selectService.selection = nodes;
-      }
-
-      Toast.success({
-        content: 'Nodes pasted',
-      });
-    },
-  });
-
-  shortcutsRegistry.addHandlers({
-    commandId: FlowCommandId.COLLAPSE,
-    commandDetail: {
-      label: 'Collapse',
-    },
-    shortcuts: ['meta alt openbracket', 'ctrl alt openbracket'],
-    isEnabled: () => !ctx.playground.config.readonlyOrDisabled,
-    execute: () => {
-      const selection = ctx.selection;
-
-      const selectNodes = selection.selection.filter(
-        (_entity) => _entity instanceof FlowNodeEntity
-      ) as FlowNodeEntity[];
-
-      selectNodes.forEach((node) => {
-        node.renderData.expanded = false;
-      });
-    },
-  });
-
-  shortcutsRegistry.addHandlers({
-    commandId: FlowCommandId.EXPAND,
-    commandDetail: {
-      label: 'Expand',
-    },
-    shortcuts: ['meta alt closebracket', 'ctrol alt openbracket'],
-    isEnabled: () => !ctx.playground.config.readonlyOrDisabled,
-    execute: () => {
-      const selection = ctx.selection;
-
-      const selectNodes = selection.selection.filter(
-        (_entity) => _entity instanceof FlowNodeEntity
-      ) as FlowNodeEntity[];
-
-      selectNodes.forEach((node) => {
-        node.renderData.expanded = true;
-      });
-    },
-  });
+  shortcutsRegistry.addHandlers(
+    new CopyShortcut(ctx),
+    new PasteShortcut(ctx),
+    new SelectAllShortcut(ctx),
+    new CollapseShortcut(ctx),
+    new ExpandShortcut(ctx),
+    new DeleteShortcut(ctx),
+    new ZoomInShortcut(ctx),
+    new ZoomOutShortcut(ctx)
+  );
 }

+ 22 - 0
apps/demo-free-layout/src/shortcuts/type.ts

@@ -0,0 +1,22 @@
+import type { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
+
+import type { WorkflowClipboardDataID } from './constants';
+
+export interface WorkflowClipboardSource {
+  host: string;
+  // more: id?, workspaceId? etc.
+}
+
+export interface WorkflowClipboardRect {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+}
+
+export interface WorkflowClipboardData {
+  type: typeof WorkflowClipboardDataID;
+  json: WorkflowJSON;
+  source: WorkflowClipboardSource;
+  bounds: WorkflowClipboardRect;
+}

+ 26 - 0
apps/demo-free-layout/src/shortcuts/zoom-in/index.ts

@@ -0,0 +1,26 @@
+import {
+  FreeLayoutPluginContext,
+  PlaygroundConfigEntity,
+  ShortcutsHandler,
+} from '@flowgram.ai/free-layout-editor';
+
+import { FlowCommandId } from '../constants';
+
+export class ZoomInShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.ZOOM_IN;
+
+  public shortcuts = ['meta =', 'ctrl ='];
+
+  private playgroundConfig: PlaygroundConfigEntity;
+
+  constructor(context: FreeLayoutPluginContext) {
+    this.playgroundConfig = context.get(PlaygroundConfigEntity);
+  }
+
+  public async execute(): Promise<void> {
+    if (this.playgroundConfig.zoom > 1.9) {
+      return;
+    }
+    this.playgroundConfig.zoomin();
+  }
+}

+ 26 - 0
apps/demo-free-layout/src/shortcuts/zoom-out/index.ts

@@ -0,0 +1,26 @@
+import {
+  FreeLayoutPluginContext,
+  PlaygroundConfigEntity,
+  ShortcutsHandler,
+} from '@flowgram.ai/free-layout-editor';
+
+import { FlowCommandId } from '../constants';
+
+export class ZoomOutShortcut implements ShortcutsHandler {
+  public commandId = FlowCommandId.ZOOM_OUT;
+
+  public shortcuts = ['meta -', 'ctrl -'];
+
+  private playgroundConfig: PlaygroundConfigEntity;
+
+  constructor(context: FreeLayoutPluginContext) {
+    this.playgroundConfig = context.get(PlaygroundConfigEntity);
+  }
+
+  public async execute(): Promise<void> {
+    if (this.playgroundConfig.zoom > 1.9) {
+      return;
+    }
+    this.playgroundConfig.zoomout();
+  }
+}

+ 9 - 4
cspell.json

@@ -4,13 +4,18 @@
   "dictionaryDefinitions": [],
   "dictionaries": [],
   "words": [
+    "closebracket",
+    "codesandbox",
+    "douyinfe",
+    "flowgram",
     "flowgram.ai",
+    "openbracket",
+    "rsbuild",
+    "rspack",
     "rspress",
-    "douyinfe",
     "Sandpack",
-    "codesandbox",
-    "rspack",
-    "rsbuild"
+    "zoomin",
+    "zoomout"
   ],
   "ignoreWords": [],
   "import": []

+ 6 - 0
packages/canvas-engine/core/src/common/entity.ts

@@ -67,6 +67,11 @@ export class Entity<OPTS extends EntityOpts = EntityOpts> implements Disposable
    */
   readonly toDispose = new DisposableCollection();
 
+  /**
+   * 销毁前事件管理
+   */
+  readonly preDispose = new DisposableCollection();
+
   // /**
   //  * able 管理
   //  */
@@ -252,6 +257,7 @@ export class Entity<OPTS extends EntityOpts = EntityOpts> implements Disposable
    * 销毁实体
    */
   dispose(): void {
+    this.preDispose.dispose();
     this.toDispose.dispose();
   }
 

+ 17 - 3
packages/canvas-engine/core/src/services/selection-service.ts

@@ -14,6 +14,8 @@ export class SelectionService implements Disposable {
 
   private currentSelection: Entity[] = [];
 
+  private disposers: Disposable[] = [];
+
   get selection(): Entity[] {
     return this.currentSelection;
   }
@@ -23,10 +25,22 @@ export class SelectionService implements Disposable {
   }
 
   set selection(selection: Entity<any>[]) {
-    if (Compare.isArrayShallowChanged(this.currentSelection, selection)) {
-      this.currentSelection = selection;
-      this.onSelectionChangedEmitter.fire(this.currentSelection);
+    if (!Compare.isArrayShallowChanged(this.currentSelection, selection)) {
+      return;
     }
+    this.disposers.forEach((disposer) => disposer.dispose());
+    this.changeSelection(selection);
+    this.disposers = this.currentSelection.map((selection) =>
+      selection.onDispose(() => {
+        const newSelection = this.currentSelection.filter((n) => n !== selection);
+        this.changeSelection(newSelection);
+      })
+    );
+  }
+
+  private changeSelection(selection: Entity<any>[]) {
+    this.currentSelection = selection;
+    this.onSelectionChangedEmitter.fire(this.currentSelection);
   }
 
   dispose() {

+ 1 - 1
packages/canvas-engine/free-layout-core/src/entity-datas/workflow-node-lines-data.ts

@@ -26,7 +26,7 @@ export class WorkflowNodeLinesData extends EntityData<WorkflowNodeLines> {
   constructor(entity: WorkflowNodeEntity) {
     super(entity);
     this.entity = entity;
-    this.toDispose.push(
+    this.entity.preDispose.push(
       Disposable.create(() => {
         this.inputLines.slice().forEach((line) => line.dispose());
         this.outputLines.slice().forEach((line) => line.dispose());

+ 1 - 1
packages/canvas-engine/free-layout-core/src/service/workflow-drag-service.ts

@@ -120,7 +120,7 @@ export class WorkflowDragService {
    * 拖拽选中节点
    * @param triggerEvent
    */
-  startDragSelectedNodes(triggerEvent: MouseEvent | React.MouseEvent): Promise<boolean> {
+  async startDragSelectedNodes(triggerEvent: MouseEvent | React.MouseEvent): Promise<boolean> {
     let { selectedNodes } = this.selectService;
     if (
       selectedNodes.length === 0 ||

+ 13 - 8
packages/canvas-engine/free-layout-core/src/workflow-document.ts

@@ -566,22 +566,27 @@ export class WorkflowDocument extends FlowDocument {
   /**
    * 逐层创建节点和线条
    */
-  private renderJSON(
+  public renderJSON(
     json: WorkflowJSON,
     options?: {
       parent?: WorkflowNodeEntity;
       isClone?: boolean;
     }
-  ) {
-    // await delay(0); // Loop 节点 onCreate 存在异步创建子画布
+  ): {
+    nodes: WorkflowNodeEntity[];
+    edges: WorkflowLineEntity[];
+  } {
     const { parent = this.root, isClone = false } = options ?? {};
     // 创建节点
     const containerID = this.getNodeSubCanvas(parent)?.canvasNode.id ?? parent.id;
-    json.nodes.forEach((nodeJSON: WorkflowNodeJSON) => {
-      this.createWorkflowNode(nodeJSON, isClone, containerID);
-    }),
-      // 创建线条
-      json.edges.forEach((edge) => this.createWorkflowLine(edge, containerID));
+    const nodes = json.nodes.map((nodeJSON: WorkflowNodeJSON) =>
+      this.createWorkflowNode(nodeJSON, isClone, containerID)
+    );
+    // 创建线条
+    const edges = json.edges
+      .map((edge) => this.createWorkflowLine(edge, containerID))
+      .filter(Boolean) as WorkflowLineEntity[];
+    return { nodes, edges };
   }
 
   private getNodeSubCanvas(node: WorkflowNodeEntity): WorkflowSubCanvas | undefined {

+ 3 - 8
packages/plugins/free-history-plugin/src/changes/add-node-change.ts

@@ -1,4 +1,4 @@
-import { WorkflowDocument, delay, type WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
+import { WorkflowDocument, type WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
 import { WorkflowContentChangeType } from '@flowgram.ai/free-layout-core';
 import { type FlowNodeEntity } from '@flowgram.ai/document';
 
@@ -11,17 +11,12 @@ import { FreeHistoryConfig } from '../free-history-config';
 
 export const addNodeChange: ContentChangeTypeToOperation<AddWorkflowNodeOperation> = {
   type: WorkflowContentChangeType.ADD_NODE,
-  toOperation: async (event, ctx) => {
+  toOperation: (event, ctx) => {
     const config = ctx.get<FreeHistoryConfig>(FreeHistoryConfig);
     const document = ctx.get<WorkflowDocument>(WorkflowDocument);
     const node = event.entity as FlowNodeEntity;
     const parentID = node.parent?.id;
-    /**
-     * 由于document.toNodeJSON依赖表单里面的default的值初始化,故此处需要等表单的初始化完成
-     * 比如dataset-node/index.ts中formatOnSubmit实现需要value被初始化
-     */
-    await delay(10);
-    const json: WorkflowNodeJSON = await document.toNodeJSON(node);
+    const json: WorkflowNodeJSON = document.toNodeJSON(node);
 
     return {
       type: FreeOperationType.addNode,

+ 3 - 6
packages/plugins/free-history-plugin/src/changes/delete-node-change.ts

@@ -1,4 +1,4 @@
-import { WorkflowDocument, WorkflowContentChangeType, delay } from '@flowgram.ai/free-layout-core';
+import { WorkflowDocument, WorkflowContentChangeType } from '@flowgram.ai/free-layout-core';
 import { type FlowNodeEntity } from '@flowgram.ai/document';
 
 import {
@@ -10,16 +10,13 @@ import { FreeHistoryConfig } from '../free-history-config';
 
 export const deleteNodeChange: ContentChangeTypeToOperation<DeleteWorkflowNodeOperation> = {
   type: WorkflowContentChangeType.DELETE_NODE,
-  toOperation: async (event, ctx) => {
+  toOperation: (event, ctx) => {
     const config = ctx.get<FreeHistoryConfig>(FreeHistoryConfig);
     const document = ctx.get<WorkflowDocument>(WorkflowDocument);
     const node = event.entity as FlowNodeEntity;
-    const json = await document.toNodeJSON(node);
+    const json = document.toNodeJSON(node);
     const parentID = node.parent?.id;
 
-    // 删除节点和删除连线同时触发,删除节点需放在后面执行
-    await delay(0);
-
     return {
       type: FreeOperationType.deleteNode,
       value: {

+ 1 - 1
packages/plugins/shortcuts-plugin/src/shortcuts-contribution.ts

@@ -1,7 +1,7 @@
 import { inject, injectable, named, optional, postConstruct } from 'inversify';
 import { Command, CommandRegistry, ContributionProvider } from '@flowgram.ai/core';
 
-interface ShortcutsHandler {
+export interface ShortcutsHandler {
   commandId: string;
   commandDetail?: Omit<Command, 'id'>;
   shortcuts: string[];