Bläddra i källkod

feat(free-layout): workflowPortEntity add location/offset/size (#625)

xiamidaxia 5 månader sedan
förälder
incheckning
f86adbd2de

+ 1 - 1
apps/demo-free-layout-simple/src/components/node-add-panel.tsx

@@ -7,7 +7,7 @@ import React from 'react';
 
 import { WorkflowDragService, useService } from '@flowgram.ai/free-layout-editor';
 
-const cardkeys = ['Node1', 'Node2', 'Condition'];
+const cardkeys = ['Node1', 'Node2', 'Condition', 'Chain', 'Tool'];
 
 export const NodeAddPanel: React.FC = (props) => {
   const startDragSerivce = useService<WorkflowDragService>(WorkflowDragService);

+ 52 - 0
apps/demo-free-layout-simple/src/initial-data.ts

@@ -77,6 +77,48 @@ export const initialData: WorkflowJSON = {
         content: 'xxxx',
       },
     },
+    {
+      id: 'chain0',
+      type: 'chain',
+      meta: {
+        position: {
+          x: 150,
+          y: 246,
+        },
+      },
+      data: {
+        title: 'Chain',
+        content: 'xxxx',
+      },
+    },
+    {
+      id: '100260',
+      type: 'tool',
+      meta: {
+        position: {
+          x: 55.8,
+          y: 410,
+        },
+      },
+      data: {
+        title: 'New Tool',
+        content: 'xxxx',
+      },
+    },
+    {
+      id: '105108',
+      type: 'tool',
+      meta: {
+        position: {
+          x: 280.5,
+          y: 410,
+        },
+      },
+      data: {
+        title: 'New Tool',
+        content: 'xxxx',
+      },
+    },
   ],
   edges: [
     {
@@ -101,5 +143,15 @@ export const initialData: WorkflowJSON = {
       sourceNodeID: '144150',
       targetNodeID: 'end_0',
     },
+    {
+      sourceNodeID: 'chain0',
+      targetNodeID: '100260',
+      sourcePortID: 'p4',
+    },
+    {
+      sourceNodeID: 'chain0',
+      targetNodeID: '105108',
+      sourcePortID: 'p5',
+    },
   ],
 };

+ 17 - 0
apps/demo-free-layout-simple/src/node-registries.ts

@@ -26,6 +26,23 @@ export const nodeRegistries: WorkflowNodeRegistry[] = [
       useDynamicPort: true,
     },
   },
+  {
+    type: 'chain',
+    meta: {
+      defaultPorts: [
+        { type: 'input' },
+        { type: 'output' },
+        { portID: 'p4', location: 'bottom', offset: { x: -10, y: 0 }, type: 'output' },
+        { portID: 'p5', location: 'bottom', offset: { x: 10, y: 0 }, type: 'output' },
+      ],
+    },
+  },
+  {
+    type: 'tool',
+    meta: {
+      defaultPorts: [{ location: 'top', type: 'input' }],
+    },
+  },
   {
     type: 'end',
     meta: {

+ 100 - 21
packages/canvas-engine/free-layout-core/src/entities/workflow-port-entity.ts

@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { type IPoint, Rectangle, Emitter } from '@flowgram.ai/utils';
+import { type IPoint, Rectangle, Emitter, Compare } from '@flowgram.ai/utils';
 import { FlowNodeTransformData } from '@flowgram.ai/document';
 import {
   Entity,
@@ -27,11 +27,29 @@ import { type WorkflowLineEntity } from './workflow-line-entity';
 // port 的宽度
 export const PORT_SIZE = 24;
 
+export type WorkflowPortLocation = 'left' | 'top' | 'right' | 'bottom';
+
 export interface WorkflowPort {
   /**
    * 没有代表 默认连接点,默认 input 类型 为最左边中心,output 类型为最右边中心
    */
   portID?: string | number;
+  /**
+   * 输入或者输出点
+   */
+  type: WorkflowPortType;
+  /**
+   * 端口位置
+   */
+  location?: WorkflowPortLocation;
+  /**
+   * 端口热区大小
+   */
+  size?: { width: number; height: number };
+  /**
+   * 相对于 position 的偏移
+   */
+  offset?: IPoint;
   /**
    * 禁用端口
    */
@@ -40,10 +58,6 @@ export interface WorkflowPort {
    * 将点位渲染到该父节点上
    */
   targetElement?: HTMLElement;
-  /**
-   * 输入或者输出点
-   */
-  type: WorkflowPortType;
 }
 
 export type WorkflowPorts = WorkflowPort[];
@@ -63,22 +77,25 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
 
   readonly node: WorkflowNodeEntity;
 
-  targetElement?: HTMLElement;
-
   readonly portID: string | number = '';
 
-  readonly _disabled: boolean = false;
+  readonly portType: WorkflowPortType;
+
+  private _disabled?: boolean;
 
   private _hasError = false;
 
+  private _location?: WorkflowPortLocation;
+
+  private _size?: { width: number; height: number };
+
+  private _offset?: IPoint;
+
   protected readonly _onErrorChangedEmitter = new Emitter<void>();
 
   onErrorChanged = this._onErrorChangedEmitter.event;
 
-  /**
-   * port 类型
-   */
-  portType: WorkflowPortType;
+  targetElement?: HTMLElement;
 
   static getPortEntityId(
     node: WorkflowNodeEntity,
@@ -88,12 +105,18 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
     return getPortEntityId(node, portType, portID);
   }
 
-  // relativePosition
+  get position(): WorkflowPortLocation | undefined {
+    return this._location;
+  }
+
   constructor(opts: WorkflowPortEntityOpts) {
     super(opts);
     this.portID = opts.portID || '';
     this.portType = opts.type;
-    this._disabled = opts.disabled ?? false;
+    this._disabled = opts.disabled;
+    this._offset = opts.offset;
+    this._location = opts.location;
+    this._size = opts.size;
     this.node = opts.node;
     this.updateTargetElement(opts.targetElement);
     this.toDispose.push(this.node.getData(TransformData)!.onDataChange(() => this.fireChange()));
@@ -144,20 +167,49 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
           clientY: pos.y,
         });
     }
-    if (this.portType === 'input') {
-      // 默认为左边重点
-      return bounds.leftCenter;
+    let point = { x: 0, y: 0 };
+    const offset = this._offset || { x: 0, y: 0 };
+    if (this._location) {
+      switch (this._location) {
+        case 'left':
+          point = bounds.leftCenter;
+          break;
+        case 'top':
+          point = bounds.topCenter;
+          break;
+        case 'right':
+          point = bounds.rightCenter;
+          break;
+        case 'bottom':
+          point = bounds.bottomCenter;
+          break;
+      }
+    } else {
+      if (this.portType === 'input') {
+        // 默认为左边重点
+        point = bounds.leftCenter;
+      } else {
+        point = bounds.rightCenter;
+      }
     }
-    return bounds.rightCenter;
+    return {
+      x: point.x + offset.x,
+      y: point.y + offset.y,
+    };
   }
 
   /**
-   * 点的区域
+   * 端口热区
    */
   get bounds(): Rectangle {
     const { point } = this;
-    const halfSize = PORT_SIZE / 2;
-    return new Rectangle(point.x - halfSize, point.y - halfSize, PORT_SIZE, PORT_SIZE);
+    const size = this._size || { width: PORT_SIZE, height: PORT_SIZE };
+    return new Rectangle(
+      point.x - size.width / 2,
+      point.y - size.height / 2,
+      size.width,
+      size.height
+    );
   }
 
   isHovered(x: number, y: number): boolean {
@@ -235,6 +287,33 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
     return lines;
   }
 
+  update(data: Exclude<WorkflowPort, 'portID' | 'type'>) {
+    let changed = false;
+    if (data.targetElement !== this.targetElement) {
+      this.targetElement = data.targetElement;
+      changed = true;
+    }
+    if (data.location !== this._location) {
+      this._location = data.location;
+      changed = true;
+    }
+    if (Compare.isChanged(data.offset, this._offset)) {
+      this._offset = data.offset;
+      changed = true;
+    }
+    if (Compare.isChanged(data.size, this._size)) {
+      this._size = data.size;
+      changed = true;
+    }
+    if (data.disabled !== this._disabled) {
+      this._disabled = data.disabled;
+      changed = true;
+    }
+    if (changed) {
+      this.fireChange();
+    }
+  }
+
   dispose(): void {
     // 点位被删除,对应的线条也要删除
     this.lines.forEach((l) => l.dispose());

+ 15 - 7
packages/canvas-engine/free-layout-core/src/entity-datas/workflow-node-ports-data.ts

@@ -62,11 +62,14 @@ export class WorkflowNodePortsData extends EntityData {
   }
 
   /**
-   * 更新静态的 ports 数据
+   * Update all ports data, includes static ports and dynamic ports
+   * @param ports
    */
-  public updateStaticPorts(ports: WorkflowPorts): void {
+  public updateAllPorts(ports?: WorkflowPorts) {
     const meta = this.entity.getNodeMeta<WorkflowNodeMeta>();
-    this._staticPorts = ports;
+    if (ports) {
+      this._staticPorts = ports;
+    }
     if (meta.useDynamicPort) {
       this.updateDynamicPorts();
     } else {
@@ -74,6 +77,13 @@ export class WorkflowNodePortsData extends EntityData {
     }
   }
 
+  /**
+   * @deprecated use `updateAllPorts` instead
+   */
+  public updateStaticPorts(ports: WorkflowPorts): void {
+    this.updateAllPorts(ports);
+  }
+
   /**
    * 动态计算点位,通过 dom 的 data-port-key
    */
@@ -111,7 +121,7 @@ export class WorkflowNodePortsData extends EntityData {
   /**
    * 更新 ports 数据
    */
-  public updatePorts(ports: WorkflowPorts): void {
+  protected updatePorts(ports: WorkflowPorts): void {
     if (!isEqual(this._prePorts, ports)) {
       const portKeys = ports.map((port) => this.getPortId(port.type, port.portID));
       this._portIDSet.forEach((portId) => {
@@ -237,9 +247,7 @@ export class WorkflowNodePortsData extends EntityData {
    */
   protected updatePortEntity(portInfo: WorkflowPort): WorkflowPortEntity {
     const portEntity = this.getOrCreatePortEntity(portInfo);
-    if (portInfo.targetElement) {
-      portEntity.updateTargetElement(portInfo.targetElement);
-    }
+    portEntity.update(portInfo);
     return portEntity;
   }
 }

+ 8 - 1
packages/canvas-engine/free-layout-core/src/typings/workflow-registry.ts

@@ -7,8 +7,9 @@ import type { FormMeta } from '@flowgram.ai/node';
 import type { FormMetaOrFormMetaGenerator } from '@flowgram.ai/form-core';
 import type { FlowNodeRegistry } from '@flowgram.ai/document';
 
-import type { WorkflowNodeEntity } from '../entities';
+import type { WorkflowNodeEntity, WorkflowPortEntity } from '../entities';
 import type { WorkflowNodeMeta } from './workflow-node';
+import type { WorkflowLinesManager } from '../workflow-lines-manager';
 
 /**
  * 节点表单引擎配置
@@ -20,6 +21,12 @@ export type WorkflowNodeFormMeta = FormMetaOrFormMetaGenerator | FormMeta;
  */
 export interface WorkflowNodeRegistry extends FlowNodeRegistry<WorkflowNodeMeta> {
   formMeta?: WorkflowNodeFormMeta;
+  canAddLine?: (
+    fromPort: WorkflowPortEntity,
+    toPort: WorkflowPortEntity,
+    lines: WorkflowLinesManager,
+    silent?: boolean
+  ) => boolean;
 }
 
 export interface WorkflowNodeRenderProps {

+ 9 - 0
packages/canvas-engine/free-layout-core/src/workflow-lines-manager.ts

@@ -22,6 +22,7 @@ import {
   type WorkflowContentChangeEvent,
   WorkflowContentChangeType,
   type WorkflowEdgeJSON,
+  WorkflowNodeRegistry,
 } from './typings';
 import { WorkflowHoverService, WorkflowSelectService } from './service';
 import { WorkflowNodeLinesData } from './entity-datas/workflow-node-lines-data';
@@ -361,6 +362,14 @@ export class WorkflowLinesManager {
     ) {
       return false;
     }
+    const fromCanAdd = fromPort.node.getNodeRegistry<WorkflowNodeRegistry>().canAddLine;
+    const toCanAdd = toPort.node.getNodeRegistry<WorkflowNodeRegistry>().canAddLine;
+    if (fromCanAdd && !fromCanAdd(fromPort, toPort, this, silent)) {
+      return false;
+    }
+    if (toCanAdd && !toCanAdd(fromPort, toPort, this, silent)) {
+      return false;
+    }
     if (this.options.canAddLine) {
       return this.options.canAddLine(fromPort, toPort, this, silent);
     }

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

@@ -225,5 +225,18 @@ export namespace FreeLayoutProps {
    */
   export const DEFAULT: FreeLayoutProps = {
     ...EditorProps.DEFAULT,
+    isVerticalLine(ctx, line) {
+      const fromPosition = line.fromPort.position;
+      const toPosition = line.toPort?.position;
+      if (
+        fromPosition === 'top' ||
+        fromPosition === 'bottom' ||
+        toPosition === 'top' ||
+        toPosition === 'bottom'
+      ) {
+        return true;
+      }
+      return false;
+    },
   } as FreeLayoutProps;
 }