소스 검색

feat(core): operation-fromJSON supports diff JSON to perform CRUD on nodes and edges (#890)

* feat(core): operation-fromJSON supports diff JSON to perform CRUD on nodes and edges

* fix(core): operation fromJSON re-build all lines
Louis Young 3 달 전
부모
커밋
b29701d9d7

+ 2 - 0
apps/demo-free-layout/src/components/comment/constant.ts

@@ -12,6 +12,8 @@ export enum CommentEditorFormField {
 
 /** 编辑器事件 */
 export enum CommentEditorEvent {
+  /** 初始化事件 */
+  Init = 'init',
   /** 内容变更事件 */
   Change = 'change',
   /** 多选事件 */

+ 1 - 1
apps/demo-free-layout/src/components/comment/hooks/use-model.ts

@@ -35,7 +35,7 @@ export const useModel = () => {
   // 同步表单值初始化
   useEffect(() => {
     const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
-    model.setValue(value); // 设置初始值
+    model.setInitValue(value); // 设置初始值
     model.selectEnd(); // 设置初始化光标位置
   }, [formModel, model]);
 

+ 1 - 1
apps/demo-free-layout/src/components/comment/hooks/use-overflow.ts

@@ -36,7 +36,7 @@ export const useOverflow = (params: { model: CommentEditorModel; height: number
   // 监听 change 事件
   useEffect(() => {
     const changeDisposer = model.on((params) => {
-      if (params.type !== CommentEditorEvent.Change) {
+      if (params.type !== CommentEditorEvent.Change && params.type !== CommentEditorEvent.Init) {
         return;
       }
       updateOverflow();

+ 16 - 0
apps/demo-free-layout/src/components/comment/model.ts

@@ -39,6 +39,22 @@ export class CommentEditorModel {
     });
   }
 
+  /** 外部设置模型值 */
+  public setInitValue(value: string = CommentEditorDefaultValue): void {
+    if (!this.initialized) {
+      return;
+    }
+    if (value === this.innerValue) {
+      return;
+    }
+    this.innerValue = value;
+    this.syncEditorValue();
+    this.emitter.fire({
+      type: CommentEditorEvent.Init,
+      value: this.innerValue,
+    });
+  }
+
   public set element(el: HTMLTextAreaElement) {
     if (this.initialized) {
       return;

+ 7 - 1
apps/demo-free-layout/src/components/comment/type.ts

@@ -22,8 +22,14 @@ interface CommentEditorBlurEvent {
   type: CommentEditorEvent.Blur;
 }
 
+interface CommentEditorInitEvent {
+  type: CommentEditorEvent.Init;
+  value: string;
+}
+
 export type CommentEditorEventParams =
   | CommentEditorChangeEvent
   | CommentEditorMultiSelectEvent
   | CommentEditorSelectEvent
-  | CommentEditorBlurEvent;
+  | CommentEditorBlurEvent
+  | CommentEditorInitEvent;

+ 61 - 0
packages/canvas-engine/free-layout-core/src/service/workflow-operation-base-service.ts

@@ -8,11 +8,14 @@ import { IPoint, Emitter } from '@flowgram.ai/utils';
 import { FlowNodeEntityOrId, FlowOperationBaseServiceImpl } from '@flowgram.ai/document';
 import { TransformData } from '@flowgram.ai/core';
 
+import { WorkflowLinesManager } from '../workflow-lines-manager';
 import { WorkflowDocument } from '../workflow-document';
 import {
   NodePostionUpdateEvent,
   WorkflowOperationBaseService,
 } from '../typings/workflow-operation';
+import { WorkflowJSON } from '../typings';
+import { WorkflowNodeEntity, WorkflowLineEntity } from '../entities';
 
 export class WorkflowOperationBaseServiceImpl
   extends FlowOperationBaseServiceImpl
@@ -21,6 +24,8 @@ export class WorkflowOperationBaseServiceImpl
   @inject(WorkflowDocument)
   protected declare document: WorkflowDocument;
 
+  @inject(WorkflowLinesManager) linesManager: WorkflowLinesManager;
+
   private onNodePostionUpdateEmitter = new Emitter<NodePostionUpdateEvent>();
 
   public readonly onNodePostionUpdate = this.onNodePostionUpdateEmitter.event;
@@ -47,4 +52,60 @@ export class WorkflowOperationBaseServiceImpl
       newPosition: position,
     });
   }
+
+  fromJSON(json: WorkflowJSON) {
+    if (this.document.disposed) return;
+    const workflowJSON: WorkflowJSON = {
+      nodes: json.nodes ?? [],
+      edges: json.edges ?? [],
+    };
+
+    const oldNodes = this.document.getAllNodes();
+    const oldPositionMap = new Map<string, IPoint>(
+      oldNodes.map((node) => [
+        node.id,
+        {
+          x: node.transform.transform.position.x,
+          y: node.transform.transform.position.y,
+        },
+      ])
+    );
+
+    const newNodes: WorkflowNodeEntity[] = [];
+    const newEdges: WorkflowLineEntity[] = [];
+
+    // 清空线条
+    this.linesManager.getAllLines().map((line) => line.dispose());
+
+    // 逐层渲染
+    this.document.batchAddFromJSON(workflowJSON, {
+      onNodeCreated: (node) => newNodes.push(node),
+      onEdgeCreated: (edge) => newEdges.push(edge),
+    });
+
+    const newNodeIDSet = new Set<string>(newNodes.map((node) => node.id));
+    oldNodes.forEach((node) => {
+      // 清空旧节点
+      if (!newNodeIDSet.has(node.id)) {
+        node.dispose();
+        return;
+      }
+      // 记录现有节点位置变更
+      const oldPosition = oldPositionMap.get(node.id);
+      const newPosition = {
+        x: node.transform.transform.position.x,
+        y: node.transform.transform.position.y,
+      };
+      if (oldPosition && (oldPosition.x !== newPosition.x || oldPosition.y !== newPosition.y)) {
+        this.onNodePostionUpdateEmitter.fire({
+          node,
+          oldPosition,
+          newPosition,
+        });
+      }
+    });
+
+    // 批量触发画布更新
+    this.document.fireRender();
+  }
 }

+ 7 - 0
packages/canvas-engine/free-layout-core/src/typings/workflow-operation.ts

@@ -10,6 +10,8 @@ import {
   FlowOperationBaseService,
 } from '@flowgram.ai/document';
 
+import { WorkflowJSON } from './workflow-json';
+
 export interface NodePostionUpdateEvent {
   node: FlowNodeEntity;
   oldPosition: IPoint;
@@ -28,6 +30,11 @@ export interface WorkflowOperationBaseService extends FlowOperationBaseService {
    * @returns
    */
   updateNodePosition(nodeOrId: FlowNodeEntityOrId, position: IPoint): void;
+
+  /**
+   * 更新节点与线条
+   */
+  fromJSON(json: WorkflowJSON): void;
 }
 
 export const WorkflowOperationBaseService = Symbol('WorkflowOperationBaseService');

+ 10 - 1
packages/canvas-engine/free-layout-core/src/workflow-document.ts

@@ -191,9 +191,11 @@ export class WorkflowDocument extends FlowDocument {
     json: WorkflowNodeJSON,
     options?: {
       parentID?: string;
+      onNodeCreated?: (node: WorkflowNodeEntity) => void;
+      onEdgeCreated?: (edge: WorkflowLineEntity) => void;
     }
   ): WorkflowNodeEntity {
-    const { parentID } = options ?? {};
+    const { parentID, onNodeCreated, onEdgeCreated } = options ?? {};
     // 是否是一个已经存在的节点
     const isExistedNode = this.getNode(json.id);
     const parent = this.getNode(parentID ?? this.root.id) ?? this.root;
@@ -293,6 +295,8 @@ export class WorkflowDocument extends FlowDocument {
         { nodes: json.blocks, edges: json.edges ?? [] },
         {
           parent: node,
+          onNodeCreated,
+          onEdgeCreated,
         }
       );
     }
@@ -729,6 +733,8 @@ export class WorkflowDocument extends FlowDocument {
     json: WorkflowJSON,
     options?: {
       parent?: WorkflowNodeEntity;
+      onNodeCreated?: (node: WorkflowNodeEntity) => void;
+      onEdgeCreated?: (edge: WorkflowLineEntity) => void;
     }
   ): {
     nodes: WorkflowNodeEntity[];
@@ -747,6 +753,9 @@ export class WorkflowDocument extends FlowDocument {
     const edges = processedJSON.edges
       .map((edge) => this.createWorkflowLine(edge, parentID))
       .filter(Boolean) as WorkflowLineEntity[];
+    // 触发回调
+    nodes.forEach((node) => options?.onNodeCreated?.(node));
+    edges.forEach((edge) => options?.onEdgeCreated?.(edge));
     return { nodes, edges };
   }
 

+ 17 - 1
packages/client/free-layout-editor/__tests__/free-layout-preset.test.ts

@@ -6,20 +6,36 @@
 import React from 'react';
 
 import { describe, it, expect } from 'vitest';
+import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
 import { FlowDocument, FlowNodeFormData } from '@flowgram.ai/editor';
 
+import { WorkflowOperationService } from '../src/types';
 import { mockJSON, mockJSON2, mockSimpleJSON, mockSimpleJSON2 } from '../__mocks__/flow.mocks';
 import { createEditor } from './create-editor';
 
 describe('free-layout-preset', () => {
   it('fromJSON and toJSON', () => {
     const editor = createEditor({});
-    const document = editor.get(FlowDocument);
+    const document = editor.get(WorkflowDocument);
     document.fromJSON(mockJSON);
     expect(document.toJSON()).toEqual(mockJSON);
     document.fromJSON(mockJSON2);
     expect(document.toJSON()).toEqual(mockJSON2);
   });
+  it('operation fromJSON', () => {
+    const editor = createEditor({
+      history: {
+        enable: true,
+      },
+    });
+    const operation = editor.get<WorkflowOperationService>(WorkflowOperationService);
+    const document = editor.get(WorkflowDocument);
+    operation.fromJSON(mockJSON);
+    expect(document.toJSON()).toEqual(mockJSON);
+    document.clear();
+    operation.fromJSON(mockJSON2);
+    expect(document.toJSON()).toEqual(mockJSON2);
+  });
   it('custom fromNodeJSON and toNodeJSON', () => {
     const container = createEditor({
       fromNodeJSON: (node, json, isFirstCreate) => {

+ 24 - 0
packages/client/free-layout-editor/src/plugins/create-operation-plugin.ts

@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { definePluginCreator } from '@flowgram.ai/editor';
+
+import { WorkflowOperationService } from '../types';
+import { HistoryOperationServiceImpl } from '../services/history-operation-service';
+import { WorkflowOperationServiceImpl } from '../services/flow-operation-service';
+import { FreeLayoutProps } from '../preset';
+
+export const createOperationPlugin = definePluginCreator<FreeLayoutProps>({
+  onBind: ({ bind }, opts) => {
+    bind(WorkflowOperationService)
+      .to(opts?.history?.enable ? HistoryOperationServiceImpl : WorkflowOperationServiceImpl)
+      .inSingletonScope();
+  },
+  onDispose: (ctx) => {
+    const flowOperationService =
+      ctx.container.get<WorkflowOperationService>(WorkflowOperationService);
+    flowOperationService.dispose();
+  },
+});

+ 2 - 0
packages/client/free-layout-editor/src/preset/free-layout-preset.ts

@@ -38,6 +38,7 @@ import {
 } from '@flowgram.ai/editor';
 
 import { WorkflowAutoLayoutTool } from '../tools';
+import { createOperationPlugin } from '../plugins/create-operation-plugin';
 import { fromNodeJSON, toNodeJSON } from './node-serialize';
 import { FreeLayoutProps, FreeLayoutPluginContext } from './free-layout-props';
 
@@ -206,6 +207,7 @@ export function createFreeLayoutPreset(
         },
         containerModules: [WorkflowDocumentContainerModule],
       }),
+      createOperationPlugin(opts),
       /**
        * 渲染层级管理
        */

+ 19 - 0
packages/client/free-layout-editor/src/services/flow-operation-service.ts

@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { injectable } from 'inversify';
+import { WorkflowOperationBaseServiceImpl } from '@flowgram.ai/free-layout-core';
+
+import { WorkflowOperationService } from '../types';
+
+@injectable()
+export class WorkflowOperationServiceImpl
+  extends WorkflowOperationBaseServiceImpl
+  implements WorkflowOperationService
+{
+  startTransaction(): void {}
+
+  endTransaction(): void {}
+}

+ 39 - 0
packages/client/free-layout-editor/src/services/history-operation-service.ts

@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { inject, injectable } from 'inversify';
+import { HistoryService } from '@flowgram.ai/history';
+import { WorkflowJSON } from '@flowgram.ai/free-layout-core';
+
+import { WorkflowOperationServiceImpl } from './flow-operation-service';
+import { WorkflowOperationService } from '../types';
+
+@injectable()
+export class HistoryOperationServiceImpl
+  extends WorkflowOperationServiceImpl
+  implements WorkflowOperationService
+{
+  @inject(HistoryService)
+  protected historyService: HistoryService;
+
+  startTransaction(): void {
+    this.historyService.startTransaction();
+  }
+
+  endTransaction(): void {
+    this.historyService.endTransaction();
+  }
+
+  fromJSON(json: WorkflowJSON): void {
+    this.startTransaction();
+    try {
+      super.fromJSON(json);
+    } catch (e) {
+      // eslint-disable-next-line no-console
+      console.log('fromJSON error', e);
+    }
+    this.endTransaction();
+  }
+}

+ 21 - 0
packages/client/free-layout-editor/src/types.ts

@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowJSON, WorkflowOperationBaseService } from '@flowgram.ai/free-layout-core';
+
+export interface WorkflowOperationService extends WorkflowOperationBaseService {
+  /**
+   * 开始事务
+   */
+  startTransaction(): void;
+  /**
+   * 结束事务
+   */
+  endTransaction(): void;
+
+  fromJSON(json: WorkflowJSON): void;
+}
+
+export const WorkflowOperationService = Symbol('WorkflowOperationService');