ソースを参照

fix(auto-layout): inconsistent auto-layout animation (#838)

* fix(auto-layout): inconsistent auto-layout animation

* fix: test error
Louis Young 3 ヶ月 前
コミット
ab389d04e2

+ 3 - 1
apps/demo-free-layout/src/components/tools/auto-layout.tsx

@@ -14,7 +14,9 @@ export const AutoLayout = () => {
   const tools = usePlaygroundTools();
   const playground = usePlayground();
   const autoLayout = useCallback(async () => {
-    await tools.autoLayout();
+    await tools.autoLayout({
+      enableAnimation: true,
+    });
   }, [tools]);
 
   return (

+ 1 - 1
packages/client/free-layout-editor/__tests__/use-playground-tools.test.ts

@@ -75,7 +75,7 @@ describe(
       const endPos = doc.getNode('end_0')!.getData(PositionData)!;
       expect(endPos.x - startPos.x).toEqual(800);
       const revert = await toolsData.current.autoLayout();
-      expect(endPos.x - startPos.x).toEqual(760);
+      expect(endPos.x - startPos.x).toEqual(880);
       revert(); // 回滚
       expect(endPos.x - startPos.x).toEqual(800);
     });

+ 1 - 0
packages/plugins/free-auto-layout-plugin/src/layout/constant.ts

@@ -20,4 +20,5 @@ export const DefaultLayoutConfig: LayoutConfig = {
 export const DefaultLayoutOptions: LayoutOptions = {
   getFollowNode: undefined,
   enableAnimation: false,
+  animationDuration: 300,
 };

+ 15 - 14
packages/plugins/free-auto-layout-plugin/src/layout/dagre.ts

@@ -68,13 +68,7 @@ export class DagreLayout {
   }
 
   private graphSetData(): void {
-    const nodes = Array.from(this.store.nodes.values());
-    const edges = Array.from(this.store.edges.values()).sort((next, prev) => {
-      if (next.fromIndex === prev.fromIndex) {
-        return next.toIndex! < prev.toIndex! ? -1 : 1;
-      }
-      return next.fromIndex < prev.fromIndex ? -1 : 1;
-    });
+    const { nodes, edges } = this.store;
     nodes.forEach((layoutNode) => {
       this.graph.setNode(layoutNode.index, {
         originID: layoutNode.id,
@@ -82,13 +76,20 @@ export class DagreLayout {
         height: layoutNode.size.height,
       });
     });
-    edges.forEach((layoutEdge) => {
-      this.graph.setEdge({
-        v: layoutEdge.fromIndex,
-        w: layoutEdge.toIndex,
-        name: layoutEdge.name,
+    edges
+      .sort((next, prev) => {
+        if (next.fromIndex === prev.fromIndex) {
+          return next.toIndex! < prev.toIndex! ? -1 : 1;
+        }
+        return next.fromIndex < prev.fromIndex ? -1 : 1;
+      })
+      .forEach((layoutEdge) => {
+        this.graph.setEdge({
+          v: layoutEdge.fromIndex,
+          w: layoutEdge.toIndex,
+          name: layoutEdge.name,
+        });
       });
-    });
   }
 
   private layoutSetPosition(): void {
@@ -118,7 +119,7 @@ export class DagreLayout {
   }
 
   private getOffsetX(layoutNode: LayoutNode): number {
-    if (!layoutNode.hasChildren) {
+    if (layoutNode.layoutNodes.length === 0) {
       return 0;
     }
     // 存在子节点才需计算padding带来的偏移

+ 23 - 2
packages/plugins/free-auto-layout-plugin/src/layout/layout.ts

@@ -3,12 +3,14 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { LayoutConfig, LayoutOptions, LayoutParams } from './type';
+import { Rectangle } from '@flowgram.ai/utils';
+
+import { ILayout, LayoutConfig, LayoutNode, LayoutOptions, LayoutParams, LayoutSize } from './type';
 import { LayoutStore } from './store';
 import { LayoutPosition } from './position';
 import { DagreLayout } from './dagre';
 
-export class Layout {
+export class Layout implements ILayout {
   private readonly _store: LayoutStore;
 
   private readonly _layout: DagreLayout;
@@ -38,4 +40,23 @@ export class Layout {
     }
     return await this._position.position();
   }
+
+  public get size(): LayoutSize {
+    if (!this._store.initialized) {
+      return Rectangle.EMPTY;
+    }
+    const rects = this._store.nodes.map((node) => this.layoutNodeRect(node));
+    const rect = Rectangle.enlarge(rects);
+    const { padding } = this._store.container.entity.transform;
+    return {
+      width: rect.width + padding.left + padding.right,
+      height: rect.height + padding.top + padding.bottom,
+    };
+  }
+
+  private layoutNodeRect(layoutNode: LayoutNode): Rectangle {
+    const { width, height } = layoutNode.size;
+    const { x, y } = layoutNode.position;
+    return new Rectangle(x, y, width, height);
+  }
 }

+ 2 - 2
packages/plugins/free-auto-layout-plugin/src/layout/position.ts

@@ -4,7 +4,7 @@
  */
 
 import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
-import { PositionSchema, startTween, TransformData } from '@flowgram.ai/core';
+import { PositionSchema, startTween } from '@flowgram.ai/core';
 
 import { LayoutNode } from './type';
 import { LayoutStore } from './store';
@@ -30,7 +30,7 @@ export class LayoutPosition {
       startTween({
         from: { d: 0 },
         to: { d: 100 },
-        duration: 300,
+        duration: this.store.options.animationDuration ?? 0,
         onUpdate: (v) => {
           this.store.nodes.forEach((layoutNode) => {
             this.updateNodePosition({ layoutNode, step: v.d });

+ 16 - 69
packages/plugins/free-auto-layout-plugin/src/layout/store.ts

@@ -3,38 +3,31 @@
  * SPDX-License-Identifier: MIT
  */
 
-import {
-  WorkflowLineEntity,
-  WorkflowNodeEntity,
-  WorkflowNodeLinesData,
-} from '@flowgram.ai/free-layout-core';
-import { FlowNodeBaseType, FlowNodeTransformData } from '@flowgram.ai/document';
+import { WorkflowNodeEntity, WorkflowNodeLinesData } from '@flowgram.ai/free-layout-core';
+import { FlowNodeBaseType } from '@flowgram.ai/document';
 
 import type {
   GetFollowNode,
+  ILayoutStore,
   LayoutConfig,
   LayoutEdge,
   LayoutNode,
   LayoutOptions,
   LayoutParams,
+  LayoutStoreData,
 } from './type';
 
-interface LayoutStoreData {
-  nodes: Map<string, LayoutNode>;
-  edges: Map<string, LayoutEdge>;
-}
-
-export class LayoutStore {
+export class LayoutStore implements ILayoutStore {
   private indexMap: Map<string, string>;
 
   private init: boolean = false;
 
   private store: LayoutStoreData;
 
-  private container: WorkflowNodeEntity;
-
   public options: LayoutOptions;
 
+  public container: LayoutNode;
+
   constructor(public readonly config: LayoutConfig) {}
 
   public get initialized(): boolean {
@@ -66,6 +59,7 @@ export class LayoutStore {
   }
 
   public create(params: LayoutParams, options: LayoutOptions): void {
+    this.container = params.container;
     this.store = this.createStore(params);
     this.indexMap = this.createIndexMap();
     this.setOptions(options);
@@ -74,10 +68,7 @@ export class LayoutStore {
 
   /** 创建布局数据 */
   private createStore(params: LayoutParams): LayoutStoreData {
-    const { nodes, edges, container } = params;
-    this.container = container;
-    const layoutNodes = this.createLayoutNodes(nodes);
-    const layoutEdges = this.createEdgesStore(edges);
+    const { layoutNodes, layoutEdges } = params;
     const virtualEdges = this.createVirtualEdges(params);
     const store = {
       nodes: new Map(),
@@ -88,55 +79,11 @@ export class LayoutStore {
     return store;
   }
 
-  /** 创建节点布局数据 */
-  private createLayoutNodes(nodes: WorkflowNodeEntity[]): LayoutNode[] {
-    const layoutNodes = nodes.map((node, index) => {
-      const { bounds } = node.getData(FlowNodeTransformData);
-      const layoutNode: LayoutNode = {
-        id: node.id,
-        entity: node,
-        index: '', // 初始化时,index 未计算
-        rank: -1, // 初始化时,节点还未布局,层级为-1
-        order: -1, // 初始化时,节点还未布局,顺序为-1
-        position: { x: bounds.center.x, y: bounds.center.y },
-        offset: { x: 0, y: 0 },
-        size: { width: bounds.width, height: bounds.height },
-        hasChildren: node.blocks?.length > 0,
-      };
-      return layoutNode;
-    });
-    return layoutNodes;
-  }
-
-  /** 创建线条布局数据 */
-  private createEdgesStore(edges: WorkflowLineEntity[]): LayoutEdge[] {
-    const layoutEdges = edges
-      .map((edge) => {
-        const { from, to } = edge.info;
-        if (!from || !to || edge.vertical) {
-          return;
-        }
-        const layoutEdge: LayoutEdge = {
-          id: edge.id,
-          entity: edge,
-          from,
-          to,
-          fromIndex: '', // 初始化时,index 未计算
-          toIndex: '', // 初始化时,index 未计算
-          name: edge.id,
-        };
-        return layoutEdge;
-      })
-      .filter(Boolean) as LayoutEdge[];
-    return layoutEdges;
-  }
-
   /** 创建虚拟线条数据 */
-  private createVirtualEdges(params: {
-    nodes: WorkflowNodeEntity[];
-    edges: WorkflowLineEntity[];
-  }): LayoutEdge[] {
-    const { nodes, edges } = params;
+  private createVirtualEdges(params: LayoutParams): LayoutEdge[] {
+    const { layoutNodes, layoutEdges } = params;
+    const nodes = layoutNodes.map((layoutNode) => layoutNode.entity);
+    const edges = layoutEdges.map((layoutEdge) => layoutEdge.entity);
     const groupNodes = nodes.filter((n) => n.flowNodeType === FlowNodeBaseType.GROUP);
     const virtualEdges = groupNodes
       .map((group) => {
@@ -242,8 +189,8 @@ export class LayoutStore {
 
     // 第3级排序:按照从开始节点进行遍历排序
     const visited = new Set<string>();
-    const visit = (node: WorkflowNodeEntity) => {
-      if (visited.has(node.id)) {
+    const visit = (node?: WorkflowNodeEntity) => {
+      if (!node || visited.has(node.id)) {
         return;
       }
       visited.add(node.id);
@@ -277,7 +224,7 @@ export class LayoutStore {
         visit(to);
       });
     };
-    visit(this.container);
+    visit(this.container.entity);
 
     // 使用 reduceRight 去重并保留最后一个出现的节点 id
     const uniqueNodeIds: string[] = nodeIdList.reduceRight((acc: string[], nodeId: string) => {

+ 38 - 11
packages/plugins/free-auto-layout-plugin/src/layout/type.ts

@@ -5,7 +5,34 @@
 
 import type { WorkflowLineEntity, WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
 
-import { LayoutStore } from './store';
+export interface LayoutStoreData {
+  nodes: Map<string, LayoutNode>;
+  edges: Map<string, LayoutEdge>;
+}
+
+export interface ILayoutStore {
+  container: LayoutNode;
+  options: LayoutOptions;
+  get initialized(): boolean;
+  getNode(id?: string): LayoutNode | undefined;
+  getNodeByIndex(index: string): LayoutNode | undefined;
+  getEdge(id: string): LayoutEdge | undefined;
+  nodes: LayoutNode[];
+  edges: LayoutEdge[];
+  create(params: LayoutParams, options: LayoutOptions): void;
+}
+
+export interface ILayout {
+  init(params: LayoutParams, options: LayoutOptions): void;
+  layout(): void;
+  position(): Promise<void>;
+  get size(): LayoutSize;
+}
+
+export interface LayoutSize {
+  width: number;
+  height: number;
+}
 
 export interface LayoutNode {
   id: string;
@@ -28,12 +55,11 @@ export interface LayoutNode {
     y: number;
   };
   /** 宽高 */
-  size: {
-    width: number;
-    height: number;
-  };
-  /** 是否存在子节点 */
-  hasChildren: boolean;
+  size: LayoutSize;
+  /** 子节点 */
+  layoutNodes: LayoutNode[];
+  /** 子线条 */
+  layoutEdges: LayoutEdge[];
   /** 被跟随节点 */
   followedBy?: string[];
   /** 跟随节点 */
@@ -64,14 +90,15 @@ export interface DagreNode {
 }
 
 export interface LayoutParams {
-  nodes: WorkflowNodeEntity[];
-  edges: WorkflowLineEntity[];
-  container: WorkflowNodeEntity;
+  container: LayoutNode;
+  layoutNodes: LayoutNode[];
+  layoutEdges: LayoutEdge[];
 }
 
 export interface LayoutOptions {
   getFollowNode?: GetFollowNode;
   enableAnimation?: boolean;
+  animationDuration?: number;
 }
 
 export interface LayoutConfig {
@@ -98,7 +125,7 @@ export interface LayoutConfig {
 export type GetFollowNode = (
   node: LayoutNode,
   context: {
-    store: LayoutStore;
+    store: ILayoutStore;
     /** 业务自定义参数 */
     [key: string]: any;
   }

+ 73 - 16
packages/plugins/free-auto-layout-plugin/src/services.ts

@@ -12,7 +12,7 @@ import {
 } from '@flowgram.ai/free-layout-core';
 
 import { AutoLayoutOptions } from './type';
-import { LayoutConfig } from './layout/type';
+import { LayoutConfig, LayoutEdge, LayoutNode } from './layout/type';
 import { DefaultLayoutOptions } from './layout/constant';
 import { DefaultLayoutConfig, Layout, type LayoutOptions } from './layout';
 
@@ -30,29 +30,86 @@ export class AutoLayoutService {
   }
 
   public async layout(options: Partial<LayoutOptions> = {}): Promise<void> {
-    await this.layoutNode(this.document.root, {
+    const layoutOptions: LayoutOptions = {
       ...DefaultLayoutOptions,
       ...options,
-    });
+    };
+    const root = this.createLayoutNode(this.document.root, options);
+    const layouts = await this.layoutNode(root, layoutOptions);
+    await Promise.all(layouts.map((layout) => layout.position()));
   }
 
-  private async layoutNode(node: WorkflowNodeEntity, options: LayoutOptions): Promise<void> {
-    // 获取子节点
-    const nodes = node.blocks;
-    if (!nodes || !Array.isArray(nodes) || !nodes.length) {
-      return;
+  private async layoutNode(container: LayoutNode, options: LayoutOptions): Promise<Layout[]> {
+    const { layoutNodes, layoutEdges } = container;
+    if (layoutNodes.length === 0) {
+      return [];
     }
+    // 触发子节点布局
+    const childrenLayouts = (
+      await Promise.all(layoutNodes.map((n) => this.layoutNode(n, options)))
+    ).flat();
+    const layout = new Layout(this.layoutConfig);
+    layout.init({ container, layoutNodes, layoutEdges }, options);
+    layout.layout();
+    const { size } = layout;
+    container.size = size;
+    return [...childrenLayouts, layout];
+  }
 
-    // 获取子线条
-    const edges = this.getNodesAllLines(nodes);
+  private createLayoutNodes(nodes: WorkflowNodeEntity[], options: LayoutOptions): LayoutNode[] {
+    return nodes.map((node) => this.createLayoutNode(node, options));
+  }
 
-    // 先递归执行子节点 autoLayout
-    await Promise.all(nodes.map(async (child) => this.layoutNode(child, options)));
+  /** 创建节点布局数据 */
+  private createLayoutNode(node: WorkflowNodeEntity, options: LayoutOptions): LayoutNode {
+    const { blocks } = node;
+    const edges = this.getNodesAllLines(blocks);
 
-    const layout = new Layout(this.layoutConfig);
-    layout.init({ nodes, edges, container: node }, options);
-    layout.layout();
-    await layout.position();
+    // 创建子布局节点
+    const layoutNodes = this.createLayoutNodes(blocks, options);
+    const layoutEdges = this.createLayoutEdges(edges);
+
+    const { bounds } = node.transform;
+    const { width, height, center } = bounds;
+    const { x, y } = center;
+    const layoutNode: LayoutNode = {
+      id: node.id,
+      entity: node,
+      index: '', // 初始化时,index 未计算
+      rank: -1, // 初始化时,节点还未布局,层级为-1
+      order: -1, // 初始化时,节点还未布局,顺序为-1
+      position: { x, y },
+      offset: { x: 0, y: 0 },
+      size: { width, height },
+      layoutNodes,
+      layoutEdges,
+    };
+    return layoutNode;
+  }
+
+  private createLayoutEdges(edges: WorkflowLineEntity[]): LayoutEdge[] {
+    const layoutEdges = edges
+      .map((edge) => this.createLayoutEdge(edge))
+      .filter(Boolean) as LayoutEdge[];
+    return layoutEdges;
+  }
+
+  /** 创建线条布局数据 */
+  private createLayoutEdge(edge: WorkflowLineEntity): LayoutEdge | undefined {
+    const { from, to } = edge.info;
+    if (!from || !to || edge.vertical) {
+      return;
+    }
+    const layoutEdge: LayoutEdge = {
+      id: edge.id,
+      entity: edge,
+      from,
+      to,
+      fromIndex: '', // 初始化时,index 未计算
+      toIndex: '', // 初始化时,index 未计算
+      name: edge.id,
+    };
+    return layoutEdge;
   }
 
   private getNodesAllLines(nodes: WorkflowNodeEntity[]): WorkflowLineEntity[] {